From 5365cbdaba9531bba01304803a498c0ecd1882e1 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 3 Apr 2023 18:23:49 +0200 Subject: [PATCH] (wip) add/edit user children --- app/controllers/api/children_controller.rb | 52 +++++++++++ app/frontend/src/javascript/api/child.ts | 31 +++++++ .../components/family-account/child-form.tsx | 53 ++++++++++++ .../components/family-account/child-item.tsx | 38 ++++++++ .../components/family-account/child-modal.tsx | 69 +++++++++++++++ .../family-account/children-list.tsx | 86 +++++++++++++++++++ .../src/javascript/controllers/children.js | 23 +++++ app/frontend/src/javascript/models/child.ts | 16 ++++ app/frontend/src/javascript/router.js | 16 +++- .../templates/dashboard/children.html | 11 +++ app/frontend/templates/dashboard/nav.html | 1 + app/models/child.rb | 14 +++ app/models/user.rb | 3 + app/policies/child_policy.rb | 31 +++++++ app/views/api/children/_child.json.jbuilder | 3 + app/views/api/children/create.json.jbuilder | 3 + app/views/api/children/index.json.jbuilder | 5 ++ app/views/api/children/update.json.jbuilder | 3 + config/locales/app.public.en.yml | 3 + config/locales/app.public.fr.yml | 16 ++++ config/routes.rb | 2 + db/migrate/20230331132506_create_children.rb | 17 ++++ db/schema.rb | 17 +++- 23 files changed, 509 insertions(+), 4 deletions(-) create mode 100644 app/controllers/api/children_controller.rb create mode 100644 app/frontend/src/javascript/api/child.ts create mode 100644 app/frontend/src/javascript/components/family-account/child-form.tsx create mode 100644 app/frontend/src/javascript/components/family-account/child-item.tsx create mode 100644 app/frontend/src/javascript/components/family-account/child-modal.tsx create mode 100644 app/frontend/src/javascript/components/family-account/children-list.tsx create mode 100644 app/frontend/src/javascript/controllers/children.js create mode 100644 app/frontend/src/javascript/models/child.ts create mode 100644 app/frontend/templates/dashboard/children.html create mode 100644 app/models/child.rb create mode 100644 app/policies/child_policy.rb create mode 100644 app/views/api/children/_child.json.jbuilder create mode 100644 app/views/api/children/create.json.jbuilder create mode 100644 app/views/api/children/index.json.jbuilder create mode 100644 app/views/api/children/update.json.jbuilder create mode 100644 db/migrate/20230331132506_create_children.rb diff --git a/app/controllers/api/children_controller.rb b/app/controllers/api/children_controller.rb new file mode 100644 index 000000000..7c5a1003a --- /dev/null +++ b/app/controllers/api/children_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# API Controller for resources of type Child +# Children are used to provide a way to manage multiple users in the family account +class API::ChildrenController < API::ApiController + before_action :authenticate_user! + before_action :set_child, only: %i[show update destroy] + + def index + @children = policy_scope(Child) + end + + def show + authorize @child + end + + def create + @child = Child.new(child_params) + authorize @child + if @child.save + render status: :created + else + render json: @child.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @child + + if @child.update(child_params) + render status: :ok + else + render json: @child.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @child + @child.destroy + head :no_content + end + + private + + def set_child + @child = Child.find(params[:id]) + end + + def child_params + params.require(:child).permit(:first_name, :last_name, :email, :phone, :birthday, :user_id) + end +end diff --git a/app/frontend/src/javascript/api/child.ts b/app/frontend/src/javascript/api/child.ts new file mode 100644 index 000000000..1ff948219 --- /dev/null +++ b/app/frontend/src/javascript/api/child.ts @@ -0,0 +1,31 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Child, ChildIndexFilter } from '../models/child'; +import ApiLib from '../lib/api'; + +export default class ChildAPI { + static async index (filters: ChildIndexFilter): Promise> { + const res: AxiosResponse> = await apiClient.get(`/api/children${ApiLib.filtersToQuery(filters)}`); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/children/${id}`); + return res?.data; + } + + static async create (child: Child): Promise { + const res: AxiosResponse = await apiClient.post('/api/children', { child }); + return res?.data; + } + + static async update (child: Child): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/children/${child.id}`, { child }); + return res?.data; + } + + static async destroy (childId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/children/${childId}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/family-account/child-form.tsx b/app/frontend/src/javascript/components/family-account/child-form.tsx new file mode 100644 index 000000000..fee281548 --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/child-form.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { Child } from '../../models/child'; +import { TDateISODate } from '../../typings/date-iso'; +import { FormInput } from '../form/form-input'; + +interface ChildFormProps { + child: Child; + onChange: (field: string, value: string | TDateISODate) => void; +} + +/** + * A form for creating or editing a child. + */ +export const ChildForm: React.FC = ({ child, onChange }) => { + const { t } = useTranslation('public'); + + const { register, formState } = useForm({ + defaultValues: child + }); + + /** + * Handle the change of a child form field + */ + const handleChange = (event: React.ChangeEvent): void => { + onChange(event.target.id, event.target.value); + }; + + return ( +
+
+ {t('app.public.child_form.child_form_info')} +
+
+ + + +
+ ); +}; diff --git a/app/frontend/src/javascript/components/family-account/child-item.tsx b/app/frontend/src/javascript/components/family-account/child-item.tsx new file mode 100644 index 000000000..747d8212c --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/child-item.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Child } from '../../models/child'; +import { useTranslation } from 'react-i18next'; +import { FabButton } from '../base/fab-button'; + +interface ChildItemProps { + child: Child; + onEdit: (child: Child) => void; + onDelete: (child: Child) => void; +} + +/** + * A child item. + */ +export const ChildItem: React.FC = ({ child, onEdit, onDelete }) => { + const { t } = useTranslation('public'); + + return ( +
+
+
{t('app.public.child_item.last_name')}
+
{child.last_name}
+
+
+
{t('app.public.child_item.first_name')}
+
{child.first_name}
+
+
+
{t('app.public.child_item.birthday')}
+
{child.birthday}
+
+
+ } onClick={() => onEdit(child)} className="edit-button" /> + } onClick={() => onDelete(child)} className="delete-button" /> +
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/family-account/child-modal.tsx b/app/frontend/src/javascript/components/family-account/child-modal.tsx new file mode 100644 index 000000000..03f1486c4 --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/child-modal.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FabModal, ModalSize } from '../base/fab-modal'; +import { Child } from '../../models/child'; +import { TDateISODate } from '../../typings/date-iso'; +import ChildAPI from '../../api/child'; +import { ChildForm } from './child-form'; + +interface ChildModalProps { + child?: Child; + isOpen: boolean; + toggleModal: () => void; +} + +/** + * A modal for creating or editing a child. + */ +export const ChildModal: React.FC = ({ child, isOpen, toggleModal }) => { + const { t } = useTranslation('public'); + const [data, setData] = useState(child); + console.log(child, data); + + /** + * Save the child to the API + */ + const handleSaveChild = async (): Promise => { + try { + if (child?.id) { + await ChildAPI.update(data); + } else { + await ChildAPI.create(data); + } + toggleModal(); + } catch (error) { + console.error(error); + } + }; + + /** + * Check if the form is valid to save the child + */ + const isPreventedSaveChild = (): boolean => { + return !data?.first_name || !data?.last_name; + }; + + /** + * Handle the change of a child form field + */ + const handleChildChanged = (field: string, value: string | TDateISODate): void => { + setData({ + ...data, + [field]: value + }); + }; + + return ( + + + + ); +}; diff --git a/app/frontend/src/javascript/components/family-account/children-list.tsx b/app/frontend/src/javascript/components/family-account/children-list.tsx new file mode 100644 index 000000000..c737e2d98 --- /dev/null +++ b/app/frontend/src/javascript/components/family-account/children-list.tsx @@ -0,0 +1,86 @@ +import React, { useState, useEffect } from 'react'; +import { react2angular } from 'react2angular'; +import { Child } from '../../models/child'; +// import { ChildListItem } from './child-list-item'; +import ChildAPI from '../../api/child'; +import { User } from '../../models/user'; +import { useTranslation } from 'react-i18next'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { ChildModal } from './child-modal'; +import { ChildItem } from './child-item'; +import { FabButton } from '../base/fab-button'; + +declare const Application: IApplication; + +interface ChildrenListProps { + currentUser: User; +} + +/** + * A list of children belonging to the current user. + */ +export const ChildrenList: React.FC = ({ currentUser }) => { + const { t } = useTranslation('public'); + + const [children, setChildren] = useState>([]); + const [isOpenChildModal, setIsOpenChildModal] = useState(false); + const [child, setChild] = useState(); + + useEffect(() => { + ChildAPI.index({ user_id: currentUser.id }).then(setChildren); + }, [currentUser]); + + /** + * Open the add child modal + */ + const addChild = () => { + setIsOpenChildModal(true); + setChild({ user_id: currentUser.id } as Child); + }; + + /** + * Open the edit child modal + */ + const editChild = (child: Child) => { + setIsOpenChildModal(true); + setChild(child); + }; + + /** + * Delete a child + */ + const deleteChild = (child: Child) => { + ChildAPI.destroy(child.id).then(() => { + setChildren(children.filter(c => c.id !== child.id)); + }); + }; + + return ( +
+
+

