Skip to content

Commit

Permalink
refactor: update Rule Snooze Add form
Browse files Browse the repository at this point in the history
  • Loading branch information
carere committed Mar 6, 2025
1 parent a66351a commit 39a30d2
Showing 1 changed file with 168 additions and 135 deletions.
303 changes: 168 additions & 135 deletions packages/app-builder/src/routes/ressources+/cases+/add-rule-snooze.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
import { Callout } from '@app-builder/components';
import { ExternalLink } from '@app-builder/components/ExternalLink';
import { FormErrorOrDescription } from '@app-builder/components/Form/FormErrorOrDescription';
import { FormField } from '@app-builder/components/Form/FormField';
import { FormInput } from '@app-builder/components/Form/FormInput';
import { FormLabel } from '@app-builder/components/Form/FormLabel';
import { FormSelect } from '@app-builder/components/Form/FormSelect';
import { FormTextArea } from '@app-builder/components/Form/FormTextArea';
import { FormErrorOrDescription } from '@app-builder/components/Form/Tanstack/FormErrorOrDescription';
import { FormInput } from '@app-builder/components/Form/Tanstack/FormInput';
import { FormLabel } from '@app-builder/components/Form/Tanstack/FormLabel';
import { setToastMessage } from '@app-builder/components/MarbleToaster';
import { LoadingIcon } from '@app-builder/components/Spinner';
import { adaptDateTimeFieldCodes } from '@app-builder/models/duration';
import { adaptDateTimeFieldCodes, type DurationUnit } from '@app-builder/models/duration';
import { isStatusConflictHttpError } from '@app-builder/models/http-errors';
import { ruleSnoozesDocHref } from '@app-builder/services/documentation-href';
import { serverServices } from '@app-builder/services/init.server';
import { useFormatLanguage } from '@app-builder/utils/format';
import { getRoute } from '@app-builder/utils/routes';
import { FormProvider, getFormProps, getInputProps, useForm } from '@conform-to/react';
import { getZodConstraint, parseWithZod } from '@conform-to/zod';
import { type ActionFunctionArgs, json } from '@remix-run/node';
import { useFetcher } from '@remix-run/react';
import * as React from 'react';
import { useForm } from '@tanstack/react-form';
import { useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Temporal } from 'temporal-polyfill';
import { Button, ModalV2 } from 'ui-design-system';
import { Button, ModalV2, Select, TextArea } from 'ui-design-system';
import { z } from 'zod';

const durationUnitOptions = ['days', 'weeks', 'hours'] as const;
Expand All @@ -34,26 +30,37 @@ const addRuleSnoozeFormSchema = z.object({
durationUnit: z.enum(durationUnitOptions),
});

type AddRuleSnoozeForm = z.infer<typeof addRuleSnoozeFormSchema>;

export async function action({ request }: ActionFunctionArgs) {
const {
authService,
i18nextService: { getFixedT },
toastSessionService: { getSession, commitSession },
} = serverServices;
const { decision } = await authService.isAuthenticated(request, {
failureRedirect: getRoute('/sign-in'),
});

const formData = await request.formData();
const submission = parseWithZod(formData, {
schema: addRuleSnoozeFormSchema,
});
const [t, session, rawData, { decision }] = await Promise.all([
getFixedT(request, ['common', 'cases']),
getSession(request),
request.json(),
authService.isAuthenticated(request, {
failureRedirect: getRoute('/sign-in'),
}),
]);

if (submission.status !== 'success') {
return json(submission.reply());
const { data, success, error } = addRuleSnoozeFormSchema.safeParse(rawData);

if (!success) {
return json(
{ status: 'error', errors: error.flatten() },
{
headers: { 'Set-Cookie': await commitSession(session) },
},
);
}

const { decisionId, ruleId, comment, durationUnit, durationValue } = submission.value;
const { decisionId, ruleId, comment, durationUnit, durationValue } = data;

const duration = Temporal.Duration.from({
[durationUnit]: durationValue,
});
Expand All @@ -63,13 +70,18 @@ export async function action({ request }: ActionFunctionArgs) {
relativeTo: Temporal.Now.plainDateTime('gregory'),
}) >= 0
) {
const t = await getFixedT(request, ['cases']);
return json(
submission.reply({
fieldErrors: {
durationValue: [t('cases:case_detail.add_rule_snooze.errors.max_duration')],
},
}),
{
status: 'error',
errors: [
{
durationValue: [t('cases:case_detail.add_rule_snooze.errors.max_duration')],
},
],
},
{
headers: { 'Set-Cookie': await commitSession(session) },
},
);
}

