Skip to content

Commit

Permalink
fix: simu-indicateur1 (#1895)
Browse files Browse the repository at this point in the history
  • Loading branch information
pom421 authored Dec 4, 2023
1 parent 664a512 commit 85c4912
Show file tree
Hide file tree
Showing 10 changed files with 103 additions and 69 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import style from "./UESForm.module.scss";
type ValidateResult = { data?: string; ok: true } | { error: string; ok: false };

const formSchema = zodFr.object({
nom: z.string().trim().nonempty(),
nom: z.string().trim().min(1),
entreprises: z
.array(
z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import RadioButtons from "@codegouvfr/react-dsfr/RadioButtons";
import { IndicateurUnComputer } from "@common/core-domain/computers/IndicateurUnComputer";
import { ageRanges, type ExternalRemunerations, flattenRemunerations } from "@common/core-domain/computers/utils";
import { RemunerationsMode } from "@common/core-domain/domain/valueObjects/declaration/indicators/RemunerationsMode";
import { createSteps } from "@common/core-domain/dtos/CreateSimulationDTO";
import { type CreateSimulationDTO, createSteps } from "@common/core-domain/dtos/CreateSimulationDTO";
import { Object } from "@common/utils/overload";
import { type Any } from "@common/utils/types";
import { storePicker } from "@common/utils/zustand";
Expand All @@ -26,16 +26,26 @@ import { getIsEnoughEmployees } from "./tableUtil";

const schemaOtherComputer = new IndicateurUnComputer();
schemaOtherComputer.setMode(RemunerationsMode.Enum.OTHER_LEVEL);
const formSchema = createSteps.indicateur1
.and(createSteps.effectifs)
.superRefine(({ mode, remunerations, csp }, ctx) => {

const formSchema = (funnel: Partial<CreateSimulationDTO> | undefined) =>
createSteps.indicateur1.superRefine(({ mode, remunerations }, ctx) => {
if (!funnel?.effectifs?.csp) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Les effectifs doivent être renseignés`,
path: ["effectifs"],
});

return z.NEVER;
}

if (mode !== RemunerationsMode.Enum.CSP) {
// test if there is the same amount of CSP in effectifs and remunerations
schemaOtherComputer.setInput(flattenRemunerations(remunerations as ExternalRemunerations));

const { enoughWomen, enoughMen } = getIsEnoughEmployees({
computer: schemaOtherComputer,
effectifsCsp: csp,
effectifsCsp: funnel.effectifs.csp,
});

if (!enoughWomen || !enoughMen) {
Expand All @@ -47,7 +57,7 @@ const formSchema = createSteps.indicateur1
}
}
});
type Indic1FormType = z.infer<typeof formSchema>;
type Indic1FormType = z.infer<typeof createSteps.indicateur1>;
const indicateur1Navigation = NAVIGATION.indicateur1;

const cspComputer = new IndicateurUnComputer();
Expand All @@ -56,6 +66,7 @@ const otherComputer = new IndicateurUnComputer();
otherComputer.setMode(RemunerationsMode.Enum.OTHER_LEVEL);

const useStore = storePicker(useSimuFunnelStore);

export const Indic1Form = () => {
const router = useRouter();
const { data: session } = useSession();
Expand All @@ -74,10 +85,10 @@ export const Indic1Form = () => {

const methods = useForm<Indic1FormType>({
mode: "onChange",
resolver: zodResolver(formSchema),
resolver: zodResolver(formSchema(funnel)),
defaultValues: {
...funnel?.indicateur1,
...funnel?.effectifs,
// ...funnel?.effectifs,
},
criteriaMode: "all",
});
Expand Down Expand Up @@ -169,28 +180,32 @@ export const Indic1Form = () => {
value: mode,
defaultChecked: field.value === mode,
onChange() {
field.onChange(mode);
if (
mode !== RemunerationsMode.Enum.CSP &&
lastMode &&
lastMode !== RemunerationsMode.Enum.CSP
) {
setLastMode(mode);
return field.onChange(mode);
return;
}

const defaultValue =
mode === RemunerationsMode.Enum.CSP
? lastCspRemunerations ?? defaultCspModeRemunerations
: lastOtherRemunerations ?? defaultOtherModesRemunerations;

const currentRemunerations = getValues("remunerations");

if (lastMode && currentRemunerations?.length) {
if (lastMode === RemunerationsMode.Enum.CSP) {
setLastCspRemunerations(currentRemunerations as ExternalRemunerations);
} else {
setLastOtherRemunerations(currentRemunerations as ExternalRemunerations);
}
}

setLastMode(mode);
field.onChange(mode);
if (mode === RemunerationsMode.Enum.CSP || currentRemunerations?.length) {
resetField("remunerations", { defaultValue });
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type IndicateurUnComputer } from "@common/core-domain/computers/Indicat
import { ageRanges, buildRemunerationKey, categories } from "@common/core-domain/computers/utils";
import { type AgeRange } from "@common/core-domain/domain/valueObjects/declaration/AgeRange";
import { type createSteps } from "@common/core-domain/dtos/CreateSimulationDTO";
import { setValueAsFloatOrEmptyString } from "@common/utils/form";
import { currencyFormat, precisePercentFormat } from "@common/utils/number";
import { AideSimulationIndicateurUn } from "@components/aide-simulation/IndicateurUn";
import { type AlternativeTableProps } from "@design-system";
Expand Down Expand Up @@ -112,7 +113,7 @@ export const getCommonBodyColumns = ({
type: "number",
min: 0,
...register(`remunerations.${categoryIndex}.category.${ageRange}.womenSalary`, {
valueAsNumber: true,
setValueAs: setValueAsFloatOrEmptyString,
deps: `remunerations.${categoryIndex}.category.${ageRange}.menSalary`,
}),
},
Expand All @@ -125,7 +126,7 @@ export const getCommonBodyColumns = ({
type: "number",
min: 0,
...register(`remunerations.${categoryIndex}.category.${ageRange}.menSalary`, {
valueAsNumber: true,
setValueAs: setValueAsFloatOrEmptyString,
deps: `remunerations.${categoryIndex}.category.${ageRange}.womenSalary`,
}),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ReactTooltip } from "@components/utils/ReactTooltip";
import { CenteredContainer } from "@design-system";
import { type PropsWithChildren } from "react";

Expand Down Expand Up @@ -27,7 +26,6 @@ const SimulateurFunnelLayout = ({ children }: PropsWithChildren) => {
<Stepper />
</CenteredContainer>
{children}
<ReactTooltip />
</>
);
};
Expand Down
34 changes: 23 additions & 11 deletions packages/app/src/common/core-domain/dtos/CreateSimulationDTO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { CompanyWorkforceRange } from "../domain/valueObjects/declaration/Compan
import { RemunerationsMode } from "../domain/valueObjects/declaration/indicators/RemunerationsMode";

const positiveIntOrEmptyString = zodFr
.literal("")
.literal("", {
errorMap: () => ({
message: "Le champ est requis ",
}),
})
.or(
zodFr
.number()
Expand All @@ -17,7 +21,11 @@ const positiveIntOrEmptyString = zodFr
);

const positivePercentageFloatOrEmptyString = zodFr
.literal("")
.literal("", {
errorMap: () => ({
message: "Le champ est requis ",
}),
})
.or(
zodFr
.number()
Expand Down Expand Up @@ -55,13 +63,13 @@ const otherAgeRangesSchema = zodFr
.object({
womenCount: positiveIntOrEmptyString,
menCount: positiveIntOrEmptyString,
womenSalary: positiveIntOrEmptyString,
menSalary: positiveIntOrEmptyString,
womenSalary: positiveIntOrEmptyString.or(zodFr.undefined()),
menSalary: positiveIntOrEmptyString.or(zodFr.undefined()),
})
.superRefine((obj, ctx) => {
if (obj.womenCount && obj.menCount) {
if (obj.womenCount >= 3 && obj.menCount >= 3) {
if (obj.womenSalary === 0) {
if (obj.womenSalary === 0 || obj.womenSalary === "" || obj.womenSalary === undefined) {
ctx.addIssue({
path: ["womenSalary"],
code: zodFr.ZodIssueCode.too_small,
Expand All @@ -71,7 +79,7 @@ const otherAgeRangesSchema = zodFr
});
}

if (obj.menSalary === 0) {
if (obj.menSalary === 0 || obj.menSalary === "" || obj.menSalary === undefined) {
ctx.addIssue({
path: ["menSalary"],
code: zodFr.ZodIssueCode.too_small,
Expand All @@ -85,8 +93,8 @@ const otherAgeRangesSchema = zodFr
});
const otherAgeRangeNumbers = zodFr.array(
zodFr.object({
name: zodFr.string().nonempty(),
categoryId: zodFr.string().nonempty(),
name: zodFr.string().min(1, "Le champ est requis"),
categoryId: zodFr.string().min(1),
category: zodFr.record(zodFr.nativeEnum(AgeRange.Enum), otherAgeRangesSchema),
}),
);
Expand Down Expand Up @@ -121,13 +129,17 @@ export const createSteps = {
remunerations: zodFr.array(
zodFr.object({
name: zodFr.nativeEnum(CSP.Enum),
categoryId: zodFr.string().nonempty(),
categoryId: zodFr.string().min(1),
category: zodFr
.record(
zodFr.nativeEnum(AgeRange.Enum),
zodFr.object({
womenSalary: zodFr.number().positive("La rémunération ne peut pas être inférieure ou égale à 0"),
menSalary: zodFr.number().positive("La rémunération ne peut pas être inférieure ou égale à 0"),
womenSalary: zodFr
.number({ invalid_type_error: "Le champ est requis" })
.positive("La rémunération ne peut pas être inférieure ou égale à 0"),
menSalary: zodFr
.number({ invalid_type_error: "Le champ est requis" })
.positive("La rémunération ne peut pas être inférieure ou égale à 0"),
}),
)
.optional(),
Expand Down
18 changes: 0 additions & 18 deletions packages/app/src/common/utils/debug.ts

This file was deleted.

7 changes: 1 addition & 6 deletions packages/app/src/common/utils/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,10 @@ export const zodPercentageSchema = zodPositiveOrZeroNumberSchema.refine(percenta
message: "La valeur maximale est 100",
});

// Useful for input which could be empty or a number. NaN is of type number but is not serializable, and is replaced by null with JSON.stringify. So we need to allow null.
export const zodNumberOrNaNOrNull = z
.null()
.or(z.nan().or(z.number({ invalid_type_error: "La valeur doit être un nombre" })));

export const zodNumberOrEmptyString = z
.literal("", {
errorMap: () => ({
message: "La valeur est obligatoire ",
message: "La valeur est obligatoire",
}),
})
.or(z.number({ invalid_type_error: "La valeur doit être un nombre" }));
Expand Down
49 changes: 42 additions & 7 deletions packages/app/src/components/RHF/ReactHookFormDebug.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,78 @@
import { formatZodErrors } from "@common/utils/debug";
import Button from "@codegouvfr/react-dsfr/Button";
import { type Any } from "@common/utils/types";
import { ClientOnly } from "@components/utils/ClientOnly";
import { DebugButton } from "@components/utils/debug/DebugButton";
import { type FieldValues, type FormState, useFormContext } from "react-hook-form";

interface Props {
collapseData?: boolean;
formState?: FormState<FieldValues>;
}

/**
* Recursive traversal of the errors object or array to collect all the errors.
*/
const collectErrors = (issues: Any, path: string[] = []): Array<[string, string]> => {
if (issues?.message) return [[path.join("."), issues.message]];

if (Array.isArray(issues)) {
return issues.flatMap((issue, index) => collectErrors(issue, [...path, String(index)]));
}

if (typeof issues === "object") {
return Object.entries(issues).flatMap(([key, value]) => collectErrors(value, [...path, key]));
}

return [];
};

/*
Counter intuitive behavior of RHF:
It doesn't derive isValid from errors (it's not like Object.keys(errors).length > 0).
In fact :
- isValid is computed when the useForm's mode is set to onChange. On each keystroke, the validation is made and isValid is updated.
- errors for a field is only updated when the user has dirty the field or when the form is submitted. So if the user has not dirty the field, the isValid will probably will be false with no error.
- errors for a field is only updated when the user has dirty the field or when trigger is called. So if the user has not dirty the field, the isValid will probably will be false with no error.
*/

export const ReactHookFormDebug = (props: Props) => {
const { watch, formState } = useFormContext();
const { watch, formState, trigger } = useFormContext();

const { collapseData = true } = props;

// Can't use ...rest because nothing is returned since formState is a Proxy and must be destructured explicitly.
const { errors, isValid } = props.formState || formState;
const { errors, isValid, isDirty } = props.formState || formState;

const presenterErrors = Object.fromEntries(collectErrors(errors));

return (
<ClientOnly>
<fieldset>
<legend>React Hook Form Debug</legend>
<fieldset>
<legend>Form Values</legend>
<pre>{JSON.stringify(watch(), null, 2)}</pre>

<details open={!collapseData}>
<summary>Click me to expand form state data</summary>

<pre>{JSON.stringify(watch(), null, 2)}</pre>
</details>
</fieldset>
<fieldset>
<legend>
<DebugButton obj={errors} alwaysOn infoText="Form Errors" /> Form Errors
</legend>
<pre>isValid: {JSON.stringify(isValid, null, 2)}</pre>
<Button type="button" onClick={() => trigger()}>
Manually trigger validation
</Button>
<pre>
isValid: {JSON.stringify(isValid, null, 2)}
<br />
isDirty: {JSON.stringify(isDirty, null, 2)}
</pre>
{/* when mode is onChange, errors are only updated when the user has dirty the field or when the form is submitted. */}
<pre>{formatZodErrors(errors)}</pre>
<pre>{JSON.stringify(presenterErrors, null, 2)}</pre>
{/* isValid is only updated when the mode of useForm is set to onChange. */}
</fieldset>
</fieldset>
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/utils/debug/DebugButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const DebugButton = ({ obj, infoText, alwaysOn, children }: PropsWithChil
<>
{isDebugEnabled && (
<Button
type="button"
size="small"
iconId="ri-bug-2-line"
onClick={() => {
Expand Down
Loading

0 comments on commit 85c4912

Please sign in to comment.