Skip to content

Commit 88bd016

Browse files
committed
refactor: update Scenario Create form
1 parent 91463ba commit 88bd016

File tree

1 file changed

+171
-124
lines changed
  • packages/app-builder/src/routes/ressources+/scenarios+

1 file changed

+171
-124
lines changed
Lines changed: 171 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,22 @@
11
import { Callout } from '@app-builder/components';
22
import { ExternalLink } from '@app-builder/components/ExternalLink';
3-
import { FormErrorOrDescription } from '@app-builder/components/Form/FormErrorOrDescription';
4-
import { FormField } from '@app-builder/components/Form/FormField';
5-
import { FormInput } from '@app-builder/components/Form/FormInput';
6-
import { FormLabel } from '@app-builder/components/Form/FormLabel';
7-
import { FormSelect } from '@app-builder/components/Form/FormSelect';
3+
import { FormErrorOrDescription } from '@app-builder/components/Form/Tanstack/FormErrorOrDescription';
4+
import { FormInput } from '@app-builder/components/Form/Tanstack/FormInput';
5+
import { FormLabel } from '@app-builder/components/Form/Tanstack/FormLabel';
6+
import { setToastMessage } from '@app-builder/components/MarbleToaster';
87
import { scenarioObjectDocHref } from '@app-builder/services/documentation-href';
98
import { serverServices } from '@app-builder/services/init.server';
109
import { getRoute } from '@app-builder/utils/routes';
1110
import { fromUUID } from '@app-builder/utils/short-uuid';
1211
import * as Ariakit from '@ariakit/react';
13-
import { FormProvider, getFormProps, useForm } from '@conform-to/react';
14-
import { getZodConstraint, parseWithZod } from '@conform-to/zod';
1512
import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node';
1613
import { useFetcher } from '@remix-run/react';
14+
import { useForm } from '@tanstack/react-form';
1715
import { type Namespace } from 'i18next';
1816
import * as React from 'react';
1917
import { Trans, useTranslation } from 'react-i18next';
2018
import { useHydrated } from 'remix-utils/use-hydrated';
21-
import { Button, ModalV2 } from 'ui-design-system';
19+
import { Button, ModalV2, Select } from 'ui-design-system';
2220
import { Icon } from 'ui-icons';
2321
import { z } from 'zod';
2422

@@ -31,47 +29,67 @@ export async function loader({ request }: LoaderFunctionArgs) {
3129
const { dataModelRepository } = await authService.isAuthenticated(request, {
3230
failureRedirect: getRoute('/sign-in'),
3331
});
34-
const dataModel = await dataModelRepository.getDataModel();
3532

36-
return json({
37-
dataModel,
38-
});
33+
return { dataModel: await dataModelRepository.getDataModel() };
3934
}
4035

4136
const createScenarioFormSchema = z.object({
4237
name: z.string().min(1),
43-
description: z.string().nullable().default(null),
38+
description: z.string(),
4439
triggerObjectType: z.string().min(1),
4540
});
4641