Expand All @@ -80,26 +92,21 @@ export async function action({ request }: ActionFunctionArgs) {
comment,
});

return json(submission.reply());
return { status: 'success', errors: [] };
} catch (error) {
const session = await getSession(request);
const t = await getFixedT(request, ['common', 'cases']);

let message: string;
if (isStatusConflictHttpError(error)) {
message = t('cases:case_detail.add_rule_snooze.errors.duplicate_rule_snooze');
} else {
message = t('common:errors.unknown');
}

setToastMessage(session, {
type: 'error',
message,
message: isStatusConflictHttpError(error)
? t('cases:case_detail.add_rule_snooze.errors.duplicate_rule_snooze')
: t('common:errors.unknown'),
});

return json(submission.reply({ formErrors: [message] }), {
headers: { 'Set-Cookie': await commitSession(session) },
});
return json(
{ status: 'error', errors: [] },
{
headers: { 'Set-Cookie': await commitSession(session) },
},
);
}
}

Expand All @@ -112,7 +119,7 @@ export function AddRuleSnooze({
decisionId: string;
ruleId: string;
}) {
const [open, setOpen] = React.useState(false);
const [open, setOpen] = useState(false);

return (
<ModalV2.Root open={open} setOpen={setOpen}>
Expand All @@ -135,121 +142,147 @@ function AddRuleSnoozeContent({
}) {
const { t } = useTranslation(['common', 'cases']);
const language = useFormatLanguage();
const dateTimeFieldNames = React.useMemo(
const fetcher = useFetcher<typeof action>();
const dateTimeFieldNames = useMemo(
() =>
new Intl.DisplayNames(language, {
type: 'dateTimeField',
}),
[language],
);

const fetcher = useFetcher<typeof action>();
React.useEffect(() => {
useEffect(() => {
if (fetcher?.data?.status === 'success') {
setOpen(false);
}
}, [setOpen, fetcher?.data?.status]);

const [form, fields] = useForm({
shouldRevalidate: 'onInput',
defaultValue: {
const form = useForm<AddRuleSnoozeForm>({
defaultValues: {
decisionId,
ruleId,
durationValue: 1,
durationUnit: 'days',
},
lastResult: fetcher.data,
constraint: getZodConstraint(addRuleSnoozeFormSchema),
onValidate({ formData }) {
return parseWithZod(formData, {
schema: addRuleSnoozeFormSchema,
});
onSubmit: ({ value, formApi }) => {
if (formApi.state.isValid) {
fetcher.submit(value, {
method: 'POST',
action: getRoute('/ressources/cases/add-rule-snooze'),
encType: 'application/json',
});
}
},
validators: {
onChangeAsync: addRuleSnoozeFormSchema,
onBlurAsync: addRuleSnoozeFormSchema,
onSubmitAsync: addRuleSnoozeFormSchema,
},
});

return (
<FormProvider context={form.context}>
<fetcher.Form
method="post"
action={getRoute('/ressources/cases/add-rule-snooze')}
{...getFormProps(form)}
>
<ModalV2.Title>{t('cases:case_detail.add_rule_snooze.title')}</ModalV2.Title>
<div className="flex flex-col gap-6 p-6">
<ModalV2.Description render={<Callout variant="outlined" />}>
<p className="whitespace-pre text-wrap">
<Trans
t={t}
i18nKey="cases:case_detail.add_rule_snooze.callout"
components={{
DocLink: <ExternalLink href={ruleSnoozesDocHref} />,
}}
/>
</p>
</ModalV2.Description>
<input
{...getInputProps(fields.decisionId, {
type: 'hidden',
})}
/>
<input
{...getInputProps(fields.ruleId, {
type: 'hidden',
})}
/>

<FormField
name={fields.comment.name}
className="row-span-full grid grid-rows-subgrid gap-2"
>
<FormLabel>{t('cases:case_detail.add_rule_snooze.comment.label')}</FormLabel>
<FormTextArea
className="w-full"
placeholder={t('cases:case_detail.add_rule_snooze.comment.placeholder')}
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<ModalV2.Title>{t('cases:case_detail.add_rule_snooze.title')}</ModalV2.Title>
<div className="flex flex-col gap-6 p-6">
<ModalV2.Description render={<Callout variant="outlined" />}>
<p className="whitespace-pre text-wrap">
<Trans
t={t}
i18nKey="cases:case_detail.add_rule_snooze.callout"
components={{
DocLink: <ExternalLink href={ruleSnoozesDocHref} />,
}}
/>
</FormField>

<div className="grid w-full grid-cols-2 grid-rows-[repeat(3,_max-content)] gap-2">
<FormField
name={fields.durationValue.name}
className="row-span-full grid grid-rows-subgrid gap-2"
>
<FormLabel>{t('cases:case_detail.add_rule_snooze.duration_value')}</FormLabel>
<FormInput type="number" className="w-full" />
<FormErrorOrDescription />
</FormField>

<FormField
name={fields.durationUnit.name}
className="row-span-full grid grid-rows-subgrid gap-2"
>
<FormLabel>{t('cases:case_detail.add_rule_snooze.duration_unit')}</FormLabel>
<FormSelect.Default className="h-10 w-full" options={durationUnitOptions}>
{durationUnitOptions.map((unit) => (
<FormSelect.DefaultItem key={unit} value={unit}>
{dateTimeFieldNames.of(adaptDateTimeFieldCodes(unit))}
</FormSelect.DefaultItem>
))}
</FormSelect.Default>
<FormErrorOrDescription />
</FormField>
</div>

<div className="flex flex-1 flex-row gap-2">
<ModalV2.Close render={<Button className="flex-1" variant="secondary" />}>
{t('common:cancel')}
</ModalV2.Close>
<Button className="flex-1" variant="primary" type="submit" name="update">
<LoadingIcon
icon="snooze"
className="size-5"
loading={fetcher.state === 'submitting'}
</p>
</ModalV2.Description>

<form.Field name="comment">
{(field) => (
<div className="row-span-full grid grid-rows-subgrid gap-2">
<FormLabel name={field.name}>
{t('cases:case_detail.add_rule_snooze.comment.label')}
</FormLabel>
<TextArea
className="w-full"
defaultValue={field.state.value}
onChange={(e) => field.handleChange(e.currentTarget.value)}
name={field.name}
onBlur={field.handleBlur}
borderColor={field.state.meta.errors.length === 0 ? 'greyfigma-90' : 'redfigma-47'}
placeholder={t('cases:case_detail.add_rule_snooze.comment.placeholder')}
/>
{t('cases:case_detail.add_rule_snooze.snooze_this_value')}
</Button>
</div>
</div>
)}
</form.Field>

<div className="grid w-full grid-cols-2 grid-rows-[repeat(3,_max-content)] gap-2">
<form.Field name="durationValue">
{(field) => (
<div className="row-span-full grid grid-rows-subgrid gap-2">
<FormLabel name={field.name}>
{t('cases:case_detail.add_rule_snooze.duration_value')}
</FormLabel>
<FormInput
type="number"
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(+e.currentTarget.value)}
onBlur={field.handleBlur}
valid={field.state.meta.errors.length === 0}
className="w-full"
/>
<FormErrorOrDescription errors={field.state.meta.errors} />
</div>
)}
</form.Field>

<form.Field name="durationUnit">
{(field) => (
<div className="row-span-full grid grid-rows-subgrid gap-2">
<FormLabel name={field.name}>
{t('cases:case_detail.add_rule_snooze.duration_unit')}
</FormLabel>
<Select.Default
className="h-10 w-full"
defaultValue={field.state.value}
onValueChange={(unit) =>
field.handleChange(
unit as Exclude<DurationUnit, 'seconds' | 'years' | 'minutes' | 'months'>,
)
}
>
{durationUnitOptions.map((unit) => (
<Select.DefaultItem key={unit} value={unit}>
{dateTimeFieldNames.of(adaptDateTimeFieldCodes(unit))}
</Select.DefaultItem>
))}
</Select.Default>
<FormErrorOrDescription />
</div>
)}
</form.Field>
</div>

<div className="flex flex-1 flex-row gap-2">
<ModalV2.Close render={<Button className="flex-1" variant="secondary" />}>
{t('common:cancel')}
</ModalV2.Close>
<Button className="flex-1" variant="primary" type="submit" name="update">
<LoadingIcon
icon="snooze"
className="size-5"
loading={fetcher.state === 'submitting'}
/>
{t('cases:case_detail.add_rule_snooze.snooze_this_value')}
</Button>
</div>
</fetcher.Form>
</FormProvider>
</div>
</form>
);
}

0 comments on commit 39a30d2

Please sign in to comment.