From 6abea031823e3984c44a08fb83ddcc55bea86fd4 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Fri, 10 Mar 2023 14:13:00 +0100 Subject: [PATCH] (feat) update limitations --- .../api/plan_limitations_controller.rb | 12 ++ app/controllers/api/plans_controller.rb | 2 +- .../src/javascript/api/plan-limitation.ts | 9 + .../components/base/edit-destroy-buttons.tsx | 9 +- .../components/form/form-unsaved-list.tsx | 8 +- .../javascript/components/plans/plan-form.tsx | 7 +- .../components/plans/plan-limit-form.tsx | 188 +++++++++++------- .../components/plans/plan-limit-modal.tsx | 16 +- .../components/store/product-stock-form.tsx | 2 +- app/frontend/src/javascript/models/plan.ts | 3 +- .../modules/plans/plan-limit-form.scss | 13 -- app/models/plan_limitation.rb | 2 +- app/policies/plan_limitation_policy.rb | 8 + config/locales/app.admin.en.yml | 2 + config/routes.rb | 1 + package.json | 2 +- yarn.lock | 8 +- 17 files changed, 185 insertions(+), 107 deletions(-) create mode 100644 app/controllers/api/plan_limitations_controller.rb create mode 100644 app/frontend/src/javascript/api/plan-limitation.ts create mode 100644 app/policies/plan_limitation_policy.rb diff --git a/app/controllers/api/plan_limitations_controller.rb b/app/controllers/api/plan_limitations_controller.rb new file mode 100644 index 000000000..91cc11385 --- /dev/null +++ b/app/controllers/api/plan_limitations_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# API Controller for resources of type PlanLimitation +# PlanLimitation allows to restrict bookings of resources for the subscribers of that plan. +class PlanLimitationsController < API::ApiController + def destroy + @limitation = PlanLimitation.find(params[:id]) + authorize @limitation + @limitation.destroy + head :no_content + end +end diff --git a/app/controllers/api/plans_controller.rb b/app/controllers/api/plans_controller.rb index 4e1a04c0a..e2faf9e77 100644 --- a/app/controllers/api/plans_controller.rb +++ b/app/controllers/api/plans_controller.rb @@ -85,7 +85,7 @@ class API::PlansController < API::ApiController plan_file_attributes: %i[id attachment _destroy], prices_attributes: %i[id amount], advanced_accounting_attributes: %i[code analytical_section], - plan_limitations_attributes: %i[id limitable_id limitable_type limit]) + plan_limitations_attributes: %i[id limitable_id limitable_type limit _destroy]) end end end diff --git a/app/frontend/src/javascript/api/plan-limitation.ts b/app/frontend/src/javascript/api/plan-limitation.ts new file mode 100644 index 000000000..b167a104c --- /dev/null +++ b/app/frontend/src/javascript/api/plan-limitation.ts @@ -0,0 +1,9 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; + +export default class PlanLimitationAPI { + static async destroy (id: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/plan_limitations/${id}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx index 16ca23496..95fb784ac 100644 --- a/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx +++ b/app/frontend/src/javascript/components/base/edit-destroy-buttons.tsx @@ -14,7 +14,8 @@ interface EditDestroyButtonsProps { apiDestroy: (itemId: number) => Promise, confirmationMessage?: string|ReactNode, className?: string, - iconSize?: number + iconSize?: number, + showEditButton?: boolean, } /** @@ -22,7 +23,7 @@ interface EditDestroyButtonsProps { * Destroy : shows a modal dialog to ask the user for confirmation about the deletion of the provided item. * Edit : triggers the provided function. */ -export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20 }) => { +export const EditDestroyButtons: React.FC = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20, showEditButton = true }) => { const { t } = useTranslation('admin'); const [deletionModal, setDeletionModal] = useState(false); @@ -50,9 +51,9 @@ export const EditDestroyButtons: React.FC = ({ onDelete return ( <>
- + {showEditButton && - + } diff --git a/app/frontend/src/javascript/components/form/form-unsaved-list.tsx b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx index e96675f60..b791bd002 100644 --- a/app/frontend/src/javascript/components/form/form-unsaved-list.tsx +++ b/app/frontend/src/javascript/components/form/form-unsaved-list.tsx @@ -1,4 +1,4 @@ -import { FieldArrayWithId, UseFieldArrayRemove } from 'react-hook-form/dist/types/fieldArray'; +import { FieldArrayWithId } from 'react-hook-form/dist/types/fieldArray'; import { UseFormRegister } from 'react-hook-form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import { useTranslation } from 'react-i18next'; @@ -9,7 +9,7 @@ import { FieldArrayPath } from 'react-hook-form/dist/types/path'; interface FormUnsavedListProps, TKeyName extends string> { fields: Array>, - remove: UseFieldArrayRemove, + onRemove?: (index: number) => void, register: UseFormRegister, className?: string, title: string, @@ -25,7 +25,7 @@ interface FormUnsavedListProps = FieldArrayPath, TKeyName extends string = 'id'>({ fields, remove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps) => { +export const FormUnsavedList = = FieldArrayPath, TKeyName extends string = 'id'>({ fields, onRemove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps) => { const { t } = useTranslation('shared'); /** @@ -35,7 +35,7 @@ export const FormUnsavedList = {renderField(field)} -

