1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-03-15 12:29:16 +01:00

(quality) Refacto pack-form

This commit is contained in:
vincent 2023-01-26 13:42:35 +01:00 committed by Sylvain
parent a05ef1f0ba
commit 7208cd80b0
11 changed files with 253 additions and 146 deletions

View File

@ -0,0 +1,70 @@
import { PencilSimple, Trash } from 'phosphor-react';
import * as React from 'react';
import { ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from './fab-button';
import { FabModal } from './fab-modal';
interface EditDestroyButtonsProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
onEdit: () => void,
itemId: number,
itemType: string,
apiDestroy: (itemId: number) => Promise<void>,
confirmationMessage?: string|ReactNode,
className?: string,
iconSize?: number
}
/**
* This component shows a group of two buttons.
* 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> = ({ onSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20 }) => {
const { t } = useTranslation('admin');
const [deletionModal, setDeletionModal] = useState<boolean>(false);
/**
* Opens/closes the deletion modal
*/
const toggleDeletionModal = (): void => {
setDeletionModal(!deletionModal);
};
/**
* The deletion has been confirmed by the user.
* Call the API to trigger the deletion of the given item
*/
const onDeleteConfirmed = (): void => {
apiDestroy(itemId).then(() => {
onSuccess(t('app.admin.edit_destroy_buttons.deleted', { TYPE: itemType }));
}).catch((error) => {
onError(t('app.admin.edit_destroy_buttons.unable_to_delete', { TYPE: itemType }) + error);
});
toggleDeletionModal();
};
return (
<>
<div className={`edit-destroy-buttons ${className || ''}`}>
<FabButton className='edit-btn' onClick={onEdit}>
<PencilSimple size={iconSize} weight="fill" />
</FabButton>
<FabButton type='button' className='delete-btn' onClick={toggleDeletionModal}>
<Trash size={iconSize} weight="fill" />
</FabButton>
</div>
<FabModal title={t('app.admin.edit_destroy_buttons.delete_item', { TYPE: itemType })}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}
confirmButton={t('app.admin.edit_destroy_buttons.confirm_delete')}
onConfirm={onDeleteConfirmed}>
<span>{confirmationMessage || t('app.admin.edit_destroy_buttons.delete_confirmation', { TYPE: itemType })}</span>
</FabModal>
</>
);
};

View File