42+
type CreateScenarioForm = z.infer<typeof createScenarioFormSchema>;
43+
4744
export async function action({ request }: ActionFunctionArgs) {
48-
const { authService } = serverServices;
49-
const { scenario } = await authService.isAuthenticated(request, {
50-
failureRedirect: getRoute('/sign-in'),
51-
});
45+
const {
46+
authService,
47+
toastSessionService: { getSession, commitSession },
48+
} = serverServices;
5249

53-
const formData = await request.formData();
54-
const submission = parseWithZod(formData, {
55-
schema: createScenarioFormSchema,
56-
});
50+
const [session, rawData, { scenario }] = await Promise.all([
51+
getSession(request),
52+
request.json(),
53+
authService.isAuthenticated(request, {
54+
failureRedirect: getRoute('/sign-in'),
55+
}),
56+
]);
57+
58+
const { data, success, error } = createScenarioFormSchema.safeParse(rawData);
5759

58-
if (submission.status !== 'success') {
59-
return json(submission.reply());
60+
if (!success) {
61+
return json(
62+
{ status: 'error', errors: error.flatten() },
63+
{
64+
headers: { 'Set-Cookie': await commitSession(session) },
65+
},
66+
);
6067
}
6168

6269
try {
63-
const createdScenario = await scenario.createScenario(submission.value);
70+
const createdScenario = await scenario.createScenario(data);
6471
const scenarioIteration = await scenario.createScenarioIteration({
6572
scenarioId: createdScenario.id,
6673
});
74+
6775
return redirect(
6876
getRoute('/scenarios/:scenarioId/i/:iterationId', {
6977
scenarioId: fromUUID(createdScenario.id),
7078
iterationId: fromUUID(scenarioIteration.id),
7179
}),
7280
);
7381
} catch (error) {
74-
return json(submission.reply());
82+
setToastMessage(session, {
83+
type: 'error',
84+
messageKey: 'common:errors.unknown',
85+
});
86+
87+
return json(
88+
{ status: 'error', errors: [] },
89+
{
90+
headers: { 'Set-Cookie': await commitSession(session) },
91+
},
92+
);
7593
}
7694
}
7795

@@ -90,8 +108,8 @@ export function CreateScenario({ children }: { children: React.ReactElement }) {
90108
function CreateScenarioContent() {
91109
const { t, i18n } = useTranslation(handle.i18n);
92110
const dataModelFetcher = useFetcher<typeof loader>();
93-
94111
const { load: loadDataModel } = dataModelFetcher;
112+
95113
React.useEffect(() => {
96114
loadDataModel(getRoute('/ressources/scenarios/create'));
97115
}, [loadDataModel]);
@@ -103,109 +121,138 @@ function CreateScenarioContent() {
103121

104122
const createScenarioFetcher = useFetcher<typeof action>();
105123

106-
const [form, fields] = useForm({
107-
shouldRevalidate: 'onInput',
108-
lastResult: createScenarioFetcher.data,
109-
constraint: getZodConstraint(createScenarioFormSchema),
110-
onValidate({ formData }) {
111-
return parseWithZod(formData, {
112-
schema: createScenarioFormSchema,
113-
});
124+
const form = useForm<CreateScenarioForm>({
125+
defaultValues: { name: '', description: '', triggerObjectType: '' },
126+
onSubmit: ({ value, formApi }) => {
127+
if (formApi.state.isValid) {
128+
createScenarioFetcher.submit(value, {
129+
method: 'PATCH',
130+
action: getRoute('/ressources/scenarios/create'),
131+
encType: 'application/json',
132+
});
133+
}
134+
},
135+
validators: {
136+
onChangeAsync: createScenarioFormSchema,
137+
onBlurAsync: createScenarioFormSchema,
138+
onSubmitAsync: createScenarioFormSchema,
114139
},
115140
});
116141

117142
return (
118-
<FormProvider context={form.context}>
119-
<createScenarioFetcher.Form
120-
method="POST"
121-
action={getRoute('/ressources/scenarios/create')}
122-
{...getFormProps(form)}
123-
>
124-
<ModalV2.Title>{t('scenarios:create_scenario.title')}</ModalV2.Title>
125-
<div className="flex flex-col gap-6 p-6">
126-
<ModalV2.Description render={<Callout variant="outlined" />}>
127-
<p className="whitespace-pre text-wrap">
128-
<Trans
129-
t={t}
130-
i18nKey="scenarios:create_scenario.callout"
131-
components={{
132-
DocLink: <ExternalLink href={scenarioObjectDocHref} />,
133-
}}
134-
/>
135-
</p>
136-
</ModalV2.Description>
137-
<div className="flex flex-1 flex-col gap-4">
138-
<FormField name={fields.name.name} className="group flex w-full flex-col gap-2">
139-
<FormLabel>{t('scenarios:create_scenario.name')}</FormLabel>
140-
<FormInput
141-
type="text"
142-
placeholder={t('scenarios:create_scenario.name_placeholder')}
143-
/>
144-
<FormErrorOrDescription />
145-
</FormField>
146-
<FormField name={fields.description.name} className="group flex w-full flex-col gap-2">
147-
<FormLabel>{t('scenarios:create_scenario.description')}</FormLabel>
148-
<FormInput
149-
type="text"
150-
placeholder={t('scenarios:create_scenario.description_placeholder')}
151-
/>
152-
<FormErrorOrDescription />
153-
</FormField>
154-
<FormField
155-
name={fields.triggerObjectType.name}
156-
className="group flex w-full flex-col gap-2"
157-
>
158-
<FormLabel className="flex flex-row items-center gap-1">
159-
{t('scenarios:create_scenario.trigger_object_title')}
160-
<Ariakit.HovercardProvider
161-
showTimeout={0}
162-
hideTimeout={0}
163-
placement={i18n.dir() === 'ltr' ? 'right' : 'left'}
164-
>
165-
<Ariakit.HovercardAnchor
166-
tabIndex={-1}
167-
className="text-grey-80 hover:text-grey-50 cursor-pointer transition-colors"
168-
>
169-
<Icon icon="tip" className="size-5" />
170-
</Ariakit.HovercardAnchor>
171-
<Ariakit.Hovercard
172-
portal
173-
gutter={4}
174-
className="bg-grey-100 border-grey-90 flex w-fit max-w-80 rounded border p-2 shadow-md"
143+
<form
144+
onSubmit={(e) => {
145+
e.preventDefault();
146+
e.stopPropagation();
147+
form.handleSubmit();
148+
}}
149+
>
150+
<ModalV2.Title>{t('scenarios:create_scenario.title')}</ModalV2.Title>
151+
<div className="flex flex-col gap-6 p-6">
152+
<ModalV2.Description render={<Callout variant="outlined" />}>
153+
<p className="whitespace-pre text-wrap">
154+
<Trans
155+
t={t}
156+
i18nKey="scenarios:create_scenario.callout"
157+
components={{
158+
DocLink: <ExternalLink href={scenarioObjectDocHref} />,
159+
}}
160+
/>
161+
</p>
162+
</ModalV2.Description>
163+
<div className="flex flex-1 flex-col gap-4">
164+
<form.Field name="name">
165+
{(field) => (
166+
<div className="group flex w-full flex-col gap-2">
167+
<FormLabel name={field.name}>{t('scenarios:create_scenario.name')}</FormLabel>
168+
<FormInput
169+
type="text"
170+
name={field.name}
171+
defaultValue={field.state.value}
172+
onChange={(e) => field.handleChange(e.currentTarget.value)}
173+
onBlur={field.handleBlur}
174+
valid={field.state.meta.errors.length === 0}
175+
placeholder={t('scenarios:create_scenario.name_placeholder')}
176+
/>
177+
<FormErrorOrDescription errors={field.state.meta.errors} />
178+
</div>
179+
)}
180+
</form.Field>
181+
<form.Field name="description">
182+
{(field) => (
183+
<div className="group flex w-full flex-col gap-2">
184+
<FormLabel name={field.name}>
185+
{t('scenarios:create_scenario.description')}
186+
</FormLabel>
187+
<FormInput
188+
type="text"
189+
name={field.name}
190+
defaultValue={field.state.value}
191+
onChange={(e) => field.handleChange(e.currentTarget.value)}
192+
onBlur={field.handleBlur}
193+
valid={field.state.meta.errors.length === 0}
194+
placeholder={t('scenarios:create_scenario.description_placeholder')}
195+
/>
196+
<FormErrorOrDescription errors={field.state.meta.errors} />
197+
</div>
198+
)}
199+
</form.Field>
200+
<form.Field name="triggerObjectType">
201+
{(field) => (
202+
<div className="group flex w-full flex-col gap-2">
203+
<FormLabel name={field.name} className="flex flex-row items-center gap-1">
204+
{t('scenarios:create_scenario.trigger_object_title')}
205+
<Ariakit.HovercardProvider
206+
showTimeout={0}
207+
hideTimeout={0}
208+
placement={i18n.dir() === 'ltr' ? 'right' : 'left'}
175209
>
176-
{t('scenarios:trigger_object.description')}
177-
</Ariakit.Hovercard>
178-
</Ariakit.HovercardProvider>
179-
</FormLabel>
180-
<FormSelect.Default
181-
placeholder={t('scenarios:create_scenario.trigger_object_placeholder')}
182-
options={dataModel}
183-
>
184-
{dataModelFetcher.state === 'loading' ? <p>{t('common:loading')}</p> : null}
185-
{dataModel.map((tableName) => {
186-
return (
187-
<FormSelect.DefaultItem key={tableName} value={tableName}>
188-
{tableName}
189-
</FormSelect.DefaultItem>
190-
);
191-
})}
192-
{dataModelFetcher.state === 'idle' && dataModel.length === 0 ? (
193-
<p>{t('scenarios:create_scenario.no_trigger_object')}</p>
194-
) : null}
195-
</FormSelect.Default>
196-
<FormErrorOrDescription />
197-
</FormField>
198-
</div>
199-
<div className="flex flex-1 flex-row gap-2">
200-
<ModalV2.Close render={<Button className="flex-1" variant="secondary" />}>
201-
{t('common:cancel')}
202-
</ModalV2.Close>
203-
<Button className="flex-1" variant="primary" type="submit">
204-
{t('common:save')}
205-
</Button>
206-
</div>
210+
<Ariakit.HovercardAnchor
211+
tabIndex={-1}
212+
className="text-grey-80 hover:text-grey-50 cursor-pointer transition-colors"
213+
>
214+
<Icon icon="tip" className="size-5" />
215+
</Ariakit.HovercardAnchor>
216+
<Ariakit.Hovercard
217+
portal
218+
gutter={4}
219+
className="bg-grey-100 border-grey-90 flex w-fit max-w-80 rounded border p-2 shadow-md"
220+
>
221+
{t('scenarios:trigger_object.description')}
222+
</Ariakit.Hovercard>
223+
</Ariakit.HovercardProvider>
224+
</FormLabel>
225+
<Select.Default
226+
placeholder={t('scenarios:create_scenario.trigger_object_placeholder')}
227+
defaultValue={field.state.value}
228+
onValueChange={field.handleChange}
229+
>
230+
{dataModelFetcher.state === 'loading' ? <p>{t('common:loading')}</p> : null}
231+
{dataModel.map((tableName) => {
232+
return (
233+
<Select.DefaultItem key={tableName} value={tableName}>
234+
{tableName}
235+
</Select.DefaultItem>
236+
);
237+
})}
238+
{dataModelFetcher.state === 'idle' && dataModel.length === 0 ? (
239+
<p>{t('scenarios:create_scenario.no_trigger_object')}</p>
240+
) : null}
241+
</Select.Default>
242+
<FormErrorOrDescription />
243+
</div>
244+
)}
245+
</form.Field>
246+
</div>
247+
<div className="flex flex-1 flex-row gap-2">
248+
<ModalV2.Close render={<Button className="flex-1" variant="secondary" />}>
249+
{t('common:cancel')}
250+
</ModalV2.Close>
251+
<Button className="flex-1" variant="primary" type="submit">
252+
{t('common:save')}
253+
</Button>
207254
</div>
208-
</createScenarioFetcher.Form>
209-
</FormProvider>
255+
</div>
256+
</form>
210257
);
211258
}

0 commit comments

Comments
 (0)