remove(index)}> +

onRemove(index)}> {cancelLabel || t('app.shared.form_unsaved_list.cancel')}

diff --git a/app/frontend/src/javascript/components/plans/plan-form.tsx b/app/frontend/src/javascript/components/plans/plan-form.tsx index a41731269..d66dd3882 100644 --- a/app/frontend/src/javascript/components/plans/plan-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-form.tsx @@ -44,7 +44,7 @@ interface PlanFormProps { * Form to edit or create subscription plans */ export const PlanForm: React.FC = ({ action, plan, onError, onSuccess, beforeSubmit, uiRouter }) => { - const { handleSubmit, register, control, formState, setValue } = useForm({ defaultValues: { ...plan } }); + const { handleSubmit, register, control, formState, setValue, getValues, resetField } = useForm({ defaultValues: { ...plan } }); const output = useWatch({ control }); // eslint-disable-line const { t } = useTranslation('admin'); @@ -332,7 +332,10 @@ export const PlanForm: React.FC = ({ action, plan, onError, onSuc content: + onError={onError} + onSuccess={onSuccess} + getValues={getValues} + resetField={resetField} /> } ]} /> diff --git a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx index c2f288ad6..cccb25784 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-form.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-form.tsx @@ -1,9 +1,8 @@ import { ReactNode, useEffect, useState } from 'react'; -import { Control, FormState } from 'react-hook-form/dist/types/form'; +import { Control, FormState, UseFormGetValues, UseFormResetField } from 'react-hook-form/dist/types/form'; import { FormSwitch } from '../form/form-switch'; import { useTranslation } from 'react-i18next'; import { FabButton } from '../base/fab-button'; -import { PencilSimple, Trash } from 'phosphor-react'; import { PlanLimitModal } from './plan-limit-modal'; import { Plan, PlanLimitation } from '../../models/plan'; import { useFieldArray, UseFormRegister, useWatch } from 'react-hook-form'; @@ -12,25 +11,31 @@ import { MachineCategory } from '../../models/machine-category'; import MachineAPI from '../../api/machine'; import MachineCategoryAPI from '../../api/machine-category'; import { FormUnsavedList } from '../form/form-unsaved-list'; +import { EditDestroyButtons } from '../base/edit-destroy-buttons'; +import PlanLimitationAPI from '../../api/plan-limitation'; interface PlanLimitFormProps { register: UseFormRegister, control: Control, formState: FormState, onError: (message: string) => void, + onSuccess: (message: string) => void, + getValues: UseFormGetValues, + resetField: UseFormResetField } /** * Form tab to manage a subscription's usage limit */ -export const PlanLimitForm = ({ register, control, formState, onError }: PlanLimitFormProps) => { +export const PlanLimitForm = ({ register, control, formState, onError, onSuccess, getValues, resetField }: PlanLimitFormProps) => { const { t } = useTranslation('admin'); - const { fields, append, remove } = useFieldArray({ control, name: 'plan_limitations_attributes' }); + const { fields, append, remove, update } = useFieldArray({ control, name: 'plan_limitations_attributes' }); const limiting = useWatch({ control, name: 'limiting' }); const [isOpen, setIsOpen] = useState(false); const [machines, setMachines] = useState>([]); const [categories, setCategories] = useState>([]); + const [edited, setEdited] = useState<{index: number, limitation: PlanLimitation}>(null); useEffect(() => { MachineAPI.index({ disabled: false }) @@ -51,8 +56,49 @@ export const PlanLimitForm = ({ register, control, for /** * Triggered when a new limit was added or an existing limit was modified */ - const onPlanLimitSuccess = (planLimit: PlanLimitation): void => { - append({ ...planLimit }); + const onLimitationSuccess = (limitation: PlanLimitation): void => { + const id = getValues(`plan_limitations_attributes.${edited?.index}.id`); + if (id) { + update(edited.index, { ...limitation, id }); + setEdited(null); + } else { + append({ ...limitation }); + } + }; + + /** + * Triggered when an unsaved limit was removed from the "pending" list. + */ + const onRemoveUnsaved = (index: number): void => { + const id = getValues(`plan_limitations_attributes.${index}.id`); + if (id) { + // will reset the field to its default values + resetField(`plan_limitations_attributes.${index}`); + // unmount and remount the field + update(index, getValues(`plan_limitations_attributes.${index}`)); + } else { + remove(index); + } + }; + + /** + * Callback triggered when the limitation was deleted. Return a callback accepting a message + */ + const onLimitationDeleted = (index: number): (message: string) => void => { + return (message: string) => { + onSuccess(message); + remove(index); + }; + }; + + /** + * Callback triggered when the user wants to modify a limitation. Return a callback + */ + const onEditLimitation = (limitation: PlanLimitation, index: number): () => void => { + return () => { + setEdited({ index, limitation }); + toggleModal(); + }; }; /** @@ -100,84 +146,83 @@ export const PlanLimitForm = ({ register, control, for
+ limit._modified} + formAttributeName="plan_limitations_attributes" + formAttributes={['id', 'limitable_type', 'limitable_id', 'limit']} + renderField={renderOngoingLimit} + cancelLabel={t('app.admin.plan_limit_form.cancel')} /> - {fields.filter(f => f.limitable_type === 'MachineCategory').length > 0 && + {fields.filter(f => f._modified).length > 0 && +

{t('app.admin.plan_limit_form.saved_limitations')}

+ } + + {fields.filter(f => f.limitable_type === 'MachineCategory' && !f._modified).length > 0 &&

{t('app.admin.plan_limit_form.by_categories')}

- {fields.filter(f => f.limitable_type === 'MachineCategory' && !f.modified).map(limitation => ( -
-
-
- {t('app.admin.plan_limit_form.category')} -

{categories.find(c => c.id === limitation.limitable_id)?.name}

-
-
- {t('app.admin.plan_limit_form.max_hours_per_day')} -

{limitation.limit}

-
-
+ {fields.map((limitation, index) => { + if (limitation.limitable_type !== 'MachineCategory' || limitation._modified) return false; -
-
- - - - - - + return ( +
+
+
+ {t('app.admin.plan_limit_form.category')} +

{categories.find(c => c.id === limitation.limitable_id)?.name}

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limitation.limit}

+
+
+ +
+
-
- ))} - limit.limitable_type === 'MachineCategory' && limit.modified} - formAttributeName="plan_limitations_attributes" - formAttributes={['id', 'limitable_type', 'limitable_id', 'limit']} - renderField={renderOngoingLimit} - cancelLabel={t('app.admin.plan_limit_form.cancel')} /> + ); + }).filter(Boolean)}
} - {fields.filter(f => f.limitable_type === 'Machine').length > 0 && + {fields.filter(f => f.limitable_type === 'Machine' && !f._modified).length > 0 &&

{t('app.admin.plan_limit_form.by_machine')}

- {fields.filter(f => f.limitable_type === 'Machine' && !f.modified).map(limitation => ( -
-
-
- {t('app.admin.plan_limit_form.machine')} -

{machines.find(m => m.id === limitation.limitable_id)?.name}

-
-
- {t('app.admin.plan_limit_form.max_hours_per_day')} -

{limitation.limit}

-
-
+ {fields.map((limitation, index) => { + if (limitation.limitable_type !== 'Machine' || limitation._modified) return false; -
-
- - - - - - + return ( +
+
+
+ {t('app.admin.plan_limit_form.machine')} +

{machines.find(m => m.id === limitation.limitable_id)?.name}

+
+
+ {t('app.admin.plan_limit_form.max_hours_per_day')} +

{limitation.limit}

+
+
+ +
+
-
- ))} - limit.limitable_type === 'Machine' && limit.modified} - formAttributeName="plan_limitations_attributes" - formAttributes={['id', 'limitable_type', 'limitable_id', 'limit']} - renderField={renderOngoingLimit} - cancelLabel={t('app.admin.plan_limit_form.cancel')} /> + ); + }).filter(Boolean)}
}
} @@ -186,7 +231,8 @@ export const PlanLimitForm = ({ register, control, for machines={machines} categories={categories} toggleModal={toggleModal} - onSuccess={onPlanLimitSuccess} /> + onSuccess={onLimitationSuccess} + limitation={edited?.limitation} />
); }; diff --git a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx index 7c9ad6255..50c1349c7 100644 --- a/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx +++ b/app/frontend/src/javascript/components/plans/plan-limit-modal.tsx @@ -10,24 +10,30 @@ import { Machine } from '../../models/machine'; import { MachineCategory } from '../../models/machine-category'; import { SelectOption } from '../../models/select'; import { FabButton } from '../base/fab-button'; +import { useEffect } from 'react'; interface PlanLimitModalProps { isOpen: boolean, toggleModal: () => void, onSuccess: (limit: PlanLimitation) => void, machines: Array - categories: Array + categories: Array, + limitation?: PlanLimitation, } /** * Form to manage subscriptions limitations of use */ -export const PlanLimitModal: React.FC = ({ isOpen, toggleModal, machines, categories, onSuccess }) => { +export const PlanLimitModal: React.FC = ({ isOpen, toggleModal, machines, categories, onSuccess, limitation }) => { const { t } = useTranslation('admin'); - const { register, control, formState, setValue, handleSubmit } = useForm({ defaultValues: { limitable_type: 'MachineCategory' } }); + const { register, control, formState, setValue, handleSubmit, reset } = useForm({ defaultValues: limitation || { limitable_type: 'MachineCategory' } }); const limitType = useWatch({ control, name: 'limitable_type' }); + useEffect(() => { + reset(limitation); + }, [limitation]); + /** * Toggle the form between 'categories' and 'machine' */ @@ -47,7 +53,8 @@ export const PlanLimitModal: React.FC = ({ isOpen, toggleMo event.preventDefault(); } return handleSubmit((data: PlanLimitation) => { - onSuccess({ ...data, modified: true }); + onSuccess({ ...data, _modified: true }); + reset({}); toggleModal(); })(event); }; @@ -85,6 +92,7 @@ export const PlanLimitModal: React.FC = ({ isOpen, toggleMo
{t('app.admin.plan_limit_modal.machine_info')} + ({ currentFormValues,