1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-19 13:54:25 +01:00

(feat) update limitations

This commit is contained in:
Sylvain 2023-03-10 14:13:00 +01:00
parent 622a14909a
commit 6abea03182
17 changed files with 185 additions and 107 deletions

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,9 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
export default class PlanLimitationAPI {
static async destroy (id: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/plan_limitations/${id}`);
return res?.data;
}
}

View File

@ -14,7 +14,8 @@ interface EditDestroyButtonsProps {
apiDestroy: (itemId: number) => Promise<void>,
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<EditDestroyButtonsProps> = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20 }) => {
export const EditDestroyButtons: React.FC<EditDestroyButtonsProps> = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20, showEditButton = true }) => {
const { t } = useTranslation('admin');
const [deletionModal, setDeletionModal] = useState<boolean>(false);
@ -50,9 +51,9 @@ export const EditDestroyButtons: React.FC<EditDestroyButtonsProps> = ({ onDelete
return (
<>
<div className={`edit-destroy-buttons ${className || ''}`}>
<FabButton className='edit-btn' onClick={onEdit}>
{showEditButton && <FabButton className='edit-btn' onClick={onEdit}>
<PencilSimple size={iconSize} weight="fill" />
</FabButton>
</FabButton>}
<FabButton type='button' className='delete-btn' onClick={toggleDeletionModal}>
<Trash size={iconSize} weight="fill" />
</FabButton>

View File

@ -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<TFieldValues, TFieldArrayName extends FieldArrayPath<TFieldValues>, TKeyName extends string> {
fields: Array<FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>>,
remove: UseFieldArrayRemove,
onRemove?: (index: number) => void,
register: UseFormRegister<TFieldValues>,
className?: string,
title: string,
@ -25,7 +25,7 @@ interface FormUnsavedListProps<TFieldValues, TFieldArrayName extends FieldArrayP
* This component render a list of unsaved attributes, created elsewhere than in the form (e.g. in a modal dialog)
* and pending for the form to be saved.
*/
export const FormUnsavedList = <TFieldValues extends FieldValues = FieldValues, TFieldArrayName extends FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>, TKeyName extends string = 'id'>({ fields, remove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps<TFieldValues, TFieldArrayName, TKeyName>) => {
export const FormUnsavedList = <TFieldValues extends FieldValues = FieldValues, TFieldArrayName extends FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>, TKeyName extends string = 'id'>({ fields, onRemove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps<TFieldValues, TFieldArrayName, TKeyName>) => {
const { t } = useTranslation('shared');
/**
@ -35,7 +35,7 @@ export const FormUnsavedList = <TFieldValues extends FieldValues = FieldValues,
return (
<div key={index} className="unsaved-field">
{renderField(field)}
<p className="cancel-action" onClick={() => remove(index)}>
<p className="cancel-action" onClick={() => onRemove(index)}>
{cancelLabel || t('app.shared.form_unsaved_list.cancel')}
<X size={20} />
</p>

View File

@ -44,7 +44,7 @@ interface PlanFormProps {
* Form to edit or create subscription plans
*/
export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuccess, beforeSubmit, uiRouter }) => {
const { handleSubmit, register, control, formState, setValue } = useForm<Plan>({ defaultValues: { ...plan } });
const { handleSubmit, register, control, formState, setValue, getValues, resetField } = useForm<Plan>({ defaultValues: { ...plan } });
const output = useWatch<Plan>({ control }); // eslint-disable-line
const { t } = useTranslation('admin');
@ -332,7 +332,10 @@ export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuc
content: <PlanLimitForm control={control}
register={register}
formState={formState}
onError={onError} />
onError={onError}
onSuccess={onSuccess}
getValues={getValues}
resetField={resetField} />
}
]} />
</form>

View File

@ -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<TContext extends object> {
register: UseFormRegister<Plan>,
control: Control<Plan, TContext>,
formState: FormState<Plan>,
onError: (message: string) => void,
onSuccess: (message: string) => void,
getValues: UseFormGetValues<Plan>,
resetField: UseFormResetField<Plan>
}
/**
* Form tab to manage a subscription's usage limit
*/
export const PlanLimitForm = <TContext extends object> ({ register, control, formState, onError }: PlanLimitFormProps<TContext>) => {
export const PlanLimitForm = <TContext extends object> ({ register, control, formState, onError, onSuccess, getValues, resetField }: PlanLimitFormProps<TContext>) => {
const { t } = useTranslation('admin');
const { fields, append, remove } = useFieldArray<Plan, 'plan_limitations_attributes'>({ control, name: 'plan_limitations_attributes' });
const { fields, append, remove, update } = useFieldArray<Plan, 'plan_limitations_attributes'>({ control, name: 'plan_limitations_attributes' });
const limiting = useWatch<Plan>({ control, name: 'limiting' });
const [isOpen, setIsOpen] = useState<boolean>(false);
const [machines, setMachines] = useState<Array<Machine>>([]);
const [categories, setCategories] = useState<Array<MachineCategory>>([]);
const [edited, setEdited] = useState<{index: number, limitation: PlanLimitation}>(null);
useEffect(() => {
MachineAPI.index({ disabled: false })
@ -51,8 +56,49 @@ export const PlanLimitForm = <TContext extends object> ({ 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 = <TContext extends object> ({ register, control, for
</FabButton>
</div>
</header>
<FormUnsavedList fields={fields}
onRemove={onRemoveUnsaved}
register={register}
title={t('app.admin.plan_limit_form.ongoing_limitations')}
shouldRenderField={(limit: PlanLimitation) => 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 &&
<p className="title">{t('app.admin.plan_limit_form.saved_limitations')}</p>
}
{fields.filter(f => f.limitable_type === 'MachineCategory' && !f._modified).length > 0 &&
<div className='plan-limit-list'>
<p className="title">{t('app.admin.plan_limit_form.by_categories')}</p>
{fields.filter(f => f.limitable_type === 'MachineCategory' && !f.modified).map(limitation => (
<div className="plan-limit-item" key={limitation.id}>
<div className="grp">
<div>
<span>{t('app.admin.plan_limit_form.category')}</span>
<p>{categories.find(c => c.id === limitation.limitable_id)?.name}</p>
</div>
<div>
<span>{t('app.admin.plan_limit_form.max_hours_per_day')}</span>
<p>{limitation.limit}</p>
</div>
</div>
{fields.map((limitation, index) => {
if (limitation.limitable_type !== 'MachineCategory' || limitation._modified) return false;
<div className='actions'>
<div className='grpBtn'>
<FabButton className='edit-btn'>
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton className='delete-btn'>
<Trash size={20} weight="fill" />
</FabButton>
return (
<div className="plan-limit-item" key={limitation.id}>
<div className="grp">
<div>
<span>{t('app.admin.plan_limit_form.category')}</span>
<p>{categories.find(c => c.id === limitation.limitable_id)?.name}</p>
</div>
<div>
<span>{t('app.admin.plan_limit_form.max_hours_per_day')}</span>
<p>{limitation.limit}</p>
</div>
</div>
<div className='actions'>
<EditDestroyButtons onDeleteSuccess={onLimitationDeleted(index)}
onError={onError}
onEdit={onEditLimitation(limitation, index)}
itemId={limitation.id}
itemType={t('app.admin.plan_limit_form.limitation')}
apiDestroy={PlanLimitationAPI.destroy} />
</div>
</div>
</div>
))}
<FormUnsavedList fields={fields}
remove={remove}
register={register}
title={t('app.admin.plan_limit_form.ongoing_limitations')}
shouldRenderField={(limit: PlanLimitation) => 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)}
</div>
}
{fields.filter(f => f.limitable_type === 'Machine').length > 0 &&
{fields.filter(f => f.limitable_type === 'Machine' && !f._modified).length > 0 &&
<div className='plan-limit-list'>
<p className="title">{t('app.admin.plan_limit_form.by_machine')}</p>
{fields.filter(f => f.limitable_type === 'Machine' && !f.modified).map(limitation => (
<div className="plan-limit-item" key={limitation.id}>
<div className="grp">
<div>
<span>{t('app.admin.plan_limit_form.machine')}</span>
<p>{machines.find(m => m.id === limitation.limitable_id)?.name}</p>
</div>
<div>
<span>{t('app.admin.plan_limit_form.max_hours_per_day')}</span>
<p>{limitation.limit}</p>
</div>
</div>
{fields.map((limitation, index) => {
if (limitation.limitable_type !== 'Machine' || limitation._modified) return false;
<div className='actions'>
<div className='grpBtn'>
<FabButton className='edit-btn'>
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton className='delete-btn'>
<Trash size={20} weight="fill" />
</FabButton>
return (
<div className="plan-limit-item" key={limitation.id}>
<div className="grp">
<div>
<span>{t('app.admin.plan_limit_form.machine')}</span>
<p>{machines.find(m => m.id === limitation.limitable_id)?.name}</p>
</div>
<div>
<span>{t('app.admin.plan_limit_form.max_hours_per_day')}</span>
<p>{limitation.limit}</p>
</div>
</div>
<div className='actions'>
<EditDestroyButtons onDeleteSuccess={onLimitationDeleted(index)}
onError={onError}
onEdit={onEditLimitation(limitation, index)}
itemId={limitation.id}
itemType={t('app.admin.plan_limit_form.limitation')}
apiDestroy={PlanLimitationAPI.destroy} />
</div>
</div>
</div>
))}
<FormUnsavedList fields={fields}
remove={remove}
register={register}
title={t('app.admin.plan_limit_form.ongoing_limitations')}
shouldRenderField={(limit: PlanLimitation) => 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)}
</div>
}
</div>}
@ -186,7 +231,8 @@ export const PlanLimitForm = <TContext extends object> ({ register, control, for
machines={machines}
categories={categories}
toggleModal={toggleModal}
onSuccess={onPlanLimitSuccess} />
onSuccess={onLimitationSuccess}
limitation={edited?.limitation} />
</div>
);
};

View File

@ -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<Machine>
categories: Array<MachineCategory>
categories: Array<MachineCategory>,
limitation?: PlanLimitation,
}
/**
* Form to manage subscriptions limitations of use
*/
export const PlanLimitModal: React.FC<PlanLimitModalProps> = ({ isOpen, toggleModal, machines, categories, onSuccess }) => {
export const PlanLimitModal: React.FC<PlanLimitModalProps> = ({ isOpen, toggleModal, machines, categories, onSuccess, limitation }) => {
const { t } = useTranslation('admin');
const { register, control, formState, setValue, handleSubmit } = useForm<PlanLimitation>({ defaultValues: { limitable_type: 'MachineCategory' } });
const { register, control, formState, setValue, handleSubmit, reset } = useForm<PlanLimitation>({ 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<PlanLimitModalProps> = ({ 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<PlanLimitModalProps> = ({ isOpen, toggleMo
</button>
</div>
<FabAlert level='info'>{t('app.admin.plan_limit_modal.machine_info')}</FabAlert>
<FormInput register={register} id="id" type="hidden" />
<FormInput register={register} id="limitable_type" type="hidden" />
<FormSelect options={buildOptions()}
control={control}

View File

@ -203,7 +203,7 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
<FormUnsavedList fields={fields}
className="ongoing-stocks"
remove={remove}
onRemove={remove}
register={register}
title={t('app.admin.store.product_stock_form.ongoing_operations')}
formAttributeName="product_stock_movements_attributes"

View File

@ -18,7 +18,8 @@ export interface PlanLimitation {
limitable_id: number,
limitable_type: LimitableType,
limit: number,
modified?: boolean,
_modified?: boolean,
_destroy?: boolean,
}
export interface Plan {

View File

@ -46,19 +46,6 @@
display: flex;
justify-content: flex-end;
align-items: center;
.grpBtn {
overflow: hidden;
display: flex;
border-radius: var(--border-radius-sm);
button {
@include btn;
border-radius: 0;
color: var(--gray-soft-lightest);
&:hover { opacity: 0.75; }
}
.edit-btn {background: var(--gray-hard-darkest) }
.delete-btn {background: var(--main) }
}
}
@media (min-width: 540px) {

View File

@ -9,5 +9,5 @@ class PlanLimitation < ApplicationRecord
belongs_to :machine_category, foreign_type: 'MachineCategory', foreign_key: 'limitable_id', inverse_of: :plan_limitations
validates :limitable_id, :limitable_type, :limit, :plan_id, presence: true
validates :plan_id, :limitable_id, :limitable_type, uniqueness: true
validates :limitable_id, uniqueness: { scope: %i[limitable_type plan_id] }
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
# Check the access policies for API::PlanLimitationsController
class PlanLimitationPolicy < ApplicationPolicy
def destroy?
user.admin?
end
end

View File

@ -207,7 +207,9 @@ en:
machine: "Machine name"
max_hours_per_day: "Max. hours/day"
ongoing_limitations: "Ongoing limitations"
saved_limitations: "Saved limitations"
cancel: "Cancel this limitation"
limitation: "Limitation"
plan_limit_modal:
title: "Manage limitation of use"
limit_reservations: "Limit reservations"

View File

@ -116,6 +116,7 @@ Rails.application.routes.draw do
patch 'cancel', on: :member
end
resources :plan_categories
resources :plan_limitations, only: [:destroy]
resources :plans do
get 'durations', on: :collection
end

View File

@ -161,7 +161,7 @@
"react-cool-onclickoutside": "^1.7.0",
"react-custom-events": "^1.1.1",
"react-dom": "^17.0.2",
"react-hook-form": "^7.30.0",
"react-hook-form": "^7.31.3",
"react-i18next": "^11.15.6",
"react-modal": "^3.16.1",
"react-select": "^5.3.2",

View File

@ -9062,10 +9062,10 @@ react-dom@^17.0.2:
object-assign "^4.1.1"
scheduler "^0.20.2"
react-hook-form@^7.30.0:
version "7.30.0"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.30.0.tgz#c9e2fd54d3627e43bd94bf38ef549df2e80c1371"
integrity sha512-DzjiM6o2vtDGNMB9I4yCqW8J21P314SboNG1O0obROkbg7KVS0I7bMtwSdKyapnCPjHgnxc3L7E5PEdISeEUcQ==
react-hook-form@^7.31.3:
version "7.43.5"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.43.5.tgz#b320405594f1506d8d57b954383166d4ff563778"
integrity sha512-YcaXhuFHoOPipu5pC7ckxrLrialiOcU91pKu8P+isAcXZyMgByUK9PkI9j5fENO4+6XU5PwWXRGMIFlk9u9UBQ==
react-i18next@^11.15.6:
version "11.15.6"