Skip to content

Commit 1f80d0c

Browse files
committed
refactor(resources/scenarios): migrate scenarios/testrun fetchers
1 parent 578fd72 commit 1f80d0c

9 files changed

Lines changed: 364 additions & 314 deletions

File tree

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { Callout } from '@app-builder/components';
2+
import { ExternalLink } from '@app-builder/components/ExternalLink';
3+
import { DateSelector } from '@app-builder/components/Form/DateSelector';
4+
import { FormErrorOrDescription } from '@app-builder/components/Form/Tanstack/FormErrorOrDescription';
5+
import { FormLabel } from '@app-builder/components/Form/Tanstack/FormLabel';
6+
import { useLoaderRevalidator } from '@app-builder/contexts/LoaderRevalidatorContext';
7+
import { type Scenario } from '@app-builder/models/scenario';
8+
import { type ScenarioIterationWithType } from '@app-builder/models/scenario/iteration';
9+
import {
10+
createTestRunPayloadSchema,
11+
useCreateTestRunMutation,
12+
} from '@app-builder/queries/scenarios/create-testrun';
13+
import { scenarioObjectDocHref } from '@app-builder/services/documentation-href';
14+
import { getFieldErrors } from '@app-builder/utils/form';
15+
import { useForm } from '@tanstack/react-form';
16+
import { useMemo, useState } from 'react';
17+
import { Trans, useTranslation } from 'react-i18next';
18+
import { Button, ButtonV2, ModalV2, Select, Tooltip } from 'ui-design-system';
19+
import { Icon } from 'ui-icons';
20+
21+
export function CreateTestRun({
22+
children,
23+
currentScenario,
24+
scenarioIterations,
25+
atLeastOneActiveTestRun,
26+
}: {
27+
children: React.ReactElement;
28+
currentScenario: Scenario;
29+
scenarioIterations: ScenarioIterationWithType[];
30+
atLeastOneActiveTestRun: boolean;
31+
}) {
32+
const { t } = useTranslation(['common', 'scenarios']);
33+
const [open, setOpen] = useState(false);
34+
35+
const shouldAllowCreate = useMemo(
36+
() =>
37+
scenarioIterations.length > 1 &&
38+
scenarioIterations.some((i) => i.type === 'live version') &&
39+
!atLeastOneActiveTestRun,
40+
[scenarioIterations, atLeastOneActiveTestRun],
41+
);
42+
43+
if (shouldAllowCreate)
44+
return (
45+
<ModalV2.Root open={open} setOpen={setOpen}>
46+
<ModalV2.Trigger render={children} />
47+
<ModalV2.Content className="overflow-visible">
48+
<CreateTestRunToContent
49+
currentScenario={currentScenario}
50+
scenarioIterations={scenarioIterations}
51+
/>
52+
</ModalV2.Content>
53+
</ModalV2.Root>
54+
);
55+
56+
return (
57+
<Tooltip.Default content={t('scenarios:testrun.not_allowed')}>
58+
<ButtonV2 disabled variant="primary" className="isolate cursor-not-allowed">
59+
<Icon icon="plus" className="size-3.5" aria-hidden />
60+
{t('scenarios:create_testrun.title')}
61+
</ButtonV2>
62+
</Tooltip.Default>
63+
);
64+
}
65+
66+
function CreateTestRunToContent({
67+
currentScenario,
68+
scenarioIterations,
69+
}: {
70+
currentScenario: Scenario;
71+
scenarioIterations: ScenarioIterationWithType[];
72+
}) {
73+
const { t } = useTranslation(['common', 'scenarios']);
74+
const createTestRunMutation = useCreateTestRunMutation(currentScenario.id);
75+
const revalidate = useLoaderRevalidator();
76+
77+
const refIterations = useMemo(
78+
() => scenarioIterations.filter(({ type }) => type === 'live version'),
79+
[scenarioIterations],
80+
);
81+
const testIterations = useMemo(
82+
() => scenarioIterations.filter(({ type }) => type !== 'live version' && type !== 'draft'),
83+
[scenarioIterations],
84+
);
85+
const refIterationsOptions = useMemo(() => refIterations.map(({ id }) => id), [refIterations]);
86+
const testIterationsOptions = useMemo(() => testIterations.map(({ id }) => id), [testIterations]);
87+
88+
const form = useForm({
89+
defaultValues: {
90+
refIterationId: refIterationsOptions[0] as string,
91+
testIterationId: testIterationsOptions[0] as string,
92+
endDate: new Date().toISOString(),
93+
},
94+
onSubmit: ({ value, formApi }) => {
95+
if (formApi.state.isValid) {
96+
createTestRunMutation.mutateAsync(value).then(() => {
97+
revalidate();
98+
});
99+
}
100+
},
101+
validators: {
102+
onSubmitAsync: createTestRunPayloadSchema,
103+
},
104+
});
105+
106+
return (
107+
<form
108+
onSubmit={(e) => {
109+
e.preventDefault();
110+
e.stopPropagation();
111+
form.handleSubmit();
112+
}}
113+
>
114+
<ModalV2.Title>{t('scenarios:create_testrun.title')}</ModalV2.Title>
115+
<div className="flex flex-col gap-6 p-6">
116+
<ModalV2.Description render={<Callout variant="outlined" />}>
117+
<p className="whitespace-pre-wrap">
118+
<Trans
119+
t={t}
120+
i18nKey="scenarios:create_testrun.callout"
121+
components={{
122+
DocLink: <ExternalLink href={scenarioObjectDocHref} />,
123+
}}
124+
/>
125+
</p>
126+
</ModalV2.Description>
127+
<div className="flex flex-1 flex-col gap-4">
128+
<div className="flex flex-row items-start gap-2">
129+
<form.Field
130+
name="refIterationId"
131+
validators={{
132+
onBlur: createTestRunPayloadSchema.shape.refIterationId,
133+
onChange: createTestRunPayloadSchema.shape.refIterationId,
134+
}}
135+
>
136+
{(field) => (
137+
<div className="group flex w-full flex-col gap-2">
138+
<FormLabel name={field.name}>{t('scenarios:create_testrun.ref')}</FormLabel>
139+
<Select.Default name={field.name} defaultValue={field.state.value} disabled>
140+
{refIterations.map(({ id }) => {
141+
const iteration = refIterations.find(
142+
({ id: iterationId }) => iterationId === id,
143+
);
144+
return (
145+
<Select.DefaultItem key={id} value={id}>
146+
{`V${iteration?.version} ${
147+
iteration?.type === 'live version' ? t('scenarios:live') : ''
148+
}`}
149+
</Select.DefaultItem>
150+
);
151+
})}
152+
</Select.Default>
153+
<FormErrorOrDescription errors={getFieldErrors(field.state.meta.errors)} />
154+
</div>
155+
)}
156+
</form.Field>
157+
<form.Field
158+
name="testIterationId"
159+
validators={{
160+
onBlur: createTestRunPayloadSchema.shape.testIterationId,
161+
onChange: createTestRunPayloadSchema.shape.testIterationId,
162+
}}
163+
>
164+
{(field) => (
165+
<div className="group flex w-full flex-col gap-2">
166+
<FormLabel name={field.name}>{t('scenarios:create_testrun.phantom')}</FormLabel>
167+
<Select.Default
168+
placeholder={t('scenarios:create_testrun.phantom_placeholder')}
169+
defaultValue={field.state.value}
170+
onValueChange={field.handleChange}
171+
>
172+
{testIterations.map(({ id }) => (
173+
<Select.DefaultItem key={id} value={id}>
174+
{`V${
175+
testIterations.find(({ id: iterationId }) => iterationId === id)?.version
176+
}`}
177+
</Select.DefaultItem>
178+
))}
179+
</Select.Default>
180+
<FormErrorOrDescription errors={getFieldErrors(field.state.meta.errors)} />
181+
</div>
182+
)}
183+
</form.Field>
184+
</div>
185+
<form.Field
186+
name="endDate"
187+
validators={{
188+
onBlur: createTestRunPayloadSchema.shape.endDate,
189+
onChange: createTestRunPayloadSchema.shape.endDate,
190+
}}
191+
>
192+
{(field) => (
193+
<div className="group flex w-full flex-col gap-2">
194+
<FormLabel name={field.name} className="flex flex-row items-center gap-1">
195+
{t('scenarios:create_testrun.end_date')}
196+
</FormLabel>
197+
<DateSelector
198+
name={field.name}
199+
placeholder={t('scenarios:create_testrun.end_date_placeholder')}
200+
onChange={(d) => field.handleChange(d.toISOString())}
201+
defaultValue={new Date(field.state.value)}
202+
/>
203+
<FormErrorOrDescription errors={getFieldErrors(field.state.meta.errors)} />
204+
</div>
205+
)}
206+
</form.Field>
207+
</div>
208+
<div className="flex flex-1 flex-row gap-2">
209+
<ModalV2.Close render={<Button className="flex-1" type="button" variant="secondary" />}>
210+
{t('common:cancel')}
211+
</ModalV2.Close>
212+
<Button className="flex-1" variant="primary" type="submit">
213+
{t('common:save')}
214+
</Button>
215+
</div>
216+
</div>
217+
</form>
218+
);
219+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Callout } from '@app-builder/components';
2+
import { useLoaderRevalidator } from '@app-builder/contexts/LoaderRevalidatorContext';
3+
import { Scenario } from '@app-builder/models/scenario';
4+
import { useCancelTestRunMutation } from '@app-builder/queries/scenarios/cancel-testrun';
5+
import { useState } from 'react';
6+
import { useTranslation } from 'react-i18next';
7+
import { Button, ModalV2 } from 'ui-design-system';
8+
9+
export function CancelTestRun({
10+
children,
11+
currentScenario,
12+
testRunId,
13+
}: {
14+
children: React.ReactElement;
15+
currentScenario: Scenario;
16+
testRunId: string;
17+
}) {
18+
const [open, setOpen] = useState(false);
19+
const cancelTestRunMutation = useCancelTestRunMutation(currentScenario.id, testRunId);
20+
const { t } = useTranslation(['scenarios', 'common']);
21+
const revalidate = useLoaderRevalidator();
22+
23+
const handleCancelScenario = () => {
24+
cancelTestRunMutation.mutateAsync().then(() => {
25+
revalidate();
26+
});
27+
};
28+
29+
return (
30+
<ModalV2.Root open={open} setOpen={setOpen}>
31+
<ModalV2.Trigger render={children} />
32+
<ModalV2.Content className="overflow-visible">
33+
<ModalV2.Title>{t('scenarios:testrun.cancel')}</ModalV2.Title>
34+
<ModalV2.Description render={<Callout variant="outlined" />}>
35+
<p className="whitespace-pre-wrap">{t('scenarios:testrun.cancel.callout')}</p>
36+
</ModalV2.Description>
37+
<div className="flex flex-1 flex-row gap-2">
38+
<ModalV2.Close render={<Button className="flex-1" variant="secondary" />}>
39+
{t('common:cancel')}
40+
</ModalV2.Close>
41+
<Button
42+
className="flex-1"
43+
variant="primary"
44+
onClick={handleCancelScenario}
45+
disabled={cancelTestRunMutation.isPending}
46+
>
47+
{t('scenarios:testrun.cancel')}
48+
</Button>
49+
</div>
50+
</ModalV2.Content>
51+
</ModalV2.Root>
52+
);
53+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useAgnosticNavigation } from '@app-builder/contexts/AgnosticNavigationContext';
2+
import { getRoute } from '@app-builder/utils/routes';
3+
import { fromUUIDtoSUUID } from '@app-builder/utils/short-uuid';
4+
import { useMutation } from '@tanstack/react-query';
5+
6+
const endpoint = (scenarioId: string, testRunId: string) =>
7+
getRoute('/ressources/scenarios/:scenarioId/testrun/:testRunId/cancel', {
8+
scenarioId: fromUUIDtoSUUID(scenarioId),
9+
testRunId: fromUUIDtoSUUID(testRunId),
10+
});
11+
12+
export const useCancelTestRunMutation = (scenarioId: string, testRunId: string) => {
13+
const navigate = useAgnosticNavigation();
14+
15+
return useMutation({
16+
mutationKey: ['scenarios', 'testrun', 'cancel', scenarioId, testRunId],
17+
mutationFn: async () => {
18+
const response = await fetch(endpoint(scenarioId, testRunId), {
19+
method: 'POST',
20+
});
21+
22+
const result = await response.json();
23+
24+
if (result.redirectTo) {
25+
navigate(result.redirectTo);
26+
return;
27+
}
28+
29+
return result;
30+
},
31+
});
32+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useAgnosticNavigation } from '@app-builder/contexts/AgnosticNavigationContext';
2+
import { getRoute } from '@app-builder/utils/routes';
3+
import { fromUUIDtoSUUID } from '@app-builder/utils/short-uuid';
4+
import { useMutation } from '@tanstack/react-query';
5+
import { z } from 'zod/v4';
6+
7+
export const createTestRunPayloadSchema = z.object({
8+
refIterationId: z.string(),
9+
testIterationId: z.string(),
10+
endDate: z.string(),
11+
});
12+
13+
export type CreateTestRunPayload = z.infer<typeof createTestRunPayloadSchema>;
14+
15+
const endpoint = (scenarioId: string) =>
16+
getRoute('/ressources/scenarios/:scenarioId/testrun/create', {
17+
scenarioId: fromUUIDtoSUUID(scenarioId),
18+
});
19+
20+
export const useCreateTestRunMutation = (scenarioId: string) => {
21+
const navigate = useAgnosticNavigation();
22+
23+
return useMutation({
24+
mutationKey: ['scenarios', 'testrun', 'create', scenarioId],
25+
mutationFn: async (payload: CreateTestRunPayload) => {
26+
const response = await fetch(endpoint(scenarioId), {
27+
method: 'POST',
28+
body: JSON.stringify(payload),
29+
});
30+
const result = await response.json();
31+
32+
if (result.redirectTo) {
33+
navigate(result.redirectTo);
34+
return;
35+
}
36+
37+
return result;
38+
},
39+
});
40+
};

packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/home.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ExternalLink } from '@app-builder/components/ExternalLink';
55
import { setToastMessage } from '@app-builder/components/MarbleToaster';
66
import { Nudge } from '@app-builder/components/Nudge';
77
import { Page } from '@app-builder/components/Page';
8+
import { CreateTestRun } from '@app-builder/components/Scenario/Actions/CreateTestRun';
89
import { UpdateScenario } from '@app-builder/components/Scenario/Actions/UpdateScenario';
910
import {
1011
getFormattedLive,
@@ -18,7 +19,6 @@ import { type ScheduledExecution } from '@app-builder/models/decision';
1819
import { type Scenario } from '@app-builder/models/scenario';
1920
import { type ScenarioIterationWithType } from '@app-builder/models/scenario/iteration';
2021
import { useListRulesQuery } from '@app-builder/queries/Workflows';
21-
import { CreateTestRun } from '@app-builder/routes/ressources+/scenarios+/$scenarioId+/testrun+/create';
2222
import { createDecisionDocHref } from '@app-builder/services/documentation-href';
2323
import {
2424
isEditScenarioAvailable,

packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/test-run+/$testRunId+/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import {
44
type BreadCrumbProps,
55
BreadCrumbs,
66
} from '@app-builder/components/Breadcrumbs';
7+
import { CancelTestRun } from '@app-builder/components/Scenario/TestRun/Actions/CancelTestRun';
78
import { DistributionOfDecisionChart } from '@app-builder/components/Scenario/TestRun/Graphs/DistributionOfDecisionChart';
89
import { FilterTransactionByDecision } from '@app-builder/components/Scenario/TestRun/Graphs/FilterTransactionByDecision';
910
import { type Versions } from '@app-builder/components/Scenario/TestRun/Graphs/HamburgerGraph';
1011
import { DistributionOfDecisionChartSkeleton } from '@app-builder/components/Scenario/TestRun/Skeletons/DistributionOfDecicionSkeleton';
1112
import { FilterTransactionByDecisionSkeleton } from '@app-builder/components/Scenario/TestRun/Skeletons/FilterTransactionByDecicionSkeleton';
1213
import { TestRunDetails } from '@app-builder/components/Scenario/TestRun/TestRunDetails';
1314
import { adaptScenarioIterationWithType } from '@app-builder/models/scenario/iteration';
14-
import { CancelTestRun } from '@app-builder/routes/ressources+/scenarios+/$scenarioId+/testrun+/$testRunId+/cancel';
1515
import { initServerServices } from '@app-builder/services/init.server';
1616
import { useOrganizationUsers } from '@app-builder/services/organization/organization-users';
1717
import { getRoute } from '@app-builder/utils/routes';
@@ -109,7 +109,7 @@ export default function TestRun() {
109109
<Page.Header className="justify-between">
110110
<BreadCrumbs />
111111
{run.status === 'up' ? (
112-
<CancelTestRun testRunId={run.id}>
112+
<CancelTestRun currentScenario={currentScenario} testRunId={run.id}>
113113
<Button variant="secondary" color="red" className="isolate h-10 w-fit">
114114
<Icon icon="stop" className="text-grey-100 size-6" />
115115
<span className="text-grey-100">{t('scenarios:testrun.cancel')}</span>

0 commit comments

Comments
 (0)