{t('app.public.children_list.heading')}

+ + {t('app.public.children_list.add_child')} + +
+ +
+ {children.map(child => ( + + ))} +
+ setIsOpenChildModal(false)} /> +
+ ); +}; + +const ChildrenListWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('childrenList', react2angular(ChildrenListWrapper, ['currentUser'])); diff --git a/app/frontend/src/javascript/controllers/children.js b/app/frontend/src/javascript/controllers/children.js new file mode 100644 index 000000000..1fc180294 --- /dev/null +++ b/app/frontend/src/javascript/controllers/children.js @@ -0,0 +1,23 @@ +'use strict'; + +Application.Controllers.controller('ChildrenController', ['$scope', 'memberPromise', 'growl', + function ($scope, memberPromise, growl) { + // Current user's profile + $scope.user = memberPromise; + + /** + * Callback used to display a error message + */ + $scope.onError = function (message) { + console.error(message); + growl.error(message); + }; + + /** + * Callback used to display a success message + */ + $scope.onSuccess = function (message) { + growl.success(message); + }; + } +]); diff --git a/app/frontend/src/javascript/models/child.ts b/app/frontend/src/javascript/models/child.ts new file mode 100644 index 000000000..cea24bf1d --- /dev/null +++ b/app/frontend/src/javascript/models/child.ts @@ -0,0 +1,16 @@ +import { TDateISODate } from '../typings/date-iso'; +import { ApiFilter } from './api'; + +export interface ChildIndexFilter extends ApiFilter { + user_id: number, +} + +export interface Child { + id?: number, + last_name: string, + first_name: string, + email?: string, + phone?: string, + birthday: TDateISODate, + user_id: number +} diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index 5adfd6e40..8c8db892d 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -28,9 +28,9 @@ angular.module('application.router', ['ui.router']) logoBlackFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'logo-black-file' }).$promise; }], sharedTranslations: ['Translations', function (Translations) { return Translations.query(['app.shared', 'app.public.common']).$promise; }], modulesPromise: ['Setting', function (Setting) { return Setting.query({ names: "['machines_module', 'spaces_module', 'plans_module', 'invoicing_module', 'wallet_module', 'statistics_module', 'trainings_module', 'public_agenda_module', 'store_module']" }).$promise; }], - settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['public_registrations', 'store_hidden']" }).$promise; }] + settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['public_registrations', 'store_hidden', 'family_account']" }).$promise; }] }, - onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', 'modulesPromise', 'CSRF', function ($rootScope, logoFile, logoBlackFile, modulesPromise, CSRF) { + onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', 'modulesPromise', 'settingsPromise', 'CSRF', function ($rootScope, logoFile, logoBlackFile, modulesPromise, settingsPromise, CSRF) { // Retrieve Anti-CSRF tokens from cookies CSRF.setMetaTags(); // Application logo @@ -47,6 +47,9 @@ angular.module('application.router', ['ui.router']) publicAgenda: (modulesPromise.public_agenda_module === 'true'), statistics: (modulesPromise.statistics_module === 'true') }; + $rootScope.settings = { + familyAccount: (settingsPromise.family_account === 'true') + }; }] }) .state('app.public', { @@ -151,6 +154,15 @@ angular.module('application.router', ['ui.router']) } } }) + .state('app.logged.dashboard.children', { + url: '/children', + views: { + 'main@': { + templateUrl: '/dashboard/children.html', + controller: 'ChildrenController' + } + } + }) .state('app.logged.dashboard.settings', { url: '/settings', views: { diff --git a/app/frontend/templates/dashboard/children.html b/app/frontend/templates/dashboard/children.html new file mode 100644 index 000000000..8f5c2c17d --- /dev/null +++ b/app/frontend/templates/dashboard/children.html @@ -0,0 +1,11 @@ +
+
+
+ +
+ +
+ + + +
diff --git a/app/frontend/templates/dashboard/nav.html b/app/frontend/templates/dashboard/nav.html index 8da97d01a..f0dc4bd10 100644 --- a/app/frontend/templates/dashboard/nav.html +++ b/app/frontend/templates/dashboard/nav.html @@ -11,6 +11,7 @@

{{ 'app.public.common.dashboard' }}