@ -5,9 +5,10 @@ import { useTranslation } from 'react-i18next';
import { FabPopover } from '../../base/fab-popover';
import { CreatePack } from './create-pack';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { EditPack } from './edit-pack';
import FormatLib from '../../../lib/format';
import { DestroyButton } from '../../base/destroy-button';
import { EditDestroyButtons } from '../../base/edit-destroy-buttons';
import { FabModal } from '../../base/fab-modal';
import { PackForm } from './pack-form';
interface ConfigurePacksButtonProps {
packsData: Array<PrepaidPack>,
@ -27,6 +28,8 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
const [packs, setPacks] = useState<Array<PrepaidPack>>(packsData);
const [showList, setShowList] = useState<boolean>(false);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [packData, setPackData] = useState<PrepaidPack>(null);
/**
* Return the number of hours, user-friendly formatted
@ -58,10 +61,41 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
*/
const renderAddButton = (): ReactNode => {
return <CreatePack onSuccess={handleSuccess}
onError={onError}
groupId={groupId}
priceableId={priceableId}
priceableType={priceableType} />;
onError={onError}
groupId={groupId}
priceableId={priceableId}
priceableType={priceableType} />;
};
/**
* Open/closes the "edit pack" modal dialog
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* When the user clicks on the edition button, query the full data of the current pack from the API, then open te edition modal
*/
const handleRequestEdit = (pack: PrepaidPack): void => {
PrepaidPackAPI.get(pack.id)
.then(data => {
setPackData(data);
toggleModal();
})
.catch(error => onError(error));
};
/**
* Callback triggered when the user has validated the changes of the PrepaidPack
*/
const handleUpdate = (pack: PrepaidPack): void => {
PrepaidPackAPI.update(pack)
.then(() => {
handleSuccess(t('app.admin.configure_packs_button.pack_successfully_updated'));
toggleModal();
})
.catch(error => onError(error));
};
return (
@ -74,16 +108,22 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
{packs?.map(p =>
<li key={p.id} className={p.disabled ? 'disabled' : ''}>
{formatDuration(p.minutes)} - {FormatLib.price(p.amount)}
<span className="pack-actions">
<EditPack onSuccess={handleSuccess} onError={onError} pack={p} />
<DestroyButton onSuccess={handleSuccess}
onError={onError}
itemId={p.id}
itemType={t('app.admin.configure_packs_button.pack')}
apiDestroy={PrepaidPackAPI.destroy}
iconSize={12}
confirmationMessage={t('app.admin.configure_packs_button.delete_confirmation')} />
</span>
<EditDestroyButtons className='pack-actions'
onError={onError}
onSuccess={handleSuccess}
onEdit={() => handleRequestEdit(p)}
itemId={p.id}
itemType={t('app.admin.configure_packs_button.pack')}
apiDestroy={PrepaidPackAPI.destroy}/>
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.configure_packs_button.edit_pack')}
className="edit-pack-modal"
closeButton
confirmButton={t('app.admin.configure_packs_button.confirm_changes')}
onConfirmSendFormId="edit-pack">
{packData && <PackForm formId="edit-pack" onSubmit={handleUpdate} pack={packData} />}
</FabModal>
</li>)}
</ul>
{packs?.length === 0 && <span>{t('app.admin.configure_packs_button.no_packs')}</span>}

View File

@ -1,13 +1,13 @@
import { BaseSyntheticEvent } from 'react';
import * as React from 'react';
import Select from 'react-select';
import Switch from 'react-switch';
import { useEffect, useState } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { PrepaidPack } from '../../../models/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { useImmer } from 'use-immer';
import { FabInput } from '../../base/fab-input';
import { IFablab } from '../../../models/fablab';
import { SelectOption } from '../../../models/select';
import { FormInput } from '../../form/form-input';
import { FormSelect } from '../../form/form-select';
import { FormSwitch } from '../../form/form-switch';
import { FabInput } from '../../base/fab-input';
declare let Fablab: IFablab;
@ -25,10 +25,18 @@ type interval = typeof ALL_INTERVALS[number];
* The form validation must be created elsewhere, using the attribute form={formId}.
*/
export const PackForm: React.FC<PackFormProps> = ({ formId, onSubmit, pack }) => {
const [packData, updatePackData] = useImmer<PrepaidPack>(pack || {} as PrepaidPack);
const { t } = useTranslation('admin');
const { handleSubmit, register, control, formState, setValue } = useForm<PrepaidPack>({ defaultValues: { ...pack } });
const [formattedDuration, setFormattedDuration] = useState<number>(pack?.minutes || 60);
/**
* Callback triggered when the user validates the form
*/
const submitForm: SubmitHandler<PrepaidPack> = (data:PrepaidPack) => {
onSubmit(data);
};
/**
* Convert all validity-intervals to the react-select format
*/
@ -41,101 +49,65 @@ export const PackForm: React.FC<PackFormProps> = ({ formId, onSubmit, pack }) =>
*/
const intervalToOption = (value: interval): SelectOption<interval> => {
if (!value) return { value, label: '' };
return { value, label: t(`app.admin.pack_form.intervals.${value}`, { COUNT: packData.validity_count || 0 }) };
return { value, label: t(`app.admin.pack_form.intervals.${value}`, { COUNT: pack?.validity_count || 0 }) };
};
/**
* Callback triggered when the user sends the form.
* Changes hours into minutes
*/
const handleSubmit = (event: BaseSyntheticEvent): void => {
event.preventDefault();
onSubmit(packData);
};
/**
* Callback triggered when the user inputs an amount for the current pack.
*/
const handleUpdateAmount = (amount: string) => {
updatePackData(draft => {
draft.amount = parseFloat(amount);
});
};
/**
* Callback triggered when the user inputs a number of hours for the current pack.
*/
const handleUpdateHours = (hours: string) => {
updatePackData(draft => {
draft.minutes = parseInt(hours, 10) * 60;
});
};
/**
* Callback triggered when the user inputs a number of periods for the current pack.
*/
const handleUpdateValidityCount = (count: string) => {
updatePackData(draft => {
draft.validity_count = parseInt(count, 10);
});
};
/**
* Callback triggered when the user selects a type of interval for the current pack.
*/
const handleUpdateValidityInterval = (option: SelectOption<interval>) => {
updatePackData(draft => {
draft.validity_interval = option.value as interval;
});
};
/**
* Callback triggered when the user disables the pack.
*/
const handleUpdateDisabled = (checked: boolean) => {
updatePackData(draft => {
draft.disabled = checked;
});
const formatDuration = (value) => {
setFormattedDuration(value * 60);
};
useEffect(() => {
setValue('minutes', formattedDuration);
}, [formattedDuration]);
return (
<form id={formId} onSubmit={handleSubmit} className="pack-form">
<label htmlFor="hours">{t('app.admin.pack_form.hours')} *</label>
<FabInput id="hours"
type="number"
defaultValue={packData?.minutes / 60 || ''}
onChange={handleUpdateHours}
min={1}
icon={<i className="fas fa-clock" />}
required />
<label htmlFor="amount">{t('app.admin.pack_form.amount')} *</label>
<FabInput id="amount"
<form id={formId} onSubmit={handleSubmit(submitForm)} className="pack-form">
<div className="duration">
<label htmlFor="minutes">{t('app.admin.pack_form.hours')}</label>
<Controller control={control}
name='minutes'
render={() => (
<FabInput id="minutes"
type='number'
min={1}
required
icon={<i className="fas fa-clock" />}
onChange={formatDuration}
defaultValue={formattedDuration / 60} />
)} />
</div>
<FormInput id="amount"
register={register}
formState={formState}
type="number"
step={0.01}
min={0}
defaultValue={packData?.amount || ''}
onChange={handleUpdateAmount}
icon={<i className="fas fa-money-bill" />}
addOn={Fablab.intl_currency}
required />
<label htmlFor="validity_count">{t('app.admin.pack_form.validity_count')}</label>
rules={{ required: true, min: 0 }}
label={t('app.admin.pack_form.amount')} />
<label className='validity' htmlFor="validity_count">{t('app.admin.pack_form.validity_count')}</label>
<div className="interval-inputs">
<FabInput id="validity_count"
<FormInput id="validity_count"
register={register}
formState={formState}
type="number"
min={0}
defaultValue={packData?.validity_count || ''}
onChange={handleUpdateValidityCount}
icon={<i className="fas fa-calendar-week" />} />
<Select placeholder={t('app.admin.pack_form.select_interval')}
className="select-interval"
defaultValue={intervalToOption(packData?.validity_interval)}
onChange={handleUpdateValidityInterval}
options={buildOptions()} />
</div>
<label htmlFor="disabled">{t('app.admin.pack_form.disabled')}</label>
<div>
<Switch checked={packData?.disabled || false} onChange={handleUpdateDisabled} id="disabled" />
icon={<i className="fas fa-calendar-week" />}
rules={{ min: 0 }}/>
<FormSelect id="validity_interval"
control={control}
options={buildOptions()}
className="select-interval"
placeholder={t('app.admin.pack_form.select_interval')}/>
</div>
<FormSwitch id="disabled"
control={control}
formState={formState}
label={t('app.admin.pack_form.disabled')} />
</form>
);
};

View File

@ -7,13 +7,13 @@ import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import Select from 'react-select';
import { SelectOption } from '../../models/select';
import { CalendarBlank, PencilSimple } from 'phosphor-react';
import { CalendarBlank } from 'phosphor-react';
import { useEffect, useState } from 'react';
import type { Training } from '../../models/training';
import type { Machine } from '../../models/machine';
import TrainingAPI from '../../api/training';
import MachineAPI from '../../api/machine';
import { DestroyButton } from '../base/destroy-button';
import { EditDestroyButtons } from '../base/edit-destroy-buttons';
declare const Application: IApplication;
@ -136,10 +136,17 @@ export const Trainings: React.FC<TrainingsProps> = ({ onError, onSuccess }) => {
<p>{training.name}</p>
</div>
{(hasMachines(training) && <div className='machines'>
<div className="machines">
<span>{t('app.admin.trainings.associated_machines')}</span>
<p>{machinesNames(training.machine_ids)}</p>
</div>) || <div/>}
{(hasMachines(training) &&
<p>{machinesNames(training.machine_ids)}</p>
) || <p>---</p>}
</div>
<div className='capacity'>
<span>{t('app.admin.trainings.capacity')}</span>
<p>{training.nb_total_places || '---'}</p>
</div>
<div className='cancel'>
<span>{t('app.admin.trainings.cancellation')}</span>
@ -150,11 +157,6 @@ export const Trainings: React.FC<TrainingsProps> = ({ onError, onSuccess }) => {
</p>) || <p>---</p>}
</div>
<div className='capacity'>
<span>{t('app.admin.trainings.capacity')}</span>
<p>{training.nb_total_places}</p>
</div>
<div className='authorisation'>
<span>{t('app.admin.trainings.authorisation')}</span>
<p>
@ -171,17 +173,13 @@ export const Trainings: React.FC<TrainingsProps> = ({ onError, onSuccess }) => {
</div>
<div className='actions'>
<div className='grpBtn'>
<FabButton className='edit-btn' onClick={() => toTrainingEdit(training)}>
<PencilSimple size={20} weight="fill" />
</FabButton>
<DestroyButton onSuccess={onSuccess}
className="delete-btn"
onError={onError}
itemId={training.id}
itemType={t('app.admin.trainings.training')}
apiDestroy={TrainingAPI.destroy} />
</div>
<EditDestroyButtons className='grpBtn'
onError={onError}
onSuccess={onSuccess}
onEdit={() => toTrainingEdit(training)}
itemId={training.id}
itemType={t('app.admin.trainings.training')}
apiDestroy={TrainingAPI.destroy}/>
</div>
</div>
))}

View File

@ -23,6 +23,7 @@
@import "modules/authentication-provider/openid-connect-data-mapping-form";
@import "modules/authentication-provider/provider-form";
@import "modules/authentication-provider/type-mapping-modal";
@import "modules/base/edit-destroy-buttons";
@import "modules/base/editorial-block";
@import "modules/base/fab-alert";
@import "modules/base/fab-button";

View File

@ -0,0 +1,15 @@
.edit-destroy-buttons {
button {
@include btn;
border-radius: 0;
color: var(--gray-soft-lightest);
&.edit-btn {background: var(--gray-hard-darkest) }
&.delete-btn {background: var(--main) }
&:hover,
&:focus {
opacity: 0.75;
color: var(--gray-soft-lightest);
}
}
}

View File

@ -22,10 +22,14 @@
.popover-content {
ul {
padding-left: 19px;
display: flex;
flex-direction: column;
gap: 0.8rem 0;
li {
display: flex;
justify-content: space-between;
align-items: center;
&::before {
content: '\f466';
font-family: 'Font Awesome 5 Free';
@ -37,12 +41,10 @@
line-height: 24px;
}
.pack-actions button {
min-height: unset;
font-size: 10px;
vertical-align: middle;
line-height: 10px;
height: auto;
.pack-actions {
overflow: hidden;
display: flex;
border-radius: var(--border-radius-sm);
}
&.disabled {

View File

@ -7,4 +7,22 @@
margin-left: 4px;
}
}
.duration {
margin-bottom: 1.6rem;
label {
@include text-sm;
cursor: pointer;
&::after {
content: "*";
margin-left: 0.5ch;
color: var(--alert);
}
}
}
.validity {
@include text-sm;
cursor: pointer;
}
}

View File

@ -82,14 +82,6 @@
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) }
}
}

View File

@ -29,7 +29,7 @@
<trainings-settings on-error="onError" on-success="onSuccess"></trainings-settings>
</uib-tab>
<uib-tab heading="{{ 'app.admin.trainings.all_trainings' | translate }}" index="0" class="manage-trainings">
<trainings on-error="onError" on-success="on-success"></trainings>
<trainings on-error="onError" on-success="onSuccess"></trainings>
<div class="m-t m-b">
<button type="button" class="btn btn-warning" ui-sref="app.admin.trainings_new" ng-show="isAuthorized('admin')">

View File

@ -1,7 +1,7 @@
en:
app:
admin:
destroy_button:
edit_destroy_buttons:
deleted: "The {TYPE} was successfully deleted."
unable_to_delete: "Unable to delete the {TYPE}: "
delete_item: "Delete the {TYPE}"
@ -681,6 +681,9 @@ en:
no_packs: "No packs for now"
pack_DURATION: "{DURATION} hours"
delete_confirmation: "Are you sure you want to delete this prepaid pack? This won't be possible if the pack was already bought by users."
edit_pack: "Edit the pack"
confirm_changes: "Confirm changes"
pack_successfully_updated: "The prepaid pack was successfully updated."
configure_extended_prices_button:
extended_prices: "Extended prices"
no_extended_prices: "No extended price for now"
@ -704,10 +707,6 @@ en:
new_pack_info: "A prepaid pack allows users to buy {TYPE, select, Machine{machine} Space{space} other{}} hours before booking any slots. These packs can provide discounts on volumes purchases."
create_pack: "Create this pack"
pack_successfully_created: "The new prepaid pack was successfully created."
edit_pack:
edit_pack: "Edit the pack"
confirm_changes: "Confirm changes"
pack_successfully_updated: "The prepaid pack was successfully updated."
create_extended_price:
new_extended_price: "New extended price"
new_extended_price_info: "Extended prices allows you to define prices based on custom durations, instead of the default hourly rates."