diff --git a/package-lock.json b/package-lock.json index 8b4af04cb..a501208cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "26.8.28", "license": "AGPL-3.0-or-later", "dependencies": { + "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@hello-pangea/dnd": "^18.0.1", @@ -17,13 +18,14 @@ "@mui/system": "^9.1.1", "@mui/x-data-grid": "^9.5.0", "@mui/x-date-pickers": "^9.5.0", + "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.101.0", "@vitejs/plugin-react": "^6.0.2", "axios": "^1.18.0", - "formik": "^2.4.9", "i18next": "^26.3.1", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.0", + "lodash": "^4.18.1", "luxon": "^3.7.2", "markdown-to-jsx": "^9.8.2", "react": "^19.2.7", @@ -2598,6 +2600,50 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.4.tgz", + "integrity": "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==", + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.33.5", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.33.5.tgz", + "integrity": "sha512-3dfx9MBP0aq5sXKteikG629X9oviptrQj0IFRk9YGcb+lB7Kv5x8S17oOSk1wUWgjQZ4xVJEMbKwOAODymocgA==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.101.4", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", @@ -2619,6 +2665,28 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/react-form": { + "version": "1.33.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.33.5.tgz", + "integrity": "sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA==", + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "1.33.5", + "@tanstack/react-store": "^0.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, "node_modules/@tanstack/react-query": { "version": "5.101.4", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", @@ -2653,6 +2721,34 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2875,18 +2971,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", - "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", - "license": "MIT", - "dependencies": { - "hoist-non-react-statics": "^3.3.0" - }, - "peerDependencies": { - "@types/react": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4487,15 +4571,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5319,31 +5394,6 @@ "node": ">= 6" } }, - "node_modules/formik": { - "version": "2.4.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz", - "integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==", - "funding": [ - { - "type": "individual", - "url": "https://opencollective.com/formik" - } - ], - "license": "Apache-2.0", - "dependencies": { - "@types/hoist-non-react-statics": "^3.3.1", - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6477,12 +6527,6 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "license": "MIT" - }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -7243,12 +7287,6 @@ "react-dom": ">= 16.3.0" } }, - "node_modules/react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", - "license": "MIT" - }, "node_modules/react-grid-layout": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz", @@ -7875,12 +7913,6 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8000,7 +8032,9 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "dev": true, + "license": "0BSD", + "optional": true }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index 3cbc7bbc4..6e2c5bfc3 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "access": "public" }, "dependencies": { + "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@hello-pangea/dnd": "^18.0.1", @@ -41,13 +42,14 @@ "@mui/system": "^9.1.1", "@mui/x-data-grid": "^9.5.0", "@mui/x-date-pickers": "^9.5.0", + "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.101.0", "@vitejs/plugin-react": "^6.0.2", "axios": "^1.18.0", - "formik": "^2.4.9", "i18next": "^26.3.1", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.0", + "lodash": "^4.18.1", "luxon": "^3.7.2", "markdown-to-jsx": "^9.8.2", "react": "^19.2.7", diff --git a/src/components/Exercises/forms/ExerciseAliases.tsx b/src/components/Exercises/forms/ExerciseAliases.tsx index 200e81ca8..3cacdba9a 100644 --- a/src/components/Exercises/forms/ExerciseAliases.tsx +++ b/src/components/Exercises/forms/ExerciseAliases.tsx @@ -1,13 +1,19 @@ import { Autocomplete, Chip, InputAdornment, TextField } from "@mui/material"; -import { useField } from "formik"; +import { useFieldContext } from "@/core/forms/formContexts"; +import { useNestedFieldError } from "@/core/forms/useNestedFieldError"; import React from "react"; import { useTranslation } from "react-i18next"; -type AliasItem = { id?: number; alias: string }; +export type AliasItem = { id?: number; alias: string }; -export function ExerciseAliases(props: { fieldName: string }) { +/** Bound to the form field it is rendered in via form.AppField */ +export function ExerciseAliases() { const [t] = useTranslation(); - const [field, meta, helpers] = useField(props.fieldName); + const field = useFieldContext(); + // The validator reports on the single aliases, e.g. `aliases[0].alias` + const nestedError = useNestedFieldError(field); + const error = field.state.meta.isTouched ? nestedError : undefined; + const value = field.state.value || []; const normalize = (items: (AliasItem | string)[] | null | undefined): AliasItem[] => { const seen = new Set(); @@ -26,38 +32,11 @@ export function ExerciseAliases(props: { fieldName: string }) { }); }; - /** - * Extract a human-readable error string from the Yup alias validator, which - * returns a list of errors. - */ - const formatError = (err: unknown): string | undefined => { - if (!err) return undefined; - if (typeof err === "string") return err; - - if (typeof err === "object") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const o = err as any; - if (typeof o.alias === "string") return o.alias; - if (typeof o.message === "string") return o.message; - - for (const k of Object.keys(o)) { - const v = o[k]; - if (typeof v === "string") return v; - if (v && typeof v === "object") { - if (typeof v.alias === "string") return v.alias; - if (typeof v.message === "string") return v.message; - } - } - } - - return String(err); - }; - return (typeof opt === "string" ? opt : opt.alias)} isOptionEqualToValue={(option, value) => { @@ -68,18 +47,14 @@ export function ExerciseAliases(props: { fieldName: string }) { return optionAlias === valueAlias && (optionId === valueId || optionId === undefined || valueId === undefined); }} onChange={(_, newValue) => { - helpers.setValue(normalize(newValue)); + field.handleChange(normalize(newValue)); }} - onBlur={field.onBlur} + onBlur={field.handleBlur} renderInput={(params) => { - const chips = (field.value || []).map((option, index) => ( + const chips = value.map((option, index) => ( { - const newVal = [...(field.value || [])]; - newVal.splice(index, 1); - helpers.setValue(newVal); - }} + onDelete={() => field.handleChange(value.filter((_, i) => i !== index))} key={option.id ?? option.alias} /> )); @@ -90,8 +65,8 @@ export function ExerciseAliases(props: { fieldName: string }) { id="exerciseAliases" variant="standard" label={t("exercises.alternativeNames")} - error={meta.touched && Boolean(meta.error)} - helperText={meta.touched ? formatError(meta.error) : undefined} + error={error !== undefined} + helperText={error} slotProps={{ ...params.slotProps, input: { diff --git a/src/components/Exercises/forms/ExerciseEquipmentSelect.tsx b/src/components/Exercises/forms/ExerciseEquipmentSelect.tsx index 6cc2d966f..698949f4c 100644 --- a/src/components/Exercises/forms/ExerciseEquipmentSelect.tsx +++ b/src/components/Exercises/forms/ExerciseEquipmentSelect.tsx @@ -1,29 +1,28 @@ import { Autocomplete, TextField } from "@mui/material"; -import { useField } from "formik"; +import { useFieldContext } from "@/core/forms/formContexts"; import React from "react"; import { useTranslation } from "react-i18next"; +/** Bound to the form field it is rendered in via form.AppField */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function ExerciseEquipmentSelect(props: { fieldName: string, options: any[] }) { +export function ExerciseEquipmentSelect(props: { options: any[] }) { const [t] = useTranslation(); - const [field, , helpers] = useField(props.fieldName); + const field = useFieldContext(); return e.id)} getOptionLabel={option => props.options.find(e => e.id === option)!.translatedName} - {...field} - onChange={(event, newValue) => { - helpers.setValue(newValue); - }} + value={field.state.value} + onChange={(event, newValue) => field.handleChange(newValue)} + onBlur={field.handleBlur} renderInput={params => ( )} />; -} \ No newline at end of file +} diff --git a/src/components/Exercises/forms/ExerciseName.tsx b/src/components/Exercises/forms/ExerciseName.tsx index 1d0d95f8a..ffdfc88c9 100644 --- a/src/components/Exercises/forms/ExerciseName.tsx +++ b/src/components/Exercises/forms/ExerciseName.tsx @@ -1,19 +1,25 @@ import { useTranslation } from "react-i18next"; import { TextField } from "@mui/material"; +import { useFieldContext } from "@/core/forms/formContexts"; +import { fieldError } from "@/core/forms/formUtils"; import React from "react"; -import { useField } from "formik"; -export function ExerciseName(props: { fieldName: string }) { +/** Bound to the form field it is rendered in via form.AppField */ +export function ExerciseName() { const [t] = useTranslation(); - const [field, meta] = useField(props.fieldName); + const field = useFieldContext(); + const error = fieldError(field); return field.handleChange(event.target.value)} + onBlur={field.handleBlur} + error={error !== undefined} + helperText={error} />; -} \ No newline at end of file +} diff --git a/src/components/Exercises/forms/ExerciseNotes.tsx b/src/components/Exercises/forms/ExerciseNotes.tsx index 74c170bb2..b1279a786 100644 --- a/src/components/Exercises/forms/ExerciseNotes.tsx +++ b/src/components/Exercises/forms/ExerciseNotes.tsx @@ -2,30 +2,34 @@ import AddIcon from '@mui/icons-material/Add'; import DeleteIcon from '@mui/icons-material/Delete'; import { IconButton, InputAdornment, TextField } from "@mui/material"; import Grid from '@mui/material/Grid'; -import { useField } from "formik"; +import { useFieldContext } from "@/core/forms/formContexts"; +import { useNestedFieldError } from "@/core/forms/useNestedFieldError"; import React, { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { randomUUID } from "@/core/lib/uuid"; -export function ExerciseNotes(props: { fieldName: string }) { +/** Bound to the form field it is rendered in via form.AppField */ +export function ExerciseNotes() { const [t] = useTranslation(); - const [field, meta, helpers] = useField(props.fieldName); + const field = useFieldContext(); + const notes = field.state.value; + // The validator reports on the single notes, e.g. `notes[0]` + const nestedError = useNestedFieldError(field); + const error = field.state.meta.isTouched ? nestedError : undefined; const [newNoteValue, setNewNoteValue] = useState(''); - const noteKeys = useRef(field.value.map(() => randomUUID())); + const noteKeys = useRef(notes.map(() => randomUUID())); const deleteAtIndex = (index: number) => { noteKeys.current.splice(index, 1); - helpers.setValue(field.value.filter((_: string, b: number) => b !== index)); + field.handleChange(notes.filter((_, i) => i !== index)); }; const setNoteValueIndex = (index: number, note: string) => { - field.value[index] = note; - helpers.setValue(field.value); + field.handleChange(notes.map((existing, i) => i === index ? note : existing)); }; const addEntry = () => { noteKeys.current.push(randomUUID()); - field.value.push(newNoteValue); - helpers.setValue(field.value); + field.handleChange([...notes, newNoteValue]); setNewNoteValue(''); }; @@ -38,8 +42,8 @@ export function ExerciseNotes(props: { fieldName: string }) { variant="standard" value={newNoteValue} onChange={event => setNewNoteValue(event.target.value)} - error={meta.touched && Boolean(meta.error)} - helperText={meta.touched && meta.error ? meta.error : t('exercises.notesHelpText')} + error={error !== undefined} + helperText={error ?? t('exercises.notesHelpText')} slotProps={{ input: { endAdornment: ( @@ -53,7 +57,7 @@ export function ExerciseNotes(props: { fieldName: string }) { }} /> - {field.value.map((note: string, index: number) => + {notes.map((note: string, index: number) => setNoteValueIndex(index, event.target.value)} sx={{ mt: 2 }} variant="standard" - error={meta.touched && Boolean(meta.error)} + error={error !== undefined} slotProps={{ input: { endAdornment: ( diff --git a/src/components/Exercises/forms/ExerciseSelect.tsx b/src/components/Exercises/forms/ExerciseSelect.tsx index e9c1154b4..930bcbcba 100644 --- a/src/components/Exercises/forms/ExerciseSelect.tsx +++ b/src/components/Exercises/forms/ExerciseSelect.tsx @@ -1,28 +1,30 @@ import { FormControl, FormHelperText, InputLabel, Select } from "@mui/material"; -import { useField } from "formik"; +import { useFieldContext } from "@/core/forms/formContexts"; +import { fieldError } from "@/core/forms/formUtils"; import React from "react"; import { useTranslation } from "react-i18next"; +/** Bound to the form field it is rendered in via form.AppField */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function ExerciseSelect(props: { fieldName: string, options: any }) { +export function ExerciseSelect(props: { options: any }) { const [t] = useTranslation(); - const [field, meta] = useField(props.fieldName); + const field = useFieldContext(); + const error = fieldError(field); return {t("category")} - { - meta.touched - && Boolean(meta.error) - && {meta.error} - } + {error !== undefined && {error}} ; -} \ No newline at end of file +} diff --git a/src/components/Exercises/forms/ImageDropZone.tsx b/src/components/Exercises/forms/ImageDropZone.tsx index 9c1faab0e..b62c3dd4a 100644 --- a/src/components/Exercises/forms/ImageDropZone.tsx +++ b/src/components/Exercises/forms/ImageDropZone.tsx @@ -1,13 +1,17 @@ import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'; import { Box, Stack, Typography } from "@mui/material"; -import { ImageFormData } from "@/components/Exercises/models/exercise"; -import { useFormikContext } from "formik"; import * as React from 'react'; import { useTranslation } from "react-i18next"; -export function ImageDropZone() { +interface ImageDropZoneProps { + /** The preview, empty while no image was picked */ + url: string; + /** Called with the picked file and its object URL */ + onPick: (file: File, url: string) => void; +} + +export function ImageDropZone({ url, onPick }: ImageDropZoneProps) { const [t] = useTranslation(); - const { values, setFieldValue } = useFormikContext(); const [isDragOver, setIsDragOver] = React.useState(false); const inputRef = React.useRef(null); @@ -15,8 +19,7 @@ export function ImageDropZone() { if (!file.type.startsWith('image/')) { return; } - setFieldValue('file', file); - setFieldValue('url', URL.createObjectURL(file)); + onPick(file, URL.createObjectURL(file)); }; const handleDrop = (e: React.DragEvent) => { @@ -55,10 +58,10 @@ export function ImageDropZone() { '& > *:not(input)': { pointerEvents: 'none' }, }} > - {values.url ? ( + {url ? ( Preview ) : ( diff --git a/src/components/Exercises/forms/ImageModal.test.tsx b/src/components/Exercises/forms/ImageModal.test.tsx index 24aee32e2..fa461a394 100644 --- a/src/components/Exercises/forms/ImageModal.test.tsx +++ b/src/components/Exercises/forms/ImageModal.test.tsx @@ -129,4 +129,25 @@ describe("ImageFormModal", () => { expect(screen.getByText("exercises.dropOrClickImage")).toBeInTheDocument(); expect(screen.getByTestId("submit-edit-image-form")).toBeDisabled(); }); + + test("the typed license fields are passed to submit", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render( + + ); + + await user.type(screen.getByLabelText("licenses.authors"), "Jane Doe"); + await user.type(screen.getByLabelText("licenses.authorProfile"), "https://example.com/jane"); + await user.click(screen.getByTestId("submit-edit-image-form")); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const submittedValues = onSubmit.mock.calls[0][0] as ImageFormData; + expect(submittedValues.title).toBe("An existing image"); + expect(submittedValues.author).toBe("Jane Doe"); + expect(submittedValues.authorUrl).toBe("https://example.com/jane"); + // Editing keeps the stored image when no new file is picked + expect(submittedValues.file).toBeUndefined(); + expect(submittedValues.url).toBe("https://example.com/squat.jpg"); + }); }); diff --git a/src/components/Exercises/forms/ImageModal.tsx b/src/components/Exercises/forms/ImageModal.tsx index d6f68e7c0..dd9594b96 100644 --- a/src/components/Exercises/forms/ImageModal.tsx +++ b/src/components/Exercises/forms/ImageModal.tsx @@ -1,11 +1,7 @@ import { Alert, Box, Button, Grid, Modal, Stack, Typography } from "@mui/material"; import InfoIcon from '@mui/icons-material/Info'; -import { LicenseAuthor } from "@/core/forms/LicenseAuthor"; -import { LicenseAuthorUrl } from "@/core/forms/LicenseAuthorUrl"; -import { LicenseDerivativeSourceUrl } from "@/core/forms/LicenseDerivativeSourceUrl"; -import { LicenseObjectUrl } from "@/core/forms/LicenseObjectUrl"; -import { LicenseTitle } from "@/core/forms/LicenseTitle"; -import { Form, Formik } from "formik"; +import { useAppForm } from "@/core/forms/appForm"; +import { defaultsKey, submitHandler } from "@/core/forms/formUtils"; import { ImageDropZone } from "./ImageDropZone"; import { ImageIsAiCheckbox, ImageStyleToggle } from "./ImageStyle"; import { useTranslation } from "react-i18next"; @@ -34,75 +30,120 @@ const style = { p: 4, }; -export const ImageFormModal = ({ - open, - onClose, - image, - onSubmit, - submitLabel +export const ImageFormModal = ({ + open, + onClose, + image, + onSubmit, + submitLabel }: ImageFormModalProps) => { const { t } = useTranslation(); // If no image is provided, don't render or show a loader if (!image) return null; - + return ( - {t('exercises.imageDetails')} - - {({ values, submitForm }) => ( -
- - - - - - - - - - - - - - - } severity="info"> - By submitting this image, you agree to release it under the CC - BY-SA 4.0 license. The image must be either your own work or the - author must have released in under - a license compatible with CC BY-SA 4.0. - - - - - - - - -
- )} -
+ {/* The form freezes its default values, so another image gets a fresh form via the key */} +
); -}; \ No newline at end of file +}; + +const ImageForm = ({ image, onSubmit, submitLabel }: Pick & { + image: ImageFormData, +}) => { + const { t } = useTranslation(); + const form = useAppForm({ + defaultValues: image, + onSubmit: async ({ value }) => onSubmit(value), + }); + + return ( +
+ + + state.values.url}> + {url => { + form.setFieldValue('file', file); + form.setFieldValue('url', pickedUrl); + }} + />} + + + + + + {field => } + + + {field => } + + + {field => } + + + {field => } + + + {field => } + + {() => } + {() => } + + } severity="info"> + By submitting this image, you agree to release it under the CC + BY-SA 4.0 license. The image must be either your own work or the + author must have released in under + a license compatible with CC BY-SA 4.0. + + + + + + + state.values.url}> + {url => } + + +
+ ); +}; diff --git a/src/components/Exercises/forms/ImageStyle.tsx b/src/components/Exercises/forms/ImageStyle.tsx index 5e587e327..51b72e433 100644 --- a/src/components/Exercises/forms/ImageStyle.tsx +++ b/src/components/Exercises/forms/ImageStyle.tsx @@ -7,18 +7,14 @@ import { Checkbox, FormControlLabel, Stack, Typography } from "@mui/material"; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import { ImageStyle } from "@/components/Exercises/models/image"; -import { useField } from "formik"; +import { useFieldContext } from "@/core/forms/formContexts"; import * as React from 'react'; import { useTranslation } from "react-i18next"; -export function ImageStyleToggle(props: { fieldName: string }) { +/** Bound to the form field it is rendered in via form.AppField */ +export function ImageStyleToggle() { const [t] = useTranslation(); - - const [field, , helpers] = useField(props.fieldName); - const selectedStyle = (field.value !== undefined && field.value !== null && field.value !== '') - ? Number(field.value) - : undefined; - const [style, setStyle] = React.useState(selectedStyle); + const field = useFieldContext(); const handleAlignment = ( event: React.MouseEvent, @@ -27,13 +23,12 @@ export function ImageStyleToggle(props: { fieldName: string }) { if (newStyle === null) { return; } - helpers.setValue(newStyle); - setStyle(newStyle); + field.handleChange(newStyle); }; return ( (props.fieldName); + const field = useFieldContext(); return ( helpers.setValue(e.target.checked)} + name={field.name} + checked={!!field.state.value} + onChange={(e) => field.handleChange(e.target.checked)} data-testid="image-is-ai-checkbox" /> } diff --git a/src/components/Exercises/screens/Add/Step1Basics.test.tsx b/src/components/Exercises/screens/Add/Step1Basics.test.tsx index f98c5b2ca..e9b11b7a7 100644 --- a/src/components/Exercises/screens/Add/Step1Basics.test.tsx +++ b/src/components/Exercises/screens/Add/Step1Basics.test.tsx @@ -134,4 +134,29 @@ describe("", () => { ]); expect(setEquipment).toHaveBeenCalledWith([42]); }); + + test("a too short alias shows its error and blocks the step", async () => { + // Arrange + const user = userEvent.setup(); + const mockOnContinue = vi.fn(); + const queryClient = new QueryClient(); + render( + + + + + + ); + + // Act: the validator complains about the single alias, not the list + await user.type(screen.getByLabelText("name"), 'Biceps enlarger'); + const aliases = screen.getByLabelText("exercises.alternativeNames"); + await user.type(aliases, 'abc'); + await user.keyboard('{enter}'); + await user.click(screen.getByText('continue')); + + // Assert + expect(await screen.findByText('forms.minLength')).toBeInTheDocument(); + expect(mockOnContinue).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/Exercises/screens/Add/Step1Basics.tsx b/src/components/Exercises/screens/Add/Step1Basics.tsx index f9671f305..bcc765a9b 100644 --- a/src/components/Exercises/screens/Add/Step1Basics.tsx +++ b/src/components/Exercises/screens/Add/Step1Basics.tsx @@ -2,20 +2,29 @@ import { Autocomplete, Box, Button, MenuItem, Stack, TextField, } from "@mui/mat import Grid from '@mui/material/Grid'; import { LoadingWidget } from "@/core/ui/LoadingWidget/LoadingWidget"; import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; -import { ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases"; +import { AliasItem, ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases"; import { ExerciseEquipmentSelect } from "@/components/Exercises/forms/ExerciseEquipmentSelect"; import { ExerciseName } from "@/components/Exercises/forms/ExerciseName"; import { ExerciseSelect } from "@/components/Exercises/forms/ExerciseSelect"; import { alternativeNameValidator, categoryValidator, nameValidator } from "@/components/Exercises/forms/yupValidators"; import { useCategoriesQuery, useEquipmentQuery, useMusclesQuery, } from "@/components/Exercises/queries"; import { MuscleOverview } from "@/components/Muscles/MuscleOverview"; -import { Form, Formik } from "formik"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, submitHandler } from "@/core/forms/formUtils"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useExerciseSubmissionStateValue } from "@/components/Exercises/screens/Add/state"; import * as exerciseReducer from "@/components/Exercises/screens/Add/state/exerciseSubmissionReducer"; import * as yup from "yup"; +interface Step1Values { + nameEn: string, + newAlternativeNameEn: AliasItem[], + // the empty string stands in for "not picked yet", MUI selects don't accept null + category: number | '', + equipment: number[], +} + export const Step1Basics = ({ onContinue }: StepProps) => { const [t] = useTranslation(); const [state, dispatch] = useExerciseSubmissionStateValue(); @@ -42,139 +51,130 @@ export const Step1Basics = ({ onContinue }: StepProps) => { category: categoryValidator(), }); + const defaultValues: Step1Values = { + nameEn: state.nameEn, + // The alias field and its validator work with objects, the state keeps plain strings + newAlternativeNameEn: state.alternativeNamesEn.map(alias => ({ alias })), + category: state.category !== null ? state.category : '', + equipment: state.equipment, + }; + + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + dispatch(exerciseReducer.setNameEn(value.nameEn)); + dispatch(exerciseReducer.setCategory(value.category as number)); + dispatch(exerciseReducer.setAlternativeNamesEn(value.newAlternativeNameEn.map(item => item.alias))); + dispatch(exerciseReducer.setEquipment(value.equipment)); + + onContinue!(); + }, + }); + return ( - ({ alias })), - category: state.category !== null ? state.category : '', - muscles: state.muscles, - equipment: state.equipment, - musclesSecondary: state.musclesSecondary, - }} - validationSchema={validationSchema} - onSubmit={values => { - dispatch(exerciseReducer.setNameEn(values.nameEn)); - dispatch(exerciseReducer.setCategory(values.category as number)); - dispatch(exerciseReducer.setAlternativeNamesEn(values.newAlternativeNameEn.map(item => item.alias))); - dispatch(exerciseReducer.setEquipment(values.equipment)); +
+ + {() => } + {() => } - onContinue!(); - }} - > - {formik => { - return ( - ( - - - + {categoryQuery.isLoading + ? + : + {() => ( + + {category.translatedName} + + ))} + />} + + } - {categoryQuery.isLoading - ? - : ( - - {category.translatedName} - - ))} - /> - } + {equipmentQuery.isLoading + ? + : + {() => } + + } - {equipmentQuery.isLoading - ? - : + {musclesQuery.isLoading + ? + : <> + m.id)} + getOptionDisabled={(option) => + secondaryMuscles.includes(option) } - - {musclesQuery.isLoading - ? - : <> - m.id)} - getOptionDisabled={(option) => - secondaryMuscles.includes(option) - } - getOptionLabel={option => musclesQuery.data!.find(m => m.id === option)!.getName()} - value={primaryMuscles} - onChange={(event, newValue) => { - setPrimaryMuscles(newValue); - }} - renderInput={params => ( - { - formik.setFieldValue( - formik.getFieldProps("muscles").name, - e.target.value - ); - }} - /> - )} - /> - m.id)} - getOptionDisabled={(option) => - primaryMuscles.includes(option) - } - getOptionLabel={option => musclesQuery.data!.find(m => m.id === option)!.getName()} - value={secondaryMuscles} - onChange={(event, newValue) => { - setSecondaryMuscles(newValue); - }} - renderInput={params => ( - - )} - /> - - - musclesQuery.data!.find(mq => mq.id === m)!)} - secondaryMuscles={secondaryMuscles.map(m => musclesQuery.data!.find(mq => mq.id === m)!)} - isFront={true} - /> - - - musclesQuery.data!.find(mq => mq.id === m)!)} - secondaryMuscles={secondaryMuscles.map(m => musclesQuery.data!.find(mq => mq.id === m)!)} - isFront={false} - /> - - - + getOptionLabel={option => musclesQuery.data!.find(m => m.id === option)!.getName()} + value={primaryMuscles} + onChange={(event, newValue) => { + setPrimaryMuscles(newValue); + }} + renderInput={params => ( + + )} + /> + m.id)} + getOptionDisabled={(option) => + primaryMuscles.includes(option) } - + getOptionLabel={option => musclesQuery.data!.find(m => m.id === option)!.getName()} + value={secondaryMuscles} + onChange={(event, newValue) => { + setSecondaryMuscles(newValue); + }} + renderInput={params => ( + + )} + /> - - -
- -
-
+ + musclesQuery.data!.find(mq => mq.id === m)!)} + secondaryMuscles={secondaryMuscles.map(m => musclesQuery.data!.find(mq => mq.id === m)!)} + isFront={true} + /> + + + musclesQuery.data!.find(mq => mq.id === m)!)} + secondaryMuscles={secondaryMuscles.map(m => musclesQuery.data!.find(mq => mq.id === m)!)} + isFront={false} + />
- ) - ); - }} -
+ + } + + + + +
+ +
+
+
+
+ ); }; diff --git a/src/components/Exercises/screens/Add/Step3Description.test.tsx b/src/components/Exercises/screens/Add/Step3Description.test.tsx index c98bd2fae..dad9872db 100644 --- a/src/components/Exercises/screens/Add/Step3Description.test.tsx +++ b/src/components/Exercises/screens/Add/Step3Description.test.tsx @@ -141,4 +141,16 @@ describe("Test the add exercise step 3 component", () => { // But the user does NOT advance to the next step expect(mockOnContinue).not.toHaveBeenCalled(); }); + + test("a too short description shows the error and skips the language check", async () => { + const user = userEvent.setup(); + renderStep(); + + await fillDescription(user, 'too short'); + await user.click(screen.getByText("continue")); + + expect(await screen.findByText('forms.minLength')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + expect(mockOnContinue).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/Exercises/screens/Add/Step3Description.tsx b/src/components/Exercises/screens/Add/Step3Description.tsx index e0519a49e..cb5d643fe 100644 --- a/src/components/Exercises/screens/Add/Step3Description.tsx +++ b/src/components/Exercises/screens/Add/Step3Description.tsx @@ -6,13 +6,18 @@ import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox"; import { MarkdownEditor } from "@/core/forms/MarkdownEditor"; import { ExerciseNotes } from "@/components/Exercises/forms/ExerciseNotes"; import { descriptionValidator, noteValidator } from "@/components/Exercises/forms/yupValidators"; -import { Form, Formik } from "formik"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, fieldError, setServerError, submitHandler } from "@/core/forms/formUtils"; import { useTranslation } from "react-i18next"; import { useExerciseSubmissionStateValue } from "@/components/Exercises/screens/Add/state"; import { setDescriptionEn, setNotesEn } from "@/components/Exercises/screens/Add/state/exerciseSubmissionReducer"; import { ENGLISH_LANGUAGE_ID } from "@/core/lib/consts"; import * as yup from "yup"; +interface Step3Values { + description: string, + notes: string[], +} export const Step3Description = ({ onContinue, onBack }: StepProps) => { const [t] = useTranslation(); @@ -24,80 +29,88 @@ export const Step3Description = ({ onContinue, onBack }: StepProps) => { notes: noteValidator() }); - return ( - ( { - let canContinue: boolean; + const defaultValues: Step3Values = { + description: state.descriptionEn, + notes: state.notesEn, + }; - const validationResult = await languageCheckQuery.mutateAsync({ - input: values.description, - languageId: ENGLISH_LANGUAGE_ID - }); + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + let canContinue: boolean; - // @ts-ignore - validationResult contains the message from the backend - if ("success" in validationResult) { - canContinue = true; - } else { - canContinue = false; + const validationResult = await languageCheckQuery.mutateAsync({ + input: value.description, + languageId: ENGLISH_LANGUAGE_ID + }); + + // @ts-ignore - validationResult contains the message from the backend + if ("success" in validationResult) { + canContinue = true; + } else { + canContinue = false; - // @ts-ignore - validationResult contains the message from the backend - setFieldError('description', validationResult.check.message); - } + // @ts-ignore - validationResult contains the message from the backend + setServerError(form, 'description', validationResult.check.message); + } - dispatch(setDescriptionEn(values.description)); - dispatch(setNotesEn(values.notes)); + dispatch(setDescriptionEn(value.description)); + dispatch(setNotesEn(value.notes)); - if (canContinue) { - onContinue!(); - } + if (canContinue) { + onContinue!(); + } + }, + }); - }} - > - {({ values, errors, touched, setFieldValue }) => ( -
- - + + + {field => { + const error = fieldError(field); + return setFieldValue('description', val)} - error={touched.description && Boolean(errors.description)} - helperText={touched.description ? errors.description : undefined} - /> + value={field.state.value} + onChange={(val) => { + // The server's verdict was about the old text + setServerError(form, 'description', undefined); + field.handleChange(val); + }} + error={error !== undefined} + helperText={error} + />; + }} + - + - + {() => } - - - -
- - -
-
-
-
-
- - )} -
) + + + +
+ + +
+
+
+
+ + ); -}; \ No newline at end of file +}; diff --git a/src/components/Exercises/screens/Add/Step4Translation.test.tsx b/src/components/Exercises/screens/Add/Step4Translation.test.tsx index 7fcc908e2..f9f1a1ef6 100644 --- a/src/components/Exercises/screens/Add/Step4Translation.test.tsx +++ b/src/components/Exercises/screens/Add/Step4Translation.test.tsx @@ -115,12 +115,10 @@ describe("Test the add exercise step 4 component", () => { expect(mutateAsync).not.toHaveBeenCalled(); }); - test("with translation enabled and a valid description: triggers the language check and dispatches all values", async () => { - const VALID_DESCRIPTION = - "Eine ausreichend lange Beschreibung der Übung, die die yup-Mindestlänge erfüllt."; - const user = userEvent.setup(); - renderStep(); + const VALID_DESCRIPTION = + "Eine ausreichend lange Beschreibung der Übung, die die yup-Mindestlänge erfüllt."; + async function fillTranslation(user: ReturnType) { // Enable the form await user.click(screen.getByRole("switch")); @@ -134,7 +132,13 @@ describe("Test the add exercise step 4 component", () => { // Fill the markdown description (uses the placeholder from t('useMarkdownHint')) const description = screen.getByPlaceholderText("useMarkdownHint"); await user.type(description, VALID_DESCRIPTION); + } + + test("with translation enabled and a valid description: triggers the language check and dispatches all values", async () => { + const user = userEvent.setup(); + renderStep(); + await fillTranslation(user); await user.click(screen.getByText("continue")); await waitFor(() => { @@ -153,6 +157,33 @@ describe("Test the add exercise step 4 component", () => { expect(setDescriptionI18n).toHaveBeenCalledWith(VALID_DESCRIPTION); }); + test("with translation enabled and a failing language check: shows the server message on the description and does not continue", async () => { + // The mutation resolves with the 400 payload instead of rejecting + mutateAsync = vi.fn().mockResolvedValue({ + check: { message: "this does not look like German" }, + }); + mockedUseLanguageCheckQuery.mockImplementation(() => ({ + isPending: false, + mutateAsync, + })); + + const user = userEvent.setup(); + renderStep(); + + await fillTranslation(user); + await user.click(screen.getByText("continue")); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalled(); + }); + // The server message replaces the yup helper text under the description + expect(await screen.findByText("this does not look like German")).toBeInTheDocument(); + expect(mockOnContinue).not.toHaveBeenCalled(); + // The input is still saved so it survives going back and forth + expect(setDescriptionI18n).toHaveBeenCalledWith(VALID_DESCRIPTION); + expect(setLanguageId).toHaveBeenCalledWith(testLanguages[0].id); + }); + test("the language dropdown excludes English (the primary language is captured in step 3)", async () => { const user = userEvent.setup(); renderStep(); diff --git a/src/components/Exercises/screens/Add/Step4Translations.tsx b/src/components/Exercises/screens/Add/Step4Translations.tsx index df831f29b..631918636 100644 --- a/src/components/Exercises/screens/Add/Step4Translations.tsx +++ b/src/components/Exercises/screens/Add/Step4Translations.tsx @@ -16,7 +16,7 @@ import { LoadingWidget } from "@/core/ui/LoadingWidget/LoadingWidget"; import { useLanguageCheckQuery } from "@/core/queries"; import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox"; -import { ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases"; +import { AliasItem, ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases"; import { ExerciseName } from "@/components/Exercises/forms/ExerciseName"; import { ExerciseNotes } from "@/components/Exercises/forms/ExerciseNotes"; import { @@ -26,7 +26,8 @@ import { noteValidator } from "@/components/Exercises/forms/yupValidators"; import { useLanguageQuery } from "@/components/Exercises/queries"; -import { Form, Formik } from "formik"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, fieldError, setServerError, submitHandler } from "@/core/forms/formUtils"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useExerciseSubmissionStateValue } from "@/components/Exercises/screens/Add/state"; @@ -40,6 +41,15 @@ import { import { ENGLISH_LANGUAGE_ID } from "@/core/lib/consts"; import * as yup from "yup"; +interface Step4Values { + name: string, + alternativeNames: AliasItem[], + description: string, + // the empty string stands in for "not picked yet", MUI selects don't accept null + language: number | '', + notes: string[], +} + export const Step4Translations = ({ onContinue, onBack }: StepProps) => { const [t] = useTranslation(); const languageQuery = useLanguageQuery(); @@ -61,80 +71,80 @@ export const Step4Translations = ({ onContinue, onBack }: StepProps) => { } : {} ); - return ( - ({ alias })), - description: state.descriptionI18n, - language: state.languageId === null ? '' : state.languageId, - notes: state.notesI18n - }} - validationSchema={validationSchema} - onSubmit={async (values, { setFieldError }) => { - let canContinue = true; - - if (values.description !== '') { - const validationResult = await languageCheckQuery.mutateAsync({ - input: values.description, - languageId: values.language! as unknown as number - }); + const defaultValues: Step4Values = { + name: state.nameI18n, + // The alias field and its validator work with objects, the state keeps plain strings + alternativeNames: state.alternativeNamesI18n.map(alias => ({ alias })), + description: state.descriptionI18n, + language: state.languageId === null ? '' : state.languageId, + notes: state.notesI18n + }; + + const form = useAppForm({ + defaultValues, + // The schema follows the switch, TanStack picks up the new one on every render + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + let canContinue = true; + + if (value.description !== '') { + const validationResult = await languageCheckQuery.mutateAsync({ + input: value.description, + languageId: value.language as number + }); + + // @ts-ignore - validationResult contains the message from the backend + if ("success" in validationResult) { + canContinue = true; + } else { + canContinue = false; // @ts-ignore - validationResult contains the message from the backend - if ("success" in validationResult) { - canContinue = true; - } else { - canContinue = false; - - // @ts-ignore - validationResult contains the message from the backend - setFieldError('description', validationResult.check.message); - } + setServerError(form, 'description', validationResult.check.message); } + } - dispatch(setNameI18n(values.name)); - dispatch(setDescriptionI18n(values.description)); - dispatch(setAlternativeNamesI18n(values.alternativeNames.map(item => item.alias))); - dispatch(setLanguageId(values.language === '' ? null : values.language as unknown as number)); - dispatch(setNotesI18n(values.notes)); + dispatch(setNameI18n(value.name)); + dispatch(setDescriptionI18n(value.description)); + dispatch(setAlternativeNamesI18n(value.alternativeNames.map(item => item.alias))); + dispatch(setLanguageId(value.language === '' ? null : value.language)); + dispatch(setNotesI18n(value.notes)); - if (canContinue) { - onContinue!(); - } - }} - >{formik => ( -
- - - setTranslateExercise(!translateExercise)} - control={} - label={t('exercises.translateExerciseNow')} /> - - {translateExercise && ( - <> - {languageQuery.isLoading ? ( - - - - ) : ( - + if (canContinue) { + onContinue!(); + } + }, + }); + + return ( + + + + setTranslateExercise(!translateExercise)} + control={} + label={t('exercises.translateExerciseNow')} /> + + {translateExercise && ( + <> + {languageQuery.isLoading ? ( + + + + ) : ( + + {field => {t('language')} - - )} - - - - - formik.setFieldValue('description', val)} - error={formik.touched.description && Boolean(formik.errors.description)} - helperText={formik.touched.description ? formik.errors.description : undefined} - /> - - - - - )} - - - - - -
- - -
-
-
+
} + + )} + {() => } + + {() => } + + + {field => { + const error = fieldError(field); + return { + // The server's verdict was about the old text + setServerError(form, 'description', undefined); + field.handleChange(val); + }} + error={error !== undefined} + helperText={error} + />; + }} + + + + {() => } + + )} +
+ + + + +
+ + +
+
- - )} -
+ + ); -}; \ No newline at end of file +}; diff --git a/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx b/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx index 879dcfd69..ed1b45dce 100644 --- a/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx +++ b/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx @@ -1,7 +1,7 @@ import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox"; import { EditExerciseCategory } from "@/components/Exercises/forms/Category"; import { EditExerciseEquipment } from "@/components/Exercises/forms/Equipment"; -import { ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases"; +import { AliasItem, ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases"; import { ExerciseName } from "@/components/Exercises/forms/ExerciseName"; import { AddImageCard, ImageEditCard } from "@/components/Exercises/forms/ImageCard"; import { EditExerciseMuscle } from "@/components/Exercises/forms/Muscle"; @@ -13,15 +13,12 @@ import { nameValidator } from "@/components/Exercises/forms/yupValidators"; import { Language } from "@/components/Exercises/models/language"; -import { Note } from "@/components/Exercises/models/note"; import { Translation } from "@/components/Exercises/models/translation"; +import { TranslationNotes } from "@/components/Exercises/screens/Detail/TranslationNotes"; import { - useAddNoteQuery, useAddTranslationQuery, useDeleteAliasQuery, - useDeleteNoteQuery, useEditExerciseImageQuery, - useEditNoteQuery, useEditTranslationQuery, useExerciseQuery, useMusclesQuery, @@ -32,13 +29,12 @@ import { usePermissionQuery, useProfileQuery, WgerPermissions } from "@/componen import { MarkdownEditor } from "@/core/forms/MarkdownEditor"; import { LoadingWidget } from "@/core/ui/LoadingWidget/LoadingWidget"; import { FormQueryErrorsSnackbar } from '@/core/ui/Widgets/FormError'; -import AddIcon from '@mui/icons-material/Add'; import CloseIcon from '@mui/icons-material/Close'; -import DeleteIcon from '@mui/icons-material/Delete'; -import SaveIcon from '@mui/icons-material/Save'; -import { Alert, Box, Button, IconButton, InputAdornment, TextField, Typography } from "@mui/material"; +import { useAppForm } from "@/core/forms/appForm"; +import { defaultsKey, fieldError, submitHandler, yupSchema } from "@/core/forms/formUtils"; +import { Exercise } from "@/components/Exercises/models/exercise"; +import { Alert, Box, Button, IconButton, Typography } from "@mui/material"; import Grid from '@mui/material/Grid'; -import { Form, Formik } from "formik"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import * as yup from "yup"; @@ -59,10 +55,6 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { const [secondaryMuscles, setSecondaryMuscles] = useState([]); const exerciseQuery = useExerciseQuery(exerciseId); - const addTranslationQuery = useAddTranslationQuery(exerciseId); - const editTranslationQuery = useEditTranslationQuery(exerciseId); - const postAliasQuery = usePostAliasQuery(exerciseId); - const deleteAliasQuery = useDeleteAliasQuery(exerciseId); const addImagePermissionQuery = usePermissionQuery(WgerPermissions.ADD_IMAGE); const deleteImagePermissionQuery = usePermissionQuery(WgerPermissions.DELETE_IMAGE); const addVideoPermissionQuery = usePermissionQuery(WgerPermissions.ADD_VIDEO); @@ -80,13 +72,6 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { const [editingImageId, setEditingImageId] = useState(null); const editImageMutation = useEditExerciseImageQuery(exerciseId); - const addNoteMutation = useAddNoteQuery(exerciseId); - const editNoteMutation = useEditNoteQuery(exerciseId); - const deleteNoteMutation = useDeleteNoteQuery(exerciseId); - - const [newNoteValue, setNewNoteValue] = useState(''); - const [editingNoteId, setEditingNoteId] = useState(null); - const [editingNoteValue, setEditingNoteValue] = useState(''); useEffect(() => { if (exerciseQuery.data !== undefined) { @@ -118,12 +103,6 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { : translationFromBase; const exerciseEnglish = exercise.getTranslation(); - const validationSchema = yup.object({ - name: nameValidator(), - alternativeNames: alternativeNameValidator(), - description: descriptionValidator() - }); - // Called when clicking "Edit" on an existing card // Inside handleEditClick in ExerciseDetailEdit.tsx const handleEditClick = (image: ExerciseImage) => { @@ -162,267 +141,46 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { }; return <> - ({ id: a.id, alias: a.alias })), - description: exerciseTranslation.descriptionSource, - }} - enableReinitialize - validationSchema={validationSchema} - onSubmit={async values => { - - // Exercise translation - const payload = { - exerciseId: exercise.id!, - languageId: language.id, - name: values.name, - descriptionSource: values.description, - author: '' - }; - - const translation = exerciseTranslation.id - ? await editTranslationQuery.mutateAsync({ ...payload, id: exerciseTranslation.id }) - : await addTranslationQuery.mutateAsync({ ...payload, author: profileQuery.data!.username }); - - // Alias handling - const aliasOrig = (exerciseTranslation.aliases).map(a => ({ id: a.id, alias: a.alias })); - const aliasNew = values.alternativeNames ?? []; - - const aliasToCreate = aliasNew.filter(n => !aliasOrig.some(o => o.alias === n.alias)); - const aliasToDelete = aliasOrig.filter(o => !aliasNew.some(n => n.alias === o.alias)); - - // Create new aliases - for (const a of aliasToCreate) { - await postAliasQuery.mutateAsync({ translationId: translation.id!, alias: a.alias }); - } - - // Delete removed aliases - for (const a of aliasToDelete) { - if (a.id) { - await deleteAliasQuery.mutateAsync(a.id); + {alertIsVisible && + + { + setAlertIsVisible(false); + }} + > + + } - } - - // Notify the user - setAlertIsVisible(true); - }} - > - {({ values, touched, errors, setFieldValue }) => ( -
- - {alertIsVisible && - - { - setAlertIsVisible(false); - }} - > - - - } - > - {t('exercises.successfullyUpdated')} - - - } - - - {t('translation')} - - - {t('English')} - - - - {language.nameLong} ({language.nameShort}) - - - - - {t('name')} - - - {exerciseEnglish.name} -
    - {exerciseEnglish.aliases.map((alias) => ( -
  • {alias.alias}
  • - ))} -
-
- - - - - - - - - - - - - {t('exercises.description')} - - - {/* English/Base Description (Read Only) */} - English Description (Reference) -
- - - {/* Markdown Editor */} - setFieldValue('description', val)} - error={touched.description && Boolean(errors.description)} - // FIXED: Use ternary to ensure we return undefined instead of false - helperText={touched.description ? errors.description : undefined} - /> - - - {editExercisePermissionQuery.data && <> - - - - - {t('nutrition.others')} - - - - - - e.id)} /> - - - - {t('exercises.variations')} - - - - - } - - - - - - - {t('exercises.notes')} - - -
    - {exerciseEnglish.notes.map((note: Note) => ( -
  • {note.note}
  • - ))} -
-
- - {exerciseTranslation.notes.map((note: Note) => ( - { - if (editingNoteId !== note.id) { - setEditingNoteId(note.id); - setEditingNoteValue(e.target.value); - } else { - setEditingNoteValue(e.target.value); - } - }} - onFocus={() => { - if (editingNoteId !== note.id) { - setEditingNoteId(note.id); - setEditingNoteValue(note.note); - } - }} - sx={{ mb: 1 }} - variant="standard" - slotProps={{ - input: { - endAdornment: ( - - {editingNoteId === note.id && editingNoteValue !== note.note && ( - { - await editNoteMutation.mutateAsync( - new Note(note.id, note.translation, editingNoteValue) - ); - setEditingNoteId(null); - setEditingNoteValue(''); - }} - disabled={editNoteMutation.isPending} - > - - - )} - deleteNoteMutation.mutate(note.id!)} - disabled={deleteNoteMutation.isPending} - > - - - - ), - } - }} - /> - ))} - {exerciseTranslation.id && ( - setNewNoteValue(e.target.value)} - helperText={t('exercises.notesHelpText')} - slotProps={{ - input: { - endAdornment: ( - - { - if (newNoteValue.trim()) { - await addNoteMutation.mutateAsync( - new Note(null, exerciseTranslation.id!, newNoteValue) - ); - setNewNoteValue(''); - } - }} - disabled={addNoteMutation.isPending || !newNoteValue.trim()} - > - - - - ), - } - }} - /> - )} - - - - - - - - + > + {t('exercises.successfullyUpdated')} + + + } + + {/* The form freezes its default values, so a saved or reloaded + * translation gets a fresh form via the key */} + [alias.id, alias.alias]), )} - + exercise={exercise} + language={language} + exerciseTranslation={exerciseTranslation} + exerciseEnglish={exerciseEnglish} + canEditExercise={editExercisePermissionQuery.data} + username={profileQuery.data!.username} + onSaved={() => setAlertIsVisible(true)} + /> {/* Images */} @@ -529,4 +287,194 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { /> )} ; -}; \ No newline at end of file +}; + +interface TranslationFormValues { + name: string, + alternativeNames: AliasItem[], + description: string, +} + +const TranslationEditForm = ({ + exercise, + language, + exerciseTranslation, + exerciseEnglish, + canEditExercise, + username, + onSaved, + }: { + exercise: Exercise, + language: Language, + exerciseTranslation: Translation, + exerciseEnglish: Translation, + canEditExercise: boolean | undefined, + username: string, + onSaved: () => void, +}) => { + const [t] = useTranslation(); + const exerciseId = exercise.id!; + + const addTranslationQuery = useAddTranslationQuery(exerciseId); + const editTranslationQuery = useEditTranslationQuery(exerciseId); + const postAliasQuery = usePostAliasQuery(exerciseId); + const deleteAliasQuery = useDeleteAliasQuery(exerciseId); + + const validationSchema = yup.object({ + name: nameValidator(), + alternativeNames: alternativeNameValidator(), + description: descriptionValidator() + }); + + const defaultValues: TranslationFormValues = { + name: exerciseTranslation.name, + alternativeNames: exerciseTranslation.aliases.map(a => ({ id: a.id, alias: a.alias })), + description: exerciseTranslation.descriptionSource, + }; + + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + + // Exercise translation + const payload = { + exerciseId: exerciseId, + languageId: language.id, + name: value.name, + descriptionSource: value.description, + author: '' + }; + + const translation = exerciseTranslation.id + ? await editTranslationQuery.mutateAsync({ ...payload, id: exerciseTranslation.id }) + : await addTranslationQuery.mutateAsync({ ...payload, author: username }); + + // Alias handling + const aliasOrig = (exerciseTranslation.aliases).map(a => ({ id: a.id, alias: a.alias })); + const aliasNew = value.alternativeNames ?? []; + + const aliasToCreate = aliasNew.filter(n => !aliasOrig.some(o => o.alias === n.alias)); + const aliasToDelete = aliasOrig.filter(o => !aliasNew.some(n => n.alias === o.alias)); + + // Create new aliases + for (const a of aliasToCreate) { + await postAliasQuery.mutateAsync({ translationId: translation.id!, alias: a.alias }); + } + + // Delete removed aliases + for (const a of aliasToDelete) { + if (a.id) { + await deleteAliasQuery.mutateAsync(a.id); + } + } + + // Notify the user + onSaved(); + }, + }); + + return ( +
+ + + {t('translation')} + + + {t('English')} + + + + {language.nameLong} ({language.nameShort}) + + + + + {t('name')} + + + {exerciseEnglish.name} +
    + {exerciseEnglish.aliases.map((alias) => ( +
  • {alias.alias}
  • + ))} +
+
+ + + {() => } + + {() => } + + + + + + + + {t('exercises.description')} + + + {/* English/Base Description (Read Only) */} + English Description (Reference) +
+ + + {/* Markdown Editor */} + + {field => { + const error = fieldError(field); + return field.handleChange(val)} + error={error !== undefined} + helperText={error} + />; + }} + + + + {canEditExercise && <> + + + + + {t('nutrition.others')} + + + + + + e.id)} /> + + + + {t('exercises.variations')} + + + + + } + + + + + + + + + + ); +}; diff --git a/src/components/Exercises/screens/Detail/TranslationNotes.tsx b/src/components/Exercises/screens/Detail/TranslationNotes.tsx new file mode 100644 index 000000000..e0d756559 --- /dev/null +++ b/src/components/Exercises/screens/Detail/TranslationNotes.tsx @@ -0,0 +1,132 @@ +import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox"; +import { Note } from "@/components/Exercises/models/note"; +import { Translation } from "@/components/Exercises/models/translation"; +import { useAddNoteQuery, useDeleteNoteQuery, useEditNoteQuery } from "@/components/Exercises/queries"; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import SaveIcon from '@mui/icons-material/Save'; +import { IconButton, InputAdornment, TextField, Typography } from "@mui/material"; +import Grid from '@mui/material/Grid'; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; + +/** + * The notes of a translation next to the English ones: each note saves on + * its own, so this is a list of small editors, not part of the form around it. + */ +export const TranslationNotes = ({ exerciseId, translation, englishNotes }: { + exerciseId: number, + translation: Translation, + englishNotes: Note[], +}) => { + const [t] = useTranslation(); + + const addNoteMutation = useAddNoteQuery(exerciseId); + const editNoteMutation = useEditNoteQuery(exerciseId); + const deleteNoteMutation = useDeleteNoteQuery(exerciseId); + + const [newNoteValue, setNewNoteValue] = useState(''); + const [editingNoteId, setEditingNoteId] = useState(null); + const [editingNoteValue, setEditingNoteValue] = useState(''); + + return <> + + + + + + {t('exercises.notes')} + + +
    + {englishNotes.map((note: Note) => ( +
  • {note.note}
  • + ))} +
+
+ + {translation.notes.map((note: Note) => ( + { + if (editingNoteId !== note.id) { + setEditingNoteId(note.id); + setEditingNoteValue(e.target.value); + } else { + setEditingNoteValue(e.target.value); + } + }} + onFocus={() => { + if (editingNoteId !== note.id) { + setEditingNoteId(note.id); + setEditingNoteValue(note.note); + } + }} + sx={{ mb: 1 }} + variant="standard" + slotProps={{ + input: { + endAdornment: ( + + {editingNoteId === note.id && editingNoteValue !== note.note && ( + { + await editNoteMutation.mutateAsync( + new Note(note.id, note.translation, editingNoteValue) + ); + setEditingNoteId(null); + setEditingNoteValue(''); + }} + disabled={editNoteMutation.isPending} + > + + + )} + deleteNoteMutation.mutate(note.id!)} + disabled={deleteNoteMutation.isPending} + > + + + + ), + } + }} + /> + ))} + {translation.id && ( + setNewNoteValue(e.target.value)} + helperText={t('exercises.notesHelpText')} + slotProps={{ + input: { + endAdornment: ( + + { + if (newNoteValue.trim()) { + await addNoteMutation.mutateAsync( + new Note(null, translation.id!, newNoteValue) + ); + setNewNoteValue(''); + } + }} + disabled={addNoteMutation.isPending || !newNoteValue.trim()} + > + + + + ), + } + }} + /> + )} + + ; +}; diff --git a/src/components/Measurements/widgets/CalculationSection.tsx b/src/components/Measurements/widgets/CalculationSection.tsx index 69a3401b2..3007d7d52 100644 --- a/src/components/Measurements/widgets/CalculationSection.tsx +++ b/src/components/Measurements/widgets/CalculationSection.tsx @@ -7,24 +7,26 @@ import { } from "@/components/Measurements/models/Calculation"; import { MeasurementCategory } from "@/components/Measurements/models/Category"; import { CalculationParams } from "@/components/Measurements/widgets/CalculationParams"; +import { CategoryFormValues } from "@/components/Measurements/widgets/categoryFormValues"; import { useProfileQuery } from "@/components/User"; import { Alert, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material"; -import { useFormikContext } from "formik"; import React from "react"; import { useTranslation } from "react-i18next"; -interface CalculationValues { - calculation: string; - params: Record; -} - interface CalculationSectionProps { /** The category being edited, absent while one is created */ category?: MeasurementCategory; /** Every category of the user, group children included */ categories: MeasurementCategory[]; + /** What the form holds for the calculation and its parameters */ + values: Pick; + /** The complaint about the parameters, given only once the form was sent */ + paramsError?: string; /** Switches to a calculation, which also prefills the name and the unit */ onPick: (type?: CalculationType) => void; + /** Back to filling the category in by hand */ + onManual: () => void; + onParamsChange: (params: Record) => void; } /** @@ -34,11 +36,14 @@ interface CalculationSectionProps { export const CalculationSection = ({ category, categories, + values, + paramsError, onPick, + onManual, + onParamsChange, }: CalculationSectionProps) => { const [t] = useTranslation(); - const { values, errors, submitCount, setFieldValue } = useFormikContext(); const profileQuery = useProfileQuery(); // What a category computes is what it is, like its metric type, so it is @@ -78,7 +83,7 @@ export const CalculationSection = ({ return; } if (mode === 'manual') { - setFieldValue('calculation', CALCULATION_NONE); + onManual(); return; } onPick(firstAvailable()); @@ -120,13 +125,12 @@ export const CalculationSection = ({ setFieldValue('params', params)} + onChange={onParamsChange} categories={categories} categoryId={category?.id} /> {/* Only once sent: incomplete is the normal state while typing */} - {submitCount > 0 && typeof errors.params === 'string' && - {errors.params}} + {paramsError !== undefined && {paramsError}} } ; }; diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx index a4a44b792..d4e8bf58f 100644 --- a/src/components/Measurements/widgets/CategoryForm.tsx +++ b/src/components/Measurements/widgets/CategoryForm.tsx @@ -19,7 +19,7 @@ import { useEditMeasurementCategoryQuery } from "@/components/Measurements/queries"; import { CalculationSection } from "@/components/Measurements/widgets/CalculationSection"; -import { CategoryFormValues } from "@/components/Measurements/widgets/categoryFormValues"; +import { CategoryFormValues, SetCategoryFormValues } from "@/components/Measurements/widgets/categoryFormValues"; import { ChartSettingsFields } from "@/components/Measurements/widgets/ChartSettingsFields"; import { useCalculationPrefill } from "@/components/Measurements/widgets/useCalculationPrefill"; import { @@ -28,8 +28,9 @@ import { Stack, TextField } from "@mui/material"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, fieldErrorMessage, submitHandler } from "@/core/forms/formUtils"; import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; -import { Form, Formik } from "formik"; import React from 'react'; import { useTranslation } from "react-i18next"; import * as yup from 'yup'; @@ -119,111 +120,116 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { category !== undefined, ); - return ( - , - } as CategoryFormValues} - validationSchema={validationSchema} - onSubmit={async (values) => { - const parentId = values.parentId === "" ? null : values.parentId; + const defaultValues: CategoryFormValues = { + name: category ? category.name : "", + unit: category ? category.unit : "", + metricType: category ? category.metricType : 'custom' as MetricType, + chartType: category ? category.chartType : 'auto' as ChartType, + trend: seededTrend, + averageWindow: seededWindow, + // the empty string stands in for "no group", MUI selects + // don't accept null values + parentId: category?.parentId ?? "", + calculation: storedCalculation, + params: (category?.dynamicParams ?? {}) as Record, + }; + + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value: values }) => { + const parentId = values.parentId === "" ? null : values.parentId; - /** - * Applies the chart settings the user actually changed. - * - * Only a changed one is written, so renaming a category leaves - * its configuration exactly as it was: a value another client - * wrote and this one does not know reads as the default here, - * and writing that default back would drop it. - */ - const withSettings = (target: MeasurementCategory): MeasurementCategory => { - let out = target; - if (values.trend !== seededTrend) { - out = out.withChartSetting('trend', values.trend); - } - if (values.averageWindow !== seededWindow) { - out = out.withChartSetting('average_window', values.averageWindow); - } + /** + * Applies the chart settings the user actually changed. + * + * Only a changed one is written, so renaming a category leaves + * its configuration exactly as it was: a value another client + * wrote and this one does not know reads as the default here, + * and writing that default back would drop it. + */ + const withSettings = (target: MeasurementCategory): MeasurementCategory => { + let out = target; + if (values.trend !== seededTrend) { + out = out.withChartSetting('trend', values.trend); + } + if (values.averageWindow !== seededWindow) { + out = out.withChartSetting('average_window', values.averageWindow); + } - return out; - }; + return out; + }; - // The form closes only once the server took the category, so a - // rejected write is shown instead of disappearing with it - const options = { onSuccess: () => closeFn?.() }; + // The form closes only once the server took the category, so a + // rejected write is shown instead of disappearing with it + const options = { onSuccess: () => closeFn?.() }; - // Edit existing category - if (category) { - const edited = MeasurementCategory.clone(category, { - name: values.name, - unit: values.unit, - metricType: values.metricType, - chartType: values.chartType, - parentId: parentId, - }); - edited.dynamicType = values.calculation; - edited.dynamicParams = values.calculation === CALCULATION_NONE - ? {} - : values.params; - useEditCategoryQuery.mutate(withSettings(edited), options); - } else { - useAddCategoryQuery.mutate(withSettings(new MeasurementCategory( - null, - values.name, - values.unit, - values.metricType, - false, - parentId, - 0, - values.chartType, - {}, - values.calculation, - values.calculation === CALCULATION_NONE ? {} : values.params, - )), options); - } - }} - > - {formik => ( -
+ // Edit existing category + if (category) { + const edited = MeasurementCategory.clone(category, { + name: values.name, + unit: values.unit, + metricType: values.metricType, + chartType: values.chartType, + parentId: parentId, + }); + edited.dynamicType = values.calculation; + edited.dynamicParams = values.calculation === CALCULATION_NONE + ? {} + : values.params; + useEditCategoryQuery.mutate(withSettings(edited), options); + } else { + useAddCategoryQuery.mutate(withSettings(new MeasurementCategory( + null, + values.name, + values.unit, + values.metricType, + false, + parentId, + 0, + values.chartType, + {}, + values.calculation, + values.calculation === CALCULATION_NONE ? {} : values.params, + )), options); + } + }, + }); + + // One validation for the whole patch: each field on its own would check + // the new parameters against the calculation before them + const setValues: SetCategoryFormValues = (patch) => { + for (const key of Object.keys(patch) as Array) { + const value = patch[key]; + if (value !== undefined) { + form.setFieldValue(key, value, { dontValidate: true }); + } + } + form.validate('change'); + }; + + return ( + + ({ + values: state.values, + submissionAttempts: state.submissionAttempts, + paramsErrors: state.fieldMeta.params?.errors, + })}> + {({ values, submissionAttempts, paramsErrors }) => ( - {isCustom && { - markNameEdited(); - formik.handleChange(event); - }} - />} - {isCustom && { - markUnitEdited(); - formik.handleChange(event); - }} - />} + {isCustom && + {field => } + } + {isCustom && + {field => } + } {/* What a category computes is set when it is created: * the server refuses a change afterwards, so an * existing one only shows what it already does */} @@ -232,30 +238,42 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { && pickCalculation(formik, type)} + values={values} + // Only once sent: incomplete is the normal state while typing + paramsError={submissionAttempts > 0 + ? fieldErrorMessage(paramsErrors ?? []) + : undefined} + onPick={(type?: CalculationType) => pickCalculation({ setValues }, type)} + onManual={() => setValues({ calculation: CALCULATION_NONE })} + onParamsChange={params => setValues({ params })} />} {/* The metric type is picked when the category is * created (see NewCategoryPicker) and fixed from then * on: the key of a typed category is derived from it, * and the server refuses a change */} - - {!hasChildren && formik.values.metricType === 'custom' + + {!hasChildren && values.metricType === 'custom' && parentCandidates.length > 0 && - - {t('measurements.noGroup')} - {parentCandidates.map(candidate => - - {candidate.name} - - )} - + + {field => field.handleChange(event.target.value)} + onBlur={field.handleBlur} + > + {t('measurements.noGroup')} + {parentCandidates.map(candidate => + + {candidate.name} + + )} + } + } @@ -265,8 +283,8 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { - - )} -
+ )} + + ); }; diff --git a/src/components/Measurements/widgets/ChartSettingsFields.tsx b/src/components/Measurements/widgets/ChartSettingsFields.tsx index 4b48196a8..349e534ac 100644 --- a/src/components/Measurements/widgets/ChartSettingsFields.tsx +++ b/src/components/Measurements/widgets/ChartSettingsFields.tsx @@ -8,9 +8,8 @@ import { TREND_CHARACTERS, TrendCharacter } from "@/components/Measurements/models/Category"; -import { CategoryFormValues } from "@/components/Measurements/widgets/categoryFormValues"; +import { CategoryFormValues, SetCategoryFormValues } from "@/components/Measurements/widgets/categoryFormValues"; import { MenuItem, Stack, TextField } from "@mui/material"; -import { useFormikContext } from "formik"; import React from 'react'; import { useTranslation } from "react-i18next"; @@ -29,14 +28,19 @@ const canDrawLine = (metricType: MetricType, hasChildren: boolean): boolean => const drawsLine = (values: { metricType: MetricType, chartType: ChartType }): boolean => resolveChartType(values.metricType, values.chartType) === 'line'; +interface ChartSettingsFieldsProps { + hasChildren: boolean; + values: CategoryFormValues; + onChange: SetCategoryFormValues; +} + /** * How a category is drawn: the chart it is shown as, and the two settings of * the line. Renders nothing where the metric type leaves no choice. */ -export const ChartSettingsFields = ({ hasChildren }: { hasChildren: boolean }) => { +export const ChartSettingsFields = ({ hasChildren, values, onChange }: ChartSettingsFieldsProps) => { const [t] = useTranslation(); - const formik = useFormikContext(); - const metricType = formik.values.metricType; + const metricType = values.metricType; return <> {/* @@ -52,7 +56,8 @@ export const ChartSettingsFields = ({ hasChildren }: { hasChildren: boolean }) = fullWidth id="chartType" label={t('measurements.chartType')} - {...formik.getFieldProps('chartType')} + value={values.chartType} + onChange={event => onChange({ chartType: event.target.value as ChartType })} > {chartTypeChoices(metricType).map(chartType => @@ -76,8 +81,9 @@ export const ChartSettingsFields = ({ hasChildren }: { hasChildren: boolean }) = fullWidth id="trend" label={t('measurements.chartTrend')} - disabled={!drawsLine(formik.values)} - {...formik.getFieldProps('trend')} + disabled={!drawsLine(values)} + value={values.trend} + onChange={event => onChange({ trend: event.target.value as TrendCharacter })} > {TREND_CHARACTERS.map((trend: TrendCharacter) => @@ -90,8 +96,12 @@ export const ChartSettingsFields = ({ hasChildren }: { hasChildren: boolean }) = fullWidth id="averageWindow" label={t('measurements.chartAverageWindow')} - disabled={!drawsLine(formik.values)} - {...formik.getFieldProps('averageWindow')} + disabled={!drawsLine(values)} + value={values.averageWindow} + // MUI hands the menu item's value back as it is, the string type is only nominal + onChange={event => onChange({ + averageWindow: event.target.value as unknown as CategoryFormValues['averageWindow'] + })} > {t('off')} {AVERAGE_WINDOWS.map(days => diff --git a/src/components/Measurements/widgets/EntryForm.test.tsx b/src/components/Measurements/widgets/EntryForm.test.tsx index 07e8bcdcc..7584e650f 100644 --- a/src/components/Measurements/widgets/EntryForm.test.tsx +++ b/src/components/Measurements/widgets/EntryForm.test.tsx @@ -3,7 +3,7 @@ // prevent the create-mode form submission. The rest of the suite runs on // happy-dom for speed.) import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from "@testing-library/user-event"; import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { @@ -105,13 +105,14 @@ describe("Test the EntryForm component", () => { // Assert expect(submitButton).toBeInTheDocument(); await user.click(submitButton); - expect(mutate).toHaveBeenCalledWith(new MeasurementEntry( + // The submit resolves asynchronously, so the fake clock has to advance first + await waitFor(() => expect(mutate).toHaveBeenCalledWith(new MeasurementEntry( null, TEST_MEASUREMENT_CATEGORY_1.id!, fakeNow, 42.42, 'The Shiba Inu is a breed of hunting dog from Japan.', - ), expect.anything()); + ), expect.anything())); vi.useRealTimers(); }); diff --git a/src/components/Measurements/widgets/EntryForm.tsx b/src/components/Measurements/widgets/EntryForm.tsx index 1aeaa5e9c..892cf97bc 100644 --- a/src/components/Measurements/widgets/EntryForm.tsx +++ b/src/components/Measurements/widgets/EntryForm.tsx @@ -1,4 +1,4 @@ -import { Button, Stack, TextField } from "@mui/material"; +import { Button, Stack } from "@mui/material"; import { categoryDisplayName, MeasurementCategory @@ -12,7 +12,8 @@ import { useEditMeasurementEntryQuery } from "@/components/Measurements/queries"; import { EntryDateTimeField } from "@/components/Measurements/widgets/EntryDateTimeField"; -import { Form, Formik } from "formik"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, submitHandler } from "@/core/forms/formUtils"; import { useTranslation } from "react-i18next"; import * as yup from 'yup'; @@ -29,6 +30,13 @@ interface EntryFormProps { category: MeasurementCategory, } +interface EntryFormValues { + // The text field hands over strings, the schema casts them to numbers + value: string, + date: Date | null, + notes: string, +} + export const EntryForm = ({ entry, closeFn, category }: EntryFormProps) => { const [t] = useTranslation(); @@ -52,67 +60,73 @@ export const EntryForm = ({ entry, closeFn, category }: EntryFormProps) => { .max(100, t('forms.maxLength', { value: '100' })), }); + const initialDate = entry ? entry.date : new Date(); + const defaultValues: EntryFormValues = { + value: String(entry ? entry.value : 0), + date: initialDate, + notes: entry ? entry.notes : "", + }; + + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value: values }) => { + // The schema already refused a null date, this only narrows the type + if (values.date === null) { + return; + } + const value = Number(values.value); + + // The form closes only once the server took the entry, so a + // rejected write is shown instead of disappearing with it + const options = { onSuccess: () => closeFn?.() }; + + // Edit existing entry + if (entry) { + useEditEntryQuery.mutate(MeasurementEntry.clone(entry, { + value: value, + date: values.date, + notes: values.notes, + }), options); + } else { + useAddEntryQuery.mutate( + new MeasurementEntry(null, category.id!, values.date, value, values.notes), + options + ); + } + }, + }); return ( - ( { - // The form closes only once the server took the entry, so a - // rejected write is shown instead of disappearing with it - const options = { onSuccess: () => closeFn?.() }; - - // Edit existing entry - if (entry) { - useEditEntryQuery.mutate(MeasurementEntry.clone(entry, values), options); - } else { - useAddEntryQuery.mutate( - new MeasurementEntry(null, category.id!, values.date, values.value, values.notes), - options - ); - } - }} - > - {formik => ( -
- - - formik.setFieldValue('date', date)} /> - - - - - - - -
- )} -
) +
+ + + {field => } + + form.setFieldValue('date', date)} /> + + + {field => } + + + + + + +
); }; @@ -121,6 +135,12 @@ interface GroupEntryFormProps { closeFn?: () => void, } +interface GroupEntryFormValues { + date: Date | null, + // One value per child category, keyed by its id + values: Record, +} + /** * Adds one reading for every component of a multi-value group (e.g. systolic * and diastolic blood pressure): date and time are shared, one value field @@ -143,60 +163,58 @@ export const GroupEntryForm = ({ group, closeFn }: GroupEntryFormProps) => { ))), }); + const initialDate = new Date(); + const defaultValues: GroupEntryFormValues = { + date: initialDate, + values: Object.fromEntries(group.children.map(child => [child.id!, ''])), + }; + + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value: values }) => { + if (values.date === null) { + return; + } + const date = values.date; + + addGroupEntriesQuery.mutate( + group.children.map(child => new MeasurementEntry( + null, + child.id!, + date, + Number(values.values[child.id!]), + '', + )), + { onSuccess: () => closeFn?.() } + ); + }, + }); + return ( - ( [child.id!, ''])), - }} - validationSchema={validationSchema} - onSubmit={async (values) => { - addGroupEntriesQuery.mutate( - group.children.map(child => new MeasurementEntry( - null, - child.id!, - values.date, - Number(values.values[child.id!]), - '', - )), - { onSuccess: () => closeFn?.() } - ); - }} - > - {formik => ( -
- - formik.setFieldValue('date', date)} /> - {group.children.map(child => - - )} - - - - - -
- )} -
) +
+ + form.setFieldValue('date', date)} /> + {group.children.map(child => + + {field => } + + )} + + + + + +
); -}; \ No newline at end of file +}; diff --git a/src/components/Measurements/widgets/WeightForm.tsx b/src/components/Measurements/widgets/WeightForm.tsx index e5e31a1e9..7e2f0cb86 100644 --- a/src/components/Measurements/widgets/WeightForm.tsx +++ b/src/components/Measurements/widgets/WeightForm.tsx @@ -1,5 +1,5 @@ -import { Button, Stack, TextField, ToggleButton, ToggleButtonGroup } from "@mui/material"; -import { METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category"; +import { Button, Stack, ToggleButton, ToggleButtonGroup } from "@mui/material"; +import { MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category"; import { limitsSchema } from "@/components/Measurements/widgets/limitsSchema"; import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; @@ -10,10 +10,11 @@ import { import { weightUnitOf } from "@/components/Measurements/models/bodyWeight"; import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Measurements/queries/bodyWeight"; import { useProfileQuery } from "@/components/User"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, submitHandler } from "@/core/forms/formUtils"; import { WeightUnit } from "@/core/lib/weightUnit"; import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import { EntryDateTimeField } from "@/components/Measurements/widgets/EntryDateTimeField"; -import { Form, Formik } from "formik"; import { useTranslation } from "react-i18next"; import * as yup from 'yup'; @@ -22,14 +23,38 @@ interface WeightFormProps { closeFn?: () => void, } -export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => { +interface WeightFormValues { + // The text field hands over strings, the schema casts them to numbers + weight: string, + unit: WeightUnit, + date: Date | null, +} +export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => { const categoryQuery = useBodyWeightCategoryQuery(); const profileQuery = useProfileQuery(); - const addWeightQuery = useAddMeasurementEntryQuery(); - const editWeightQuery = useEditMeasurementEntryQuery(); const displayUnit = useDisplayWeightUnit(); + // Also wait for the profile: the form freezes its default values, and the + // unit default falls back to kg while the profile has not loaded yet + if (categoryQuery.isLoading || profileQuery.isLoading) { + return ; + } + + return ; +}; + +const WeightFormFields = ({ category, displayUnit, weightEntry, closeFn }: WeightFormProps & { + category: MeasurementCategory, + displayUnit: WeightUnit, +}) => { + const addWeightQuery = useAddMeasurementEntryQuery(); + const editWeightQuery = useEditMeasurementEntryQuery(); const [t] = useTranslation(); const validationSchema = yup.object({ @@ -46,89 +71,89 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => { }), }); - // Also wait for the profile: Formik freezes the initial values, and the - // unit default falls back to kg while the profile has not loaded yet - if (categoryQuery.isLoading || profileQuery.isLoading) { - return ; - } + const initialDate = weightEntry ? weightEntry.date : new Date(); + const defaultValues: WeightFormValues = { + // when editing, show the value in the unit it was entered in + weight: String(weightEntry ? weightEntry.value : 0), + unit: weightEntry ? weightUnitOf(weightEntry, category.unit) : displayUnit, + date: initialDate, + }; - const category = categoryQuery.data!; + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + // The schema already refused a null date, this only narrows the type + if (value.date === null) { + return; + } + const weight = Number(value.weight); - return ( - ( { - // The form closes only once the server took the entry, so a - // rejected write is shown instead of disappearing with it - const options = { onSuccess: () => closeFn?.() }; + // The form closes only once the server took the entry, so a + // rejected write is shown instead of disappearing with it + const options = { onSuccess: () => closeFn?.() }; - // Edit existing weight entry - if (weightEntry) { - editWeightQuery.mutate(MeasurementEntry.clone(weightEntry, { - value: values.weight, - date: values.date, - extraData: weightEntry.extraDataInUnit(values.unit), - }), options); + // Edit existing weight entry + if (weightEntry) { + editWeightQuery.mutate(MeasurementEntry.clone(weightEntry, { + value: weight, + date: value.date, + extraData: weightEntry.extraDataInUnit(value.unit), + }), options); - // Create a new weight entry - } else { - addWeightQuery.mutate(new MeasurementEntry( - null, - category.id!, - values.date, - values.weight, - '', - 'user', - { unit: values.unit }, - ), options); - } - }} - > - {formik => ( -
- - - + // Create a new weight entry + } else { + addWeightQuery.mutate(new MeasurementEntry( + null, + category.id!, + value.date, + weight, + '', + 'user', + { unit: value.unit }, + ), options); + } + }, + }); + + return ( + + + + + {field => } + + + {field => ( { if (newUnit) { - formik.setFieldValue('unit', newUnit); + field.handleChange(newUnit); } }} > {t('server.kg')} {t('server.lb')} - + )} + + - formik.setFieldValue('date', date)} /> - - - - - - - )} -
) + form.setFieldValue('date', date)} /> + + + + + + ); }; diff --git a/src/components/Measurements/widgets/categoryFormValues.ts b/src/components/Measurements/widgets/categoryFormValues.ts index ba3d09523..e6ecb9e1f 100644 --- a/src/components/Measurements/widgets/categoryFormValues.ts +++ b/src/components/Measurements/widgets/categoryFormValues.ts @@ -12,3 +12,6 @@ export interface CategoryFormValues { calculation: string; params: Record; } + +/** Writes part of the form in one go, validated once as a whole */ +export type SetCategoryFormValues = (patch: Partial) => void; diff --git a/src/components/Measurements/widgets/useCalculationPrefill.ts b/src/components/Measurements/widgets/useCalculationPrefill.ts index ff919e1b8..5918224ef 100644 --- a/src/components/Measurements/widgets/useCalculationPrefill.ts +++ b/src/components/Measurements/widgets/useCalculationPrefill.ts @@ -5,15 +5,13 @@ import { CalculationType, defaultParams } from "@/components/Measurements/models/Calculation"; -import { CategoryFormValues } from "@/components/Measurements/widgets/categoryFormValues"; +import { SetCategoryFormValues } from "@/components/Measurements/widgets/categoryFormValues"; import React from 'react'; import { useTranslation } from "react-i18next"; -/** The part of formik the prefill writes to */ +/** The part of the form the prefill writes to */ interface PrefillTarget { - values: CategoryFormValues; - setValues: (values: CategoryFormValues) => unknown; - setFieldValue: (field: string, value: unknown) => unknown; + setValues: SetCategoryFormValues; } /** @@ -38,7 +36,7 @@ export const useCalculationPrefill = (isEdit: boolean) => { * most people, so that is what a fresh one starts with. The chips stay * removable, and an instance that never synced them prefills nothing. */ - const prefillBigThree = async (formik: PrefillTarget, type: CalculationType) => { + const prefillBigThree = async (form: PrefillTarget, type: CalculationType) => { const param = type.params.find(candidate => candidate.kind === 'exercises'); if (param === undefined) { return; @@ -52,14 +50,14 @@ export const useCalculationPrefill = (isEdit: boolean) => { // The user may have picked something else while this was loading if (ids.length === BIG_THREE_UUIDS.length && pickedRef.current === type.slug) { - formik.setFieldValue('params', { ...defaultParams(type), [param.key]: ids }); + form.setValues({ params: { ...defaultParams(type), [param.key]: ids } }); } } catch { // Nothing to prefill, the user picks the exercises themselves } }; - const pickCalculation = (formik: PrefillTarget, type?: CalculationType) => { + const pickCalculation = (form: PrefillTarget, type?: CalculationType) => { if (type === undefined) { return; } @@ -67,8 +65,7 @@ export const useCalculationPrefill = (isEdit: boolean) => { // One update, not one per field: each validates on its own and would // check the new parameters against the calculation before them - formik.setValues({ - ...formik.values, + form.setValues({ calculation: type.slug, params: defaultParams(type), ...(nameEdited @@ -76,7 +73,7 @@ export const useCalculationPrefill = (isEdit: boolean) => { : { name: t(`measurements.calculations.names.${type.slug as CalculationSlug}`) }), ...(unitEdited ? {} : { unit: type.unit }), }); - prefillBigThree(formik, type); + prefillBigThree(form, type); }; return { diff --git a/src/components/Nutrition/widgets/forms/MealForm.tsx b/src/components/Nutrition/widgets/forms/MealForm.tsx index 9ece9ed55..29f8b5960 100644 --- a/src/components/Nutrition/widgets/forms/MealForm.tsx +++ b/src/components/Nutrition/widgets/forms/MealForm.tsx @@ -1,10 +1,11 @@ -import { Button, Stack, TextField } from "@mui/material"; +import { Button, Stack } from "@mui/material"; import { LocalizationProvider, TimePicker } from "@mui/x-date-pickers"; import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon"; import { Meal } from "@/components/Nutrition/models/meal"; import { useAddMealQuery, useEditMealQuery } from "@/components/Nutrition/queries"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, submitHandler } from "@/core/forms/formUtils"; import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; -import { Form, Formik } from "formik"; import { DateTime } from "luxon"; import React from 'react'; import { useTranslation } from "react-i18next"; @@ -16,6 +17,11 @@ interface MealFormProps { closeFn?: () => void, } +interface MealFormValues { + name: string, + time: Date | null, +} + export const MealForm = ({ meal, planId, closeFn }: MealFormProps) => { const [t, i18n] = useTranslation(); @@ -32,76 +38,74 @@ export const MealForm = ({ meal, planId, closeFn }: MealFormProps) => { .required() }); + const defaultValues: MealFormValues = { + name: meal ? meal.name : "", + time: meal ? meal.time : new Date(), + }; - return ( - { - if (!(values.time instanceof Date)) { - // @ts-ignore - The result from the datepicker is a Luxon DateTime object, not a JS DateTime - values.time = values.time.toJSDate(); - } + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + // The schema already refused a missing time, this only narrows the type + if (value.time === null) { + return; + } - // The dialog closes only once the server took the meal, so a - // rejected write is shown instead of disappearing with it - const options = { onSuccess: () => closeFn?.() }; + // The dialog closes only once the server took the meal, so a + // rejected write is shown instead of disappearing with it + const options = { onSuccess: () => closeFn?.() }; - if (meal) { - // Edit - const newMeal = Meal.clone(meal, { name: values.name, time: values.time }); - editMealQuery.mutate(newMeal, options); + if (meal) { + // Edit + const newMeal = Meal.clone(meal, { name: value.name, time: value.time }); + editMealQuery.mutate(newMeal, options); - } else { - // Add - addMealQuery.mutate(new Meal({ - planId: planId, - name: values.name, - time: values.time, - }), options); - } - }} - > - {formik => ( -
- - + } else { + // Add + addMealQuery.mutate(new Meal({ + planId: planId, + name: value.name, + time: value.time, + }), options); + } + }, + }); + + return ( + + + + {field => } + - - formik.setFieldValue('time', newValue ? newValue.toJSDate() : null)} - /> - - - - {closeFn !== undefined - && } - - - - - )} -
+ + + {field => field.handleChange(newValue ? newValue.toJSDate() : null)} + />} + + + + + {closeFn !== undefined + && } + + + + ); }; diff --git a/src/components/Nutrition/widgets/forms/MealItemForm.tsx b/src/components/Nutrition/widgets/forms/MealItemForm.tsx index 7ff718aa0..2e0f84d31 100644 --- a/src/components/Nutrition/widgets/forms/MealItemForm.tsx +++ b/src/components/Nutrition/widgets/forms/MealItemForm.tsx @@ -1,4 +1,4 @@ -import { Button, InputAdornment, MenuItem, Select, Stack, TextField } from "@mui/material"; +import { Button, InputAdornment, MenuItem, Select, Stack } from "@mui/material"; import { Ingredient } from "@/components/Nutrition/models/Ingredient"; import { MealItem } from "@/components/Nutrition/models/mealItem"; import { NutritionWeightUnit } from "@/components/Nutrition/models/weightUnit"; @@ -8,8 +8,9 @@ import { useEditMealItemQuery, } from "@/components/Nutrition/queries"; import { IngredientAutocompleter } from "@/components/Nutrition/widgets/IngredientAutocompleter"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, submitHandler } from "@/core/forms/formUtils"; import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; -import { Form, Formik } from "formik"; import React, { useState } from 'react'; import { useTranslation } from "react-i18next"; import * as yup from "yup"; @@ -20,6 +21,12 @@ type MealItemFormProps = | { planId: string; item: MealItem; closeFn?: () => void; mealId?: string } | { planId: string; mealId: string; item?: undefined; closeFn?: () => void }; +interface MealItemFormValues { + // The text field hands over strings, the schema casts them to numbers + amount: string, + ingredient: number | null, +} + export const MealItemForm = ({ planId, item, mealId, closeFn }: MealItemFormProps) => { const [t] = useTranslation(); @@ -63,58 +70,64 @@ export const MealItemForm = ({ planId, item, mealId, closeFn }: MealItemFormProp } }; + const defaultValues: MealItemFormValues = { + amount: String(item ? item.amount : 0), + ingredient: item ? item.ingredientId : null, + }; + + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + // The schema already refused a missing ingredient, this only narrows the type + if (value.ingredient === null) { + return; + } + + // Just to make sure we get a number + const newAmount = Math.round(Number(value.amount)); + + if (item) { + // Edit + const newMealItem = MealItem.clone(item, { + amount: newAmount, + ingredientId: value.ingredient, + ingredient: selectedIngredient, + weightUnitId: selectedUnit?.id ?? null, + weightUnit: selectedUnit, + }); + editMealItemQuery.mutate(newMealItem, closeOnSuccess); + } else { + // Add + addMealItemQuery.mutate(new MealItem({ + mealId: mealId!, + amount: newAmount, + ingredientId: value.ingredient, + weightUnitId: selectedUnit?.id ?? null, + weightUnit: selectedUnit, + order: 1, + }), closeOnSuccess); + } + }, + }); + return ( - { - - // Just to make sure we get a number - const newAmount = Math.round(values.amount); - - if (item) { - // Edit - const newMealItem = MealItem.clone(item, { - amount: newAmount, - ingredientId: values.ingredient, - ingredient: selectedIngredient, - weightUnitId: selectedUnit?.id ?? null, - weightUnit: selectedUnit, - }); - editMealItemQuery.mutate(newMealItem, closeOnSuccess); - } else { - // Add - addMealItemQuery.mutate(new MealItem({ - mealId: mealId!, - amount: newAmount, - ingredientId: values.ingredient, - weightUnitId: selectedUnit?.id ?? null, - weightUnit: selectedUnit, - order: 1, - }), closeOnSuccess); - } - }} - > - {formik => ( -
- - { - formik.setFieldValue('ingredient', value ? value.id : null); - setSelectedIngredient(value); - setWeightUnits(value?.weightUnits ?? []); - setSelectedUnit(null); - }} - initialIngredient={item ? item.ingredient : null} - /> - + + { + form.setFieldValue('ingredient', value ? value.id : null); + setSelectedIngredient(value); + setWeightUnits(value?.weightUnits ?? []); + setSelectedUnit(null); + }} + initialIngredient={item ? item.ingredient : null} + /> + + {field => @@ -141,32 +154,29 @@ export const MealItemForm = ({ planId, item, mealId, closeFn }: MealItemFormProp ) }, htmlInput: { inputMode: 'decimal' } - }} - error={formik.touched.amount && Boolean(formik.errors.amount)} - helperText={formik.touched.amount && formik.errors.amount} - {...formik.getFieldProps('amount')} - /> - - - - - {(closeFn !== undefined && item !== undefined) - && } - - {closeFn !== undefined - && } - - - - - - )} -
+ }, + }} + />} + + + + + + {(closeFn !== undefined && item !== undefined) + && } + + {closeFn !== undefined + && } + + + + + ); }; diff --git a/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.tsx b/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.tsx index e50f63ca8..e5293dbbc 100644 --- a/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.tsx +++ b/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.tsx @@ -11,8 +11,9 @@ import { useEditDiaryEntryQuery } from "@/components/Nutrition/queries"; import { IngredientAutocompleter } from "@/components/Nutrition/widgets/IngredientAutocompleter"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, fieldError, submitHandler } from "@/core/forms/formUtils"; import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; -import { Form, Formik } from "formik"; import { DateTime } from "luxon"; import React, { useState } from 'react'; import { useTranslation } from "react-i18next"; @@ -29,6 +30,13 @@ type NutritionDiaryEntryFormProps = { closeFn?: () => void, } +interface DiaryEntryFormValues { + datetime: Date | null, + // The text field hands over strings, the schema casts them to numbers + amount: string, + ingredient: number | null, +} + export const NutritionDiaryEntryForm = ({ planId, entry, mealId, meals, closeFn }: NutritionDiaryEntryFormProps) => { const meal = mealId === undefined ? null : mealId; @@ -82,69 +90,81 @@ export const NutritionDiaryEntryForm = ({ planId, entry, mealId, meals, closeFn } }; + const defaultValues: DiaryEntryFormValues = { + datetime: entry ? entry.datetime : new Date(), + amount: String(entry ? entry.amount : 0), + ingredient: entry ? entry.ingredientId : null, + }; + + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + // The schema already refused these, this only narrows the types + if (value.datetime === null || value.ingredient === null) { + return; + } + + // Make sure "amount" is a number + const newAmount = Number(value.amount); + + if (entry) { + // Edit + const newDiaryEntry = DiaryEntry.clone(entry, { + mealId: selectedMeal, + planId: planId, + amount: newAmount, + datetime: value.datetime, + ingredientId: value.ingredient, + ingredient: selectedIngredient, + weightUnitId: selectedUnit?.id ?? null, + weightUnit: selectedUnit, + }); + editDiaryQuery.mutate(newDiaryEntry, closeOnSuccess); + } else { + // Add + addDiaryQuery.mutate(new DiaryEntry({ + planId: planId, + amount: newAmount, + datetime: value.datetime, + ingredientId: value.ingredient, + mealId: selectedMeal, + weightUnitId: selectedUnit?.id ?? null, + weightUnit: selectedUnit, + }), closeOnSuccess); + } + }, + }); + return ( - ( { - - // Make sure "amount" is a number - const newAmount = Number(values.amount); - - if (entry) { - // Edit - const newDiaryEntry = DiaryEntry.clone(entry, { - mealId: selectedMeal, - planId: planId, - amount: newAmount, - datetime: values.datetime, - ingredientId: values.ingredient!, - ingredient: selectedIngredient, - weightUnitId: selectedUnit?.id ?? null, - weightUnit: selectedUnit, - }); - editDiaryQuery.mutate(newDiaryEntry, closeOnSuccess); - } else { - // Add - addDiaryQuery.mutate(new DiaryEntry({ - planId: planId, - amount: newAmount, - datetime: values.datetime, - ingredientId: values.ingredient!, - mealId: selectedMeal, - weightUnitId: selectedUnit?.id ?? null, - weightUnit: selectedUnit, - }), closeOnSuccess); - } - }} - > - {formik => ( -
- - { - formik.setFieldTouched('ingredient', true); - formik.setFieldValue('ingredient', value?.id ?? null); - setSelectedIngredient(value); - setWeightUnits(value?.weightUnits ?? []); - setSelectedUnit(null); - }} - initialIngredient={entry ? entry.ingredient : null} + + + + {field => { + const error = fieldError(field); + return <> + { + field.handleChange(value?.id ?? null); + setSelectedIngredient(value); + setWeightUnits(value?.weightUnits ?? []); + setSelectedUnit(null); + }} + initialIngredient={entry ? entry.ingredient : null} /> - {formik.touched.ingredient && formik.errors.ingredient && ( -
- {formik.errors.ingredient} -
+ {error !== undefined && ( +
+ {error} +
)} - ; + }} +
+ + {field => @@ -171,68 +191,65 @@ export const NutritionDiaryEntryForm = ({ planId, entry, mealId, meals, closeFn ) }, htmlInput: { inputMode: 'decimal' } - }} - error={formik.touched.amount && Boolean(formik.errors.amount)} - helperText={formik.touched.amount && formik.errors.amount} - {...formik.getFieldProps('amount')} - /> - {mealObjs.length > 0 && } + + {mealObjs.length > 0 && e.id)} + getOptionLabel={option => mealObjs.find(e => e.id === option)!.displayName!} + onChange={(event, newValue) => setSelectedMeal(newValue)} + renderInput={params => ( + e.id)} - getOptionLabel={option => mealObjs.find(e => e.id === option)!.displayName!} - onChange={(event, newValue) => setSelectedMeal(newValue)} - renderInput={params => ( - - )} - />} - - - { - formik.setFieldValue('datetime', newValue?.toJSDate()); - - setDateValue(newValue); - }} - shouldDisableDate={(date) => { - - // Allow the date of the current weight entry, since we are editing it - // @ts-ignore - date is a Luxon DateTime! - if (entry && dateToYYYYMMDD(entry.datetime) === dateToYYYYMMDD(date.toJSDate())) { - return false; - } - - // all other dates are allowed - return false; - }} - /> - - - - - {(closeFn !== undefined && entry !== undefined) - && } - - {closeFn !== undefined - && } - - -
- - )} -
) + {...params} + /> + )} + />} + + + { + form.setFieldValue('datetime', newValue?.toJSDate() ?? null); + + setDateValue(newValue); + }} + shouldDisableDate={(date) => { + + // Allow the date of the current weight entry, since we are editing it + // @ts-ignore - date is a Luxon DateTime! + if (entry && dateToYYYYMMDD(entry.datetime) === dateToYYYYMMDD(date.toJSDate())) { + return false; + } + + // all other dates are allowed + return false; + }} + /> + + + + + {(closeFn !== undefined && entry !== undefined) + && } + + {closeFn !== undefined + && } + + + + ); }; diff --git a/src/components/Nutrition/widgets/forms/PlanForm.test.tsx b/src/components/Nutrition/widgets/forms/PlanForm.test.tsx index efb2adc86..e0a09767d 100644 --- a/src/components/Nutrition/widgets/forms/PlanForm.test.tsx +++ b/src/components/Nutrition/widgets/forms/PlanForm.test.tsx @@ -152,8 +152,8 @@ describe("Test the PlanForm component", () => { // Assert expect(addMutate).toHaveBeenCalledWith(expect.objectContaining({ - goalEnergy: '2500', - goalProtein: '150', + goalEnergy: 2500, + goalProtein: 150, goalCarbohydrates: null, goalFat: null, goalFiber: null, diff --git a/src/components/Nutrition/widgets/forms/PlanForm.tsx b/src/components/Nutrition/widgets/forms/PlanForm.tsx index 1b4507f76..06b102ec2 100644 --- a/src/components/Nutrition/widgets/forms/PlanForm.tsx +++ b/src/components/Nutrition/widgets/forms/PlanForm.tsx @@ -5,8 +5,7 @@ import { FormHelperText, InputAdornment, Stack, - Switch, - TextField + Switch } from "@mui/material"; import Grid from '@mui/material/Grid'; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; @@ -16,8 +15,10 @@ import { ENERGY_FACTOR } from "@/components/Nutrition/helpers/nutritionalValues" import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; import { NutritionalPlan } from "@/components/Nutrition/models/nutritionalPlan"; import { useAddNutritionalPlanQuery, useEditNutritionalPlanQuery } from "@/components/Nutrition/queries"; -import { Form, Formik } from "formik"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, fieldError, submitHandler } from "@/core/forms/formUtils"; import i18n from "@/i18n"; +import { TFunction } from "i18next"; import { DateTime } from "luxon"; import React, { useState } from 'react'; import { useTranslation } from "react-i18next"; @@ -29,12 +30,36 @@ interface PlanFormProps { closeFn?: () => void, } +interface PlanFormValues { + description: string, + // YYYY-MM-DD, what the date pickers hand over + start: string, + end: string | null, + onlyLogging: boolean, + // The text fields hand over strings, the schema casts them to numbers; + // the empty string is a goal that is not set + goalEnergy: string, + goalProtein: string, + goalCarbohydrates: string, + goalFiber: string, + goalFat: string, +} + +const goalString = (goal: number | null | undefined): string => + goal === null || goal === undefined ? '' : String(goal); + +type GoalName = 'goalEnergy' | 'goalProtein' | 'goalCarbohydrates' | 'goalFiber' | 'goalFat'; + +/** The energy a macro goal amounts to, shown in front of it; fibre has none, so its zero always shows */ +const energyOf = (goal: string, factor: number, t: TFunction) => + goal !== '' || factor === 0 ? t('nutrition.valueEnergyKcal', { value: Number(goal) * factor }) : ''; + export const PlanForm = ({ plan, closeFn }: PlanFormProps) => { const [t] = useTranslation(); const addPlanQuery = useAddNutritionalPlanQuery(); const editPlanQuery = useEditNutritionalPlanQuery(plan?.id ?? ''); - const [useGoals, setUseGoals] = useState(plan?.hasAnyGoals); + const [useGoals, setUseGoals] = useState(plan?.hasAnyGoals ?? false); const [startDateValue, setStartDateValue] = useState(plan ? DateTime.fromJSDate(plan.start) : DateTime.now); const [endDateValue, setEndDateValue] = useState(plan && plan?.end !== null ? DateTime.fromJSDate(plan!.end) : null); @@ -83,85 +108,98 @@ export const PlanForm = ({ plan, closeFn }: PlanFormProps) => { ) }); + const defaultValues: PlanFormValues = { + description: plan ? plan.description : t('nutrition.plan'), - return ( - ( { - values.goalEnergy = values.goalEnergy ? values.goalEnergy : null; - values.goalProtein = values.goalProtein ? values.goalProtein : null; - values.goalCarbohydrates = values.goalCarbohydrates ? values.goalCarbohydrates : null; - values.goalFiber = values.goalFiber ? values.goalFiber : null; - values.goalFat = values.goalFat ? values.goalFat : null; + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + // A goal counts only while the goals are switched on + const goal = (entered: string): number | null => + useGoals && entered !== '' ? Number(entered) : null; - if (!useGoals) { - values.goalEnergy = null; - values.goalProtein = null; - values.goalCarbohydrates = null; - values.goalFiber = null; - values.goalFat = null; - } + const newPlan = new NutritionalPlan({ + // the values are YYYY-MM-DD strings, parse them as local dates: + // new Date() would interpret them as UTC midnight and shift the + // day in timezones behind UTC + start: yyyymmddToDate(value.start), + end: value.end ? yyyymmddToDate(value.end) : null, - const newPlan = new NutritionalPlan({ + description: value.description, + onlyLogging: value.onlyLogging, + goalEnergy: goal(value.goalEnergy), + goalProtein: goal(value.goalProtein), + goalCarbohydrates: goal(value.goalCarbohydrates), + goalFiber: goal(value.goalFiber), + goalFat: goal(value.goalFat), + }); - // the values are YYYY-MM-DD strings, parse them as local dates: - // new Date() would interpret them as UTC midnight and shift the - // day in timezones behind UTC - start: yyyymmddToDate(values.start), - end: values.end ? yyyymmddToDate(values.end) : null, - description: values.description, - onlyLogging: values.onlyLogging, - goalEnergy: values.goalEnergy, - goalProtein: values.goalProtein, - goalCarbohydrates: values.goalCarbohydrates, - goalFiber: values.goalFiber, - goalFat: values.goalFat, - }); + // The dialog closes only once the server took the plan, so a + // rejected write is shown instead of disappearing with it + const options = { onSuccess: () => closeFn?.() }; + if (plan) { + newPlan.id = plan.id!; + editPlanQuery.mutate(newPlan, options); + } else { + addPlanQuery.mutate(newPlan, options); + } + }, + }); - // The dialog closes only once the server took the plan, so a - // rejected write is shown instead of disappearing with it - const options = { onSuccess: () => closeFn?.() }; + /** A goal in kcal or grams; with an energy factor the field shows what the grams amount to */ + const goalField = (name: GoalName, energyFactor?: number) => ( + + {field => + {energyOf(field.state.value, energyFactor, t)} + + : undefined, + endAdornment: + {t(name === 'goalEnergy' ? 'nutrition.kcal' : 'nutrition.gramShort')} + , + }, + htmlInput: { inputMode: 'decimal' }, + }, + }} + />} + + ); - if (plan) { - newPlan.id = plan.id!; - editPlanQuery.mutate(newPlan, options); - } else { - addPlanQuery.mutate(newPlan, options); - } - }} - > - {formik => ( -
- - - - - - + + + {field => } + + + + + + {field => { + const error = fieldError(field); + return { textField: { variant: "standard", fullWidth: true, - error: formik.touched.start && Boolean(formik.errors.start), - helperText: formik.touched.start && formik.errors.start ? String(formik.errors.start) : '' + error: error !== undefined, + helperText: error ?? '' } }} onChange={(newValue) => { if (newValue) { - formik.setFieldValue('start', dateToYYYYMMDD(newValue.toJSDate())); + field.handleChange(dateToYYYYMMDD(newValue.toJSDate())); } setStartDateValue(newValue); }} + />; + }} + + + - /> - - - - - - + + + {field => { + const error = fieldError(field); + return { textField: { variant: "standard", fullWidth: true, - error: formik.touched.end && Boolean(formik.errors.end), - helperText: formik.touched.end && formik.errors.end ? String(formik.errors.end) : '' + error: error !== undefined, + helperText: error ?? '' } }} onChange={(newValue) => { if (newValue) { - formik.setFieldValue('end', dateToYYYYMMDD(newValue.toJSDate())); + field.handleChange(dateToYYYYMMDD(newValue.toJSDate())); } setEndDateValue(newValue); }} + />; + }} + + + + - /> - - - - - - } - /> - - {/*TODO: implement the options like in the mobile app */} - {/**/} - {/* Goal Setting*/} - {/* {*/} - {/* }}*/} - {/* >*/} - {/* Based on my meals*/} - {/* Set basic macros*/} - {/* Set advanced macros*/} - {/* */} - {/**/} - - setUseGoals(!useGoals)} + + + {field => field.handleChange(event.target.checked)} + onBlur={field.handleBlur} + />} + />} + + + + setUseGoals(!useGoals)} - />} - /> - - {t('nutrition.useGoalsHelpTextLong')} + />} + /> + + {t('nutrition.useGoalsHelpTextLong')} - {useGoals && <> - {t('nutrition.kcal')} - }, - htmlInput: { inputMode: 'decimal' } - }} - /> - - - - {formik.values.goalProtein !== null && formik.values.goalProtein !== undefined - ? t('nutrition.valueEnergyKcal', { value: formik.values.goalProtein * ENERGY_FACTOR.protein }) - : ''} - , - endAdornment: - {t('nutrition.gramShort')} - - }, - htmlInput: { inputMode: 'decimal' } - }} - /> - - - - {formik.values.goalCarbohydrates !== null && formik.values.goalCarbohydrates !== undefined - ? t('nutrition.valueEnergyKcal', { value: formik.values.goalCarbohydrates * ENERGY_FACTOR.carbohydrates }) - : ''} - , - endAdornment: - {t('nutrition.gramShort')} - }, - htmlInput: { inputMode: 'decimal' } - }} - /> - - - - {formik.values.goalFat !== null && formik.values.goalFat !== undefined - ? t('nutrition.valueEnergyKcal', { value: formik.values.goalFat * ENERGY_FACTOR.fat }) - : ''} - , - endAdornment: - {t('nutrition.gramShort')} - }, - htmlInput: { inputMode: 'decimal' } - }} - /> - - - - - - {t('nutrition.valueEnergyKcal', { value: 0 })} - , - endAdornment: - {t('nutrition.gramShort')} - - }, - htmlInput: { inputMode: 'decimal' } - }} - /> - - - } + {useGoals && <> + {goalField('goalEnergy')} + + {goalField('goalProtein', ENERGY_FACTOR.protein)} + {goalField('goalCarbohydrates', ENERGY_FACTOR.carbohydrates)} + {goalField('goalFat', ENERGY_FACTOR.fat)} + + + {goalField('goalFiber', 0)} + + } - - - - - -
- )} -
) + + + + + + ); }; diff --git a/src/components/Routines/widgets/forms/BaseConfigForm.tsx b/src/components/Routines/widgets/forms/BaseConfigForm.tsx index be85cec98..08a879a35 100644 --- a/src/components/Routines/widgets/forms/BaseConfigForm.tsx +++ b/src/components/Routines/widgets/forms/BaseConfigForm.tsx @@ -47,7 +47,6 @@ import { useDeleteMaxNrOfSetsConfigQuery, useEditMaxNrOfSetsConfigQuery } from "@/components/Routines/queries/configs"; -import { useFormikContext } from "formik"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { DEBOUNCE_ROUTINE_FORMS } from "@/core/lib/consts"; @@ -236,12 +235,12 @@ export const SlotBaseConfigValueField = (props: { export const ConfigDetailsRequirementsField = (props: { - fieldName: string, values: RequirementsType[], + /** Called with the selection once the user saves the menu */ + onChange: (values: RequirementsType[]) => void, disabled?: boolean }) => { - const { setFieldValue } = useFormikContext(); const { t } = useTranslation(); const disable = props.disabled ?? false; @@ -257,8 +256,8 @@ export const ConfigDetailsRequirementsField = (props: { } }; - const handleSubmit = async () => { - await setFieldValue(props.fieldName, selectedElements); + const handleSubmit = () => { + props.onChange(selectedElements); setAnchorEl(null); }; diff --git a/src/components/Routines/widgets/forms/DayForm.test.tsx b/src/components/Routines/widgets/forms/DayForm.test.tsx index 3ddb87cb0..22858b514 100644 --- a/src/components/Routines/widgets/forms/DayForm.test.tsx +++ b/src/components/Routines/widgets/forms/DayForm.test.tsx @@ -1,5 +1,5 @@ import { QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from "@testing-library/user-event"; import { useProfileQuery } from "@/components/User"; import { DayForm } from "@/components/Routines/widgets/forms/DayForm"; @@ -84,4 +84,27 @@ describe('Tests for the DayForm', () => { }) ); }); -}); \ No newline at end of file + + test('the type and the logs switch are sent along', async () => { + + // Act: the test day's name is over the length limit, so it has to go first + renderWidget(); + const nameInput = screen.getByRole('textbox', { name: /name/i }); + await user.clear(nameInput); + await user.type(nameInput, 'Leg day'); + await user.click(screen.getByRole('combobox', { name: /routines\.set\.type/i })); + await user.click(screen.getByRole('option', { name: /amrap/i })); + await user.click(screen.getByRole('switch', { name: /routines\.needslogstoadvance/i })); + await user.click(screen.getByRole('button', { name: /save/i })); + + // Assert + await waitFor(() => expect(mockEditDay).toHaveBeenCalledTimes(1)); + expect(mockEditDay).toHaveBeenCalledWith( + expect.objectContaining({ + id: 5, + type: 'amrap', + needLogsToAdvance: true, + }) + ); + }); +}); diff --git a/src/components/Routines/widgets/forms/DayForm.tsx b/src/components/Routines/widgets/forms/DayForm.tsx index 55d1026d4..8f9fc4f88 100644 --- a/src/components/Routines/widgets/forms/DayForm.tsx +++ b/src/components/Routines/widgets/forms/DayForm.tsx @@ -1,3 +1,11 @@ +import { Day, DayType } from "@/components/Routines/models/Day"; +import { useDeleteDayQuery, useEditDayQuery } from "@/components/Routines/queries"; +import { DayTypeSelect } from "@/components/Routines/widgets/forms/DayTypeSelect"; +import { DefaultRoundingMenu } from "@/components/Routines/widgets/forms/RoutineForm"; +import { useAppForm } from "@/core/forms/appForm"; +import { submitHandler, yupSchema } from "@/core/forms/formUtils"; +import { DeleteConfirmationModal } from "@/core/ui/Modals/DeleteConfirmationModal"; +import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError"; import DeleteIcon from "@mui/icons-material/Delete"; import HelpOutlineIcon from "@mui/icons-material/HelpOutlined"; import { @@ -13,18 +21,18 @@ import { } from "@mui/material"; import LoadingButton from "@mui/material/Button"; import Grid from '@mui/material/Grid'; -import { WgerTextField } from "@/core/forms/WgerTextField"; -import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError"; -import { DeleteConfirmationModal } from "@/core/ui/Modals/DeleteConfirmationModal"; -import { Day, DayType } from "@/components/Routines/models/Day"; -import { useDeleteDayQuery, useEditDayQuery } from "@/components/Routines/queries"; -import { DayTypeSelect } from "@/components/Routines/widgets/forms/DayTypeSelect"; -import { DefaultRoundingMenu } from "@/components/Routines/widgets/forms/RoutineForm"; -import { Form, Formik } from "formik"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import * as Yup from 'yup'; +interface DayFormValues { + name: string, + description: string, + isRest: boolean, + needsLogsToAdvance: boolean, + type: string, +} + export const DayForm = (props: { day: Day, routineId: number, @@ -80,138 +88,132 @@ export const DayForm = (props: { type: Yup.string(), }); - const handleSubmit = (values: Partial<{ - name: string, - description: string, - isRest: boolean, - needsLogsToAdvance: boolean, - type: string - }>) => + const handleSubmit = (values: Partial) => editDayQuery.mutate(Day.clone( - props.day, - { - ...(values.name !== undefined && { name: values.name }), - ...(values.description !== undefined && { description: values.description }), - ...({ isRest: values.isRest }), - ...(values.needsLogsToAdvance !== undefined && { needLogsToAdvance: values.needsLogsToAdvance }), + props.day, + { + ...(values.name !== undefined && { name: values.name }), + ...(values.description !== undefined && { description: values.description }), + ...({ isRest: values.isRest }), + ...(values.needsLogsToAdvance !== undefined && { needLogsToAdvance: values.needsLogsToAdvance }), ...(values.type !== undefined && { type: values.type as DayType }), - }) - ); + }) + ); + + const defaultValues: DayFormValues = { + name: props.day.name, + description: props.day.description, + isRest: props.day.isRest, + needsLogsToAdvance: props.day.needLogsToAdvance, + type: props.day.type, + }; + + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => handleSubmit(value), + }); return <> - { - handleSubmit(values); - setSubmitting(false); - }} - initialTouched={{ name: true, description: true, isRest: true, needsLogsToAdvance: true }} - > - {(formik) => ( -
- - - - - - - - - - - } - label={t('routines.restDay')} /> - - - } - label={t('routines.needsLogsToAdvance')} /> - - { - }}> - - - - - - - - - - {editDayQuery.isPending - ? - {t('save')} - - : - } - -   - - - - - - - - - - {t('routines.confirmRestDay')} - - {t('routines.confirmRestDayHelpText')} - - - - - - - - - - )} -
+
+ + + + + + {field => } + + + + + {() => } + + + + } + label={t('routines.restDay')} /> + + + + {field => field.handleChange(event.target.checked)} + onBlur={field.handleBlur} + />} + label={t('routines.needsLogsToAdvance')} />} + + + { + }}> + + + + + + + {field => } + + + + + {editDayQuery.isPending + ? + {t('save')} + + : + } + +   + + + + + + + + + + {t('routines.confirmRestDay')} + + {t('routines.confirmRestDayHelpText')} + + + + + + + + + ; }; - diff --git a/src/components/Routines/widgets/forms/DayTypeSelect.tsx b/src/components/Routines/widgets/forms/DayTypeSelect.tsx index d6fad3c4a..6e8218bf0 100644 --- a/src/components/Routines/widgets/forms/DayTypeSelect.tsx +++ b/src/components/Routines/widgets/forms/DayTypeSelect.tsx @@ -1,16 +1,12 @@ import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; -import { useField } from "formik"; +import { useFieldContext } from "@/core/forms/formContexts"; import { useTranslation } from "react-i18next"; -interface DayTypeSelectProps { - fieldName: string, - title: string, -} - -export const DayTypeSelect = (props: DayTypeSelectProps) => { +/** The type of a day, bound to the form field it is rendered in via form.AppField */ +export const DayTypeSelect = () => { const { t } = useTranslation(); - const [field] = useField(props.fieldName); + const field = useFieldContext(); const options = [ { value: 'custom', @@ -53,7 +49,10 @@ export const DayTypeSelect = (props: DayTypeSelectProps) => { select label={t('routines.set.type')} variant="standard" - {...field} + name={field.name} + value={field.state.value} + onChange={event => field.handleChange(event.target.value)} + onBlur={field.handleBlur} > {options!.map((option) => ( diff --git a/src/components/Routines/widgets/forms/ProgressionForm.test.tsx b/src/components/Routines/widgets/forms/ProgressionForm.test.tsx index 8149e3b63..22635d934 100644 --- a/src/components/Routines/widgets/forms/ProgressionForm.test.tsx +++ b/src/components/Routines/widgets/forms/ProgressionForm.test.tsx @@ -261,4 +261,21 @@ describe('Tests for the ProgressionForm', () => { expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); expect(mockProcessBaseConfigs).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + test('switching an operation to replace clears repeat and requirements', async () => { + // Act: the second row adds and repeats, see testConfigs + renderWidget(); + await user.click(screen.getAllByRole('combobox', { name: /routines\.operation/i })[1]); + await user.click(screen.getByRole('option', { name: 'Replace' })); + + // Assert: the repeat switch of that row is off and locked + const repeatSwitches = screen.getAllByRole('switch'); + expect(repeatSwitches[1]).not.toBeChecked(); + expect(repeatSwitches[1]).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: /save/i })); + expect(mockProcessBaseConfigs).toHaveBeenCalledTimes(1); + const payload = mockProcessBaseConfigs.mock.calls[0][0]; + expect(payload.values.toEdit[1]).toMatchObject({ id: 456, operation: 'r', repeat: false }); + }); +}); diff --git a/src/components/Routines/widgets/forms/ProgressionForm.tsx b/src/components/Routines/widgets/forms/ProgressionForm.tsx index 1c76d34b4..0db92961b 100644 --- a/src/components/Routines/widgets/forms/ProgressionForm.tsx +++ b/src/components/Routines/widgets/forms/ProgressionForm.tsx @@ -4,26 +4,32 @@ import HelpOutlineIcon from "@mui/icons-material/HelpOutlined"; import { Box, Button, Divider, IconButton, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material"; import Grid from "@mui/material/Grid"; import Tooltip from "@mui/material/Tooltip"; -import { WgerTextField } from "@/core/forms/WgerTextField"; +import { useAppForm } from "@/core/forms/appForm"; +import { defaultsKey, submitHandler, yupSchema } from "@/core/forms/formUtils"; import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; import { BaseConfig, BaseConfigEntryForm, OPERATION_REPLACE, OPERATION_VALUES_SELECT, - REQUIREMENTS_VALUES, - STEP_VALUES_SELECT + OperationType, + STEP_VALUES_SELECT, + StepType } from "@/components/Routines/models/BaseConfig"; import { useProcessConfigsQuery } from "@/components/Routines/queries/configs"; import { ConfigDetailsRequirementsField, ConfigType } from "@/components/Routines/widgets/forms/BaseConfigForm"; -import { FieldArray, Form, Formik } from "formik"; +import { + emptyEntry, + progressionEntries, + progressionPayload, + progressionSchema, + ProgressionFormValues +} from "@/components/Routines/widgets/forms/progressionFormData"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { AddBaseConfigParams, EditBaseConfigParams } from "@/components/Routines/api/baseConfig"; import { ApiPath } from "@/core/lib/consts"; -import * as yup from "yup"; -export const ProgressionForm = (props: { +interface ProgressionFormProps { configs: BaseConfig[], configsMax: BaseConfig[], type: ConfigType, @@ -32,12 +38,30 @@ export const ProgressionForm = (props: { iterations: number[], forceInteger?: boolean; isWeeklyCycle: boolean; +} + +export const ProgressionForm = (props: ProgressionFormProps) => { + const forceInteger = props.forceInteger ?? false; + const defaultEntries = progressionEntries(props.iterations, props.configs, props.configsMax, forceInteger); + + // The form freezes its default values, so saved or reloaded configs get a + // fresh form via the key + return ; +}; + +const ProgressionFields = (props: ProgressionFormProps & { + forceInteger: boolean, + defaultEntries: BaseConfigEntryForm[], }) => { const { t } = useTranslation(); const [iterationsToDelete, setIterationsToDelete] = useState([]); const processEntriesQuery = useProcessConfigsQuery(props.routineId); - - const forceInteger = props.forceInteger ?? false; + const forceInteger = props.forceInteger; let apiPath: ApiPath; let apiPathMax: ApiPath; @@ -70,430 +94,229 @@ export const ProgressionForm = (props: { break; } - const validationSchema = yup.object({ - entries: yup.array().of( - yup.object().shape({ - edited: yup.boolean(), - iteration: yup.number().required(), - - // Conditionally apply integer validation e.g. for sets - value: yup.number() - .when('forceInteger', { - is: true, - then: schema => schema.integer(t('forms.enterInteger')).typeError(t('forms.enterNumber')), - otherwise: schema => schema.typeError(t('forms.enterNumber')).nullable().notRequired(), - }), - - // only check that the max number is higher when replacing. In other cases allow the max - // weight to e.g. increase less than the min weight - valueMax: yup.number().typeError(t('forms.enterNumber')).nullable() - .when('forceInteger', { - is: true, - then: schema => schema.integer(t('forms.enterInteger')).typeError(t('forms.enterNumber')), - otherwise: schema => schema.typeError(t('forms.enterNumber')).nullable().notRequired(), - }) - // Conditionally apply integer validation e.g. for sets - .when('operation', { - is: OPERATION_REPLACE, - then: schema => schema.min(yup.ref('value'), t('forms.maxLessThanMin')), - otherwise: schema => schema, - }), - operation: yup.string().required(), - operationMax: yup.string().required(), - requirements: yup.array().of(yup.string().oneOf(REQUIREMENTS_VALUES)), - requirementsMax: yup.array().of(yup.string().oneOf(REQUIREMENTS_VALUES)), - repeat: yup.boolean(), - repeatMax: yup.boolean() - }) - ) - .test( - 'inter-entry-validation', - 'Your error message here', - function (entries) { // Use 'function' to access 'this' - const { createError } = this; - - const data = entries as unknown as BaseConfigEntryForm[]; - - for (let i = 0; i < data.length; i++) { - const entry = data[i]; - - // If there is an entry down the line - // if (entry.iteration === 1 && data.length > 1 && !entry.value && entry.edited) { - // return createError({ - // path: `entries[${i}].value`, - // message: 'Value is required at workout nr 1 when other entries exist' - // }); - // } - - if (entry.iteration > 1 && entry.operation !== OPERATION_REPLACE) { - let hasValuePreviousReplace = false; - let hasMaxValuePreviousReplace = false; - - for (let j = 0; j < i; j++) { - if (data[j].operation === OPERATION_REPLACE && data[j].value !== '' && data[j].edited) { - hasValuePreviousReplace = true; - } - - if (data[j].operation === OPERATION_REPLACE && data[j].valueMax !== '' && data[j].edited) { - hasMaxValuePreviousReplace = true; - } - } - - if (!hasValuePreviousReplace) { - return createError({ - path: `entries[${i}].value`, - message: t('routines.progressionNeedsReplace') - }); - } - if (!hasMaxValuePreviousReplace) { - return createError({ - path: `entries[${i}].valueMax`, - message: t('routines.progressionNeedsReplace') - }); - } - } - } - - // All entries valid - return true; - } - ) - , - }); - - const getEmptyConfig = (iter: number, edited: boolean): BaseConfigEntryForm => ({ - forceInteger: forceInteger, - - edited: edited, - iteration: iter, - - id: null, - idMax: null, - value: '', - valueMax: '', - operation: OPERATION_REPLACE, - operationMax: OPERATION_REPLACE, - step: "abs", - stepMax: "abs", - requirements: [], - requirementsMax: [], - repeat: false, - repeatMax: false, + const form = useAppForm({ + defaultValues: { entries: props.defaultEntries } as ProgressionFormValues, + validators: { onChange: yupSchema(progressionSchema(t)) }, + onSubmit: async ({ value }) => processEntriesQuery.mutate(progressionPayload(value.entries, { + slotEntryId: props.slotEntryId, + configs: props.configs, + configsMax: props.configsMax, + iterationsToDelete, + apiPath, + apiPathMax, + })), }); - const initialValues = { entries: [] as BaseConfigEntryForm[] }; - for (const iteration of props.iterations) { - const config: BaseConfig | undefined = props.configs.find((c) => c.iteration === iteration); - const configMax: BaseConfig | undefined = props.configsMax.find((c) => c.iteration === iteration); - - if (config === undefined) { - initialValues.entries.push(getEmptyConfig(iteration, false)); - } else { - initialValues.entries.push({ - forceInteger: forceInteger, - - edited: true, - id: config.id, - idMax: configMax === undefined ? null : configMax.id, - iteration: iteration, - value: String(config.value), - valueMax: configMax === undefined ? '' : String(configMax.value), - operation: config.operation, - operationMax: configMax === undefined ? OPERATION_REPLACE : config.operation, - step: config.step, - stepMax: configMax === undefined ? "abs" : configMax.step, - requirements: config.requirements?.rules ?? [], - requirementsMax: configMax === undefined ? [] : configMax.requirements?.rules ?? [], - repeat: config.repeat, - repeatMax: configMax === undefined ? false : config.repeat, - }); - } - } - - const handleSubmit = (values: { entries: BaseConfigEntryForm[] }) => { - // Remove empty entries - const data = values.entries.filter(e => e.edited); - - // Split between min and max values - const editList: EditBaseConfigParams[] = data.filter(data => data.id !== null).map(data => ({ - id: data.id!, - // eslint-disable-next-line camelcase - slot_entry: props.slotEntryId, - value: data.value as number, - iteration: data.iteration, - operation: data.operation, - step: data.step, - repeat: data.repeat, - requirements: { rules: data.requirements ?? [] } - })); - const addList: AddBaseConfigParams[] = data.filter(data => data.id === null && data.value !== '').map(data => ({ - // eslint-disable-next-line camelcase - slot_entry: props.slotEntryId, - value: data.value as number, - iteration: data.iteration, - operation: data.operation, - step: data.step, - repeat: data.repeat, - requirements: { rules: data.requirements ?? [] } - })); - // Items to delete, also includes all where the value is empty - const deleteList = props.configs.filter(c => iterationsToDelete.includes(c.iteration)).map(c => c.id); - data.forEach(entry => { - if (entry.value === "" && entry.id !== null && !deleteList.includes(entry.id)) { - deleteList.push(entry.id); - } - }); - - // Max values - const editListMax: EditBaseConfigParams[] = data.filter(data => data.idMax !== null && data.valueMax !== '').map(data => ({ - id: data.idMax!, - // eslint-disable-next-line camelcase - slot_entry: props.slotEntryId, - value: data.valueMax as number, - iteration: data.iteration, - operation: data.operation, - step: data.step, - repeat: data.repeat, - requirements: { rules: data.requirements ?? [] } - })); - const addListMax: AddBaseConfigParams[] = data.filter(data => data.idMax === null && data.valueMax !== '').map(data => ({ - iteration: data.iteration, - // eslint-disable-next-line camelcase - slot_entry: props.slotEntryId, - value: data.valueMax as number, - operation: data.operation, - step: data.stepMax, - repeat: data.repeat, - requirements: { rules: data.requirements ?? [] } - })); - // Items to delete, also includes all where the value is empty - const deleteListMax = props.configsMax.filter(c => iterationsToDelete.includes(c.iteration)).map(c => c.id); - data.forEach(entry => { - if (entry.valueMax === "" && entry.idMax !== null && !deleteList.includes(entry.idMax)) { - deleteListMax.push(entry.idMax); - } - }); - - // Save to server - processEntriesQuery.mutate({ - values: { - toAdd: addList, - toDelete: deleteList, - toEdit: editList, - apiPath: apiPath - }, - maxValues: { - toAdd: addListMax, - toDelete: deleteListMax, - toEdit: editListMax, - apiPath: apiPathMax - } - }); - }; - - return <> {title} - { - handleSubmit(values); - setSubmitting(false); - }} - > - {formik => ( -
+ + + + {t('value')} + + - - {t('value')} + + {t('routines.operation')} - - - - {t('routines.operation')} - - - {t('routines.step')} - - - {t('routines.requirements')} -
- - { - }}> - - - -
- - {t('routines.repeat')} -
- - { - }}> - - - -
-
+ + {t('routines.step')} - - + + {t('routines.requirements')} +
+ + { + }}> + + +
+ + {t('routines.repeat')} +
+ + { + }}> + + + +
+
+
+ + + + + + {/* The rows read every value of every entry, which an array field + * does not re-render for: it only follows the array's length */} + state.values.entries}> + {entries => entries.map((log, index) => ( + + + {props.isWeeklyCycle ? t('routines.weekNr', { number: log.iteration }) : t('routines.workoutNr', { number: log.iteration })} + {log.edited + ? e.edited && e.iteration !== 1).length > 0} + size="small" + onClick={() => { + if (log.id !== null) { + setIterationsToDelete([...iterationsToDelete, log.iteration]); + } + form.replaceFieldValue('entries', index, emptyEntry(log.iteration, false, forceInteger)); + }}> + + + : { + form.replaceFieldValue('entries', index, emptyEntry(log.iteration, true, forceInteger)); + }}> + + + } + - - {({ insert, remove }) => (<> - - {formik.values.entries.map((log, index) => ( - - - {props.isWeeklyCycle ? t('routines.weekNr', { number: log.iteration }) : t('routines.workoutNr', { number: log.iteration })} - {log.edited - ? e.edited && e.iteration !== 1).length > 0} - size="small" - onClick={() => { - if (log.id !== null) { - setIterationsToDelete([...iterationsToDelete, log.iteration]); - } - remove(index); - insert(index, getEmptyConfig(log.iteration, false)); - }}> - - - : { - remove(index); - insert(index, getEmptyConfig(log.iteration, true)); - }}> - - - } - - - - - {log.edited && - } - - - {log.edited && - } - - - - - - {log.edited && { - formik.handleChange(e); - if (e.target.value === OPERATION_REPLACE) { - await formik.setFieldValue(`entries.${index}.requirements`, []); - await formik.setFieldValue(`entries.${index}.repeat`, false); - } - }} - > - {OPERATION_VALUES_SELECT.map((option) => ( - - {option.label} - - ))} - } - - - - {log.edited && - {STEP_VALUES_SELECT.map((option) => ( - - {option.label} - - ))} - {/* "not applicable" is set automatically by the server */} - {(log.iteration === 1 || log.operation === OPERATION_REPLACE) && - - n/a - } - } - - - {log.edited && - } - {log.requirements.length >= 0 &&
} - {log.requirements.length >= 0 && log.requirements.map((requirement) => ( - - {requirement}   - - ))} -
- - {log.edited && } - -
-
-
- ))} - - )} -
- {processEntriesQuery.isError && - - } + + {log.edited && + + {field => } + } + + + {log.edited && + + {field => } + } - - - - - - - - - )} -
+ + + + + {log.edited && + {field => { + field.handleChange(e.target.value as OperationType); + if (e.target.value === OPERATION_REPLACE) { + form.setFieldValue(`entries[${index}].requirements`, []); + form.setFieldValue(`entries[${index}].repeat`, false); + } + }} + > + {OPERATION_VALUES_SELECT.map((option) => ( + + {option.label} + + ))} + } + } + + + + {log.edited && + {field => field.handleChange(e.target.value as StepType)} + onBlur={field.handleBlur} + > + {STEP_VALUES_SELECT.map((option) => ( + + {option.label} + + ))} + {/* "not applicable" is set automatically by the server */} + {(log.iteration === 1 || log.operation === OPERATION_REPLACE) && + + n/a + } + } + } + + + {log.edited && + form.setFieldValue(`entries[${index}].requirements`, values)} + />} + {log.requirements.length >= 0 &&
} + {log.requirements.length >= 0 && log.requirements.map((requirement) => ( + + {requirement}   + + ))} +
+ + {log.edited && + {field => field.handleChange(event.target.checked)} + onBlur={field.handleBlur} + disabled={log.iteration === 1 || log.operation === OPERATION_REPLACE} + />} + } + +
+
+ + ))} + + {processEntriesQuery.isError && + + } + + + ({ + isValid: state.isValid, + isSubmitting: state.isSubmitting, + isDirty: state.isDirty, + })}> + {({ isValid, isSubmitting, isDirty }) => } + + + + + + +
; }; diff --git a/src/components/Routines/widgets/forms/RoutineForm.test.tsx b/src/components/Routines/widgets/forms/RoutineForm.test.tsx index aa735c969..b26a0c2ca 100644 --- a/src/components/Routines/widgets/forms/RoutineForm.test.tsx +++ b/src/components/Routines/widgets/forms/RoutineForm.test.tsx @@ -1,5 +1,5 @@ import { QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { RoutineForm } from "@/components/Routines/widgets/forms/RoutineForm"; import { BrowserRouter } from "react-router-dom"; @@ -92,4 +92,25 @@ describe('RoutineForm', () => { description: "The description goes here", })); }); -}); \ No newline at end of file + + test('a too short name shows the error and blocks the save', async () => { + + // Act + render( + + + + + + ); + const nameInput = screen.getByRole('textbox', { name: /name/i }); + await user.clear(nameInput); + await user.type(nameInput, 'ab'); + await user.click(screen.getByRole('button', { name: /save/i })); + + // Assert + await waitFor(() => expect(nameInput).toHaveAttribute('aria-invalid', 'true')); + expect(screen.getByText('forms.minLength')).toBeInTheDocument(); + expect(mockEditRoutine).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Routines/widgets/forms/RoutineForm.tsx b/src/components/Routines/widgets/forms/RoutineForm.tsx index 0c1150eaf..c919846ce 100644 --- a/src/components/Routines/widgets/forms/RoutineForm.tsx +++ b/src/components/Routines/widgets/forms/RoutineForm.tsx @@ -4,7 +4,8 @@ import Grid from '@mui/material/Grid'; import Tooltip from "@mui/material/Tooltip"; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon"; -import { WgerTextField } from "@/core/forms/WgerTextField"; +import { useAppForm } from "@/core/forms/appForm"; +import { yupSchema, fieldError, submitHandler } from "@/core/forms/formUtils"; import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; import { useProfileQuery } from "@/components/User"; import { @@ -17,7 +18,6 @@ import { } from "@/components/Routines/models/Routine"; import { useAddRoutineQuery, useEditRoutineQuery } from "@/components/Routines/queries/routines"; import { SlotEntryRoundingField } from "@/components/Routines/widgets/forms/SlotEntryForm"; -import { Form, Formik } from "formik"; import { DateTime } from "luxon"; import React, { useState } from 'react'; import { useTranslation } from "react-i18next"; @@ -32,6 +32,15 @@ interface RoutineFormProps { closeFn?: () => void, } +interface RoutineFormValues { + name: string, + description: string, + // What the pickers hand over; yup casts the ISO string a DateTime prints as + start: DateTime, + end: DateTime, + fitInWeek: boolean, +} + export const RoutineForm = ({ existingRoutine, isTemplate = false, @@ -51,10 +60,7 @@ export const RoutineForm = ({ isPublic: isPublicTemplate }); - /* - * Note: Controlling the state of the dates manually, otherwise some undebuggable errors - * about missing properties occur deep within formik. - */ + // The pickers keep their own copy of the dates, the duration below reads it const [startDate, setStartDate] = useState(DateTime.fromJSDate(routine.start)); const [endDate, setEndDate] = useState(DateTime.fromJSDate(routine.end)); @@ -112,64 +118,70 @@ export const RoutineForm = ({ fitInWeek: yup.boolean() }); + const defaultValues: RoutineFormValues = { + name: routine.name, + description: routine.description, + start: startDate, + end: endDate, + fitInWeek: routine.fitInWeek, + }; - return ( - ( { - routine.name = values.name; - routine.description = values.description; - routine.fitInWeek = values.fitInWeek; - routine.start = values.start!.toJSDate(); - routine.end = values.end!.toJSDate(); + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + routine.name = value.name; + routine.description = value.description; + routine.fitInWeek = value.fitInWeek; + routine.start = value.start.toJSDate(); + routine.end = value.end.toJSDate(); - if (routine.id !== null) { - editRoutineQuery.mutate(routine); - } else { - const result = await addRoutineQuery.mutateAsync(routine); - navigate(makeLink(WgerLink.ROUTINE_EDIT, i18n.language, { id: result.id! })); + if (routine.id !== null) { + editRoutineQuery.mutate(routine); + } else { + const result = await addRoutineQuery.mutateAsync(routine); + navigate(makeLink(WgerLink.ROUTINE_EDIT, i18n.language, { id: result.id! })); - if (closeFn) { - closeFn(); - } + if (closeFn) { + closeFn(); } - }} - > - {formik => ( -
- - - - + } + }, + }); - - - - - - - - - + + + + + + + + {field => } + + + + + {field => } + + + + + + {field => { + const error = fieldError(field); + return { if (newValue) { - formik.setFieldValue('start', newValue); + field.handleChange(newValue); setStartDate(newValue); } }} @@ -177,22 +189,27 @@ export const RoutineForm = ({ textField: { variant: "standard", fullWidth: true, - error: formik.touched.start && Boolean(formik.errors.start), - helperText: formik.touched.start && formik.errors.start ? String(formik.errors.start) : '' + error: error !== undefined, + helperText: error ?? '' } }} - /> - - - - - ; + }} + + + + + + + {field => { + const error = fieldError(field); + return { if (newValue) { - formik.setFieldValue('end', newValue); + field.handleChange(newValue); setEndDate(newValue); } }} @@ -200,53 +217,62 @@ export const RoutineForm = ({ textField: { variant: "standard", fullWidth: true, - error: formik.touched.end && Boolean(formik.errors.end), - helperText: formik.touched.end && formik.errors.end ? String(formik.errors.end) : '' + error: error !== undefined, + helperText: error ?? '' } }} - /> - - - ; }} - > - {durationDays === 0 ? t('durationWeeks', { number: durationWeeks }) : t('durationWeeksDays', { - nrWeeks: durationWeeks, - nrDays: durationDays - })} - - - - } - label={t('routines.fitDaysInWeek')} /> - - - - - - - - - - - - )} -
) + + + + + {durationDays === 0 ? t('durationWeeks', { number: durationWeeks }) : t('durationWeeksDays', { + nrWeeks: durationWeeks, + nrDays: durationDays + })} + + + + {field => field.handleChange(event.target.checked)} + onBlur={field.handleBlur} + /> + } + label={t('routines.fitDaysInWeek')} />} + + + + + + + + + state.isSubmitting}> + {isSubmitting => } + + + + ); }; @@ -313,4 +339,4 @@ export const DefaultRoundingMenu = (props: { routineId: number }) => { ); -}; \ No newline at end of file +}; diff --git a/src/components/Routines/widgets/forms/SessionForm.test.tsx b/src/components/Routines/widgets/forms/SessionForm.test.tsx index b5bb16d06..9f422ab1c 100644 --- a/src/components/Routines/widgets/forms/SessionForm.test.tsx +++ b/src/components/Routines/widgets/forms/SessionForm.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { WorkoutSession } from "@/components/Routines/models/WorkoutSession"; +import { IMPRESSION_GOOD, WorkoutSession } from "@/components/Routines/models/WorkoutSession"; import { useAddSessionQuery, useEditSessionQuery, useSessionOfDay } from "@/components/Routines/queries"; import { DateTime } from 'luxon'; import { BrowserRouter } from "react-router-dom"; @@ -375,4 +375,21 @@ describe('SessionForm', () => { expect(draft.notes).toBe('Test notes'); expect(addMutateAsync).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + test('the picked impression is sent along', async () => { + + // Arrange + const user = userEvent.setup(); + mockUseSessionOfDay.mockImplementation(lookupReturning([])); + + // Act + renderForm(DateTime.fromISO('2024-05-01')); + await user.click(screen.getByRole('button', { name: /routines\.impressiongood/i })); + await user.click(screen.getByRole('button', { name: /submit/i })); + + // Assert + await waitFor(() => expect(addMutateAsync).toHaveBeenCalled()); + const draft = addMutateAsync.mock.calls[0][0] as WorkoutSession; + expect(draft.impression).toBe(IMPRESSION_GOOD); + }); +}); diff --git a/src/components/Routines/widgets/forms/SessionForm.tsx b/src/components/Routines/widgets/forms/SessionForm.tsx index 31ff0db51..01566004a 100644 --- a/src/components/Routines/widgets/forms/SessionForm.tsx +++ b/src/components/Routines/widgets/forms/SessionForm.tsx @@ -6,7 +6,8 @@ import { WorkoutSession } from "@/components/Routines/models/WorkoutSession"; import { useAddSessionQuery, useEditSessionQuery, useSessionOfDay } from "@/components/Routines/queries"; -import { WgerTextField } from "@/core/forms/WgerTextField"; +import { useAppForm } from "@/core/forms/appForm"; +import { defaultsKey, fieldError, submitHandler, yupSchema } from "@/core/forms/formUtils"; import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; import { Add, SentimentNeutral, SentimentSatisfiedAlt, SentimentVeryDissatisfied } from "@mui/icons-material"; import { @@ -22,7 +23,6 @@ import { import Grid from '@mui/material/Grid'; import { DatePicker, LocalizationProvider, TimePicker } from "@mui/x-date-pickers"; import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon"; -import { Form, Formik } from "formik"; import { DateTime } from "luxon"; import React from 'react'; import { useTranslation } from "react-i18next"; @@ -37,6 +37,14 @@ interface SessionFormProps { setChosenSessionId: (id: string | null) => void } +interface SessionFormValues { + notes: string, + date: Date, + start: DateTime | null, + end: DateTime | null, + impression: string, +} + /* Stands in for the session id while the user is adding one to a day that * already has sessions */ const NEW_SESSION = 'new'; @@ -51,10 +59,6 @@ export const SessionForm = ( setChosenSessionId }: SessionFormProps) => { - const [t, i18n] = useTranslation(); - - const addSessionQuery = useAddSessionQuery(); - const editSessionQuery = useEditSessionQuery(); const { sessions, session, isLoading: isLoadingSessions } = useSessionOfDay( routineId, dayId, @@ -67,6 +71,45 @@ export const SessionForm = ( // edit an arbitrary one or add yet another next to them const needsChoice = sessions.length > 1 && session === undefined && chosenSessionId !== NEW_SESSION; + // The form freezes its default values, so a session that arrives or changes + // later gets a fresh form via the key + return ; +}; + +const SessionFormFields = ( + { + dayId, + routineId, + selectedDate, + setSelectedDate, + setChosenSessionId, + sessions, + session, + needsChoice, + isLoadingSessions, + }: Omit & { + sessions: WorkoutSession[], + session: WorkoutSession | undefined, + needsChoice: boolean, + isLoadingSessions: boolean, + }) => { + + const [t, i18n] = useTranslation(); + + const addSessionQuery = useAddSessionQuery(); + const editSessionQuery = useEditSessionQuery(); + const isLoading = addSessionQuery.isPending || editSessionQuery.isPending || isLoadingSessions; const validationSchema = yup.object({ @@ -86,212 +129,223 @@ export const SessionForm = ( fitInWeek: yup.boolean() }); + const defaultValues: SessionFormValues = { + notes: session?.notes ?? '', + date: session !== undefined ? session.datetimeStart : new Date(), + start: session !== undefined ? DateTime.fromJSDate(session.datetimeStart) : null, + end: session?.datetimeEnd != null ? DateTime.fromJSDate(session.datetimeEnd) : null, + impression: session !== undefined ? session.impression : IMPRESSION_NEUTRAL, + }; - return ( - { - const day = selectedDate.startOf('day'); - const start = values.start - ? day.set({ hour: values.start.hour, minute: values.start.minute }) - : day; - let end = values.end - ? day.set({ hour: values.end.hour, minute: values.end.minute }) - : null; + const form = useAppForm({ + defaultValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => { + const day = selectedDate.startOf('day'); + const start = value.start + ? day.set({ hour: value.start.hour, minute: value.start.minute }) + : day; + let end = value.end + ? day.set({ hour: value.end.hour, minute: value.end.minute }) + : null; + + // An end before the start means the session ran past midnight + if (end !== null && end < start) { + end = end.plus({ days: 1 }); + } + + const draft = new WorkoutSession({ + id: session?.id ?? null, + dayId: dayId, + routineId: routineId, + notes: value.notes, + impression: value.impression, + datetimeStart: start.toJSDate(), + datetimeEnd: end !== null ? end.toJSDate() : null, + }); + + if (session !== undefined) { + await editSessionQuery.mutateAsync(draft); + } else { + // Keep editing what was just added, a second submit would + // otherwise write another session + const added = await addSessionQuery.mutateAsync(draft); + setChosenSessionId(added?.id ?? null); + } + }, + }); - // An end before the start means the session ran past midnight - if (end !== null && end < start) { - end = end.plus({ days: 1 }); - } - const draft = new WorkoutSession({ - id: session?.id ?? null, - dayId: dayId, - routineId: routineId, - notes: values.notes, - impression: values.impression, - datetimeStart: start.toJSDate(), - datetimeEnd: end !== null ? end.toJSDate() : null, - }); + return ( +
+ + + + + {field => { + if (!newValue) { + return; + } + field.handleChange(newValue.toJSDate()); + setSelectedDate(newValue); + }} + disableFuture={true} + slotProps={{ + textField: { + variant: "standard", + fullWidth: true, + error: fieldError(field) !== undefined, + } + }} + />} + + + {sessions.length > 1 && !needsChoice && + } + - if (session !== undefined) { - await editSessionQuery.mutateAsync(draft); - } else { - // Keep editing what was just added, a second submit would - // otherwise write another session - const added = await addSessionQuery.mutateAsync(draft); - setChosenSessionId(added?.id ?? null); - } - }} - > - {formik => ( - - - - - + + {t('routines.multipleSessions')} + + + {sessions.map(entry => + + setChosenSessionId(entry.id)}> + + + + )} + + setChosenSessionId(NEW_SESSION)}> + + + + + + + + : <> + + + + {field => { - if (!newValue) { - return; + if (newValue) { + field.handleChange(newValue); } - formik.setFieldValue('date', newValue); - setSelectedDate(newValue); }} - disableFuture={true} slotProps={{ textField: { variant: "standard", fullWidth: true, - error: formik.touched.date && Boolean(formik.errors.date), - // helperText: formik.touched.date && formik.errors.date + onBlur: field.handleBlur, + error: fieldError(field) !== undefined, + helperText: fieldError(field) } }} - /> - - {sessions.length > 1 && !needsChoice && - } - - - {needsChoice ? - - {t('routines.multipleSessions')} - - - {sessions.map(entry => - - setChosenSessionId(entry.id)}> - - - - )} - - setChosenSessionId(NEW_SESSION)}> - - - - - - - - : <> - - - { - if (newValue) { - formik.setFieldValue('start', newValue); - } - }} - slotProps={{ - textField: { - variant: "standard", - fullWidth: true, - error: formik.touched.start && Boolean(formik.errors.start), - helperText: formik.touched.start && formik.errors.start - } - }} - /> - - - - - { - if (newValue) { - formik.setFieldValue('end', newValue); - } - }} - slotProps={{ - textField: { - variant: "standard", - fullWidth: true, - error: formik.touched.end && Boolean(formik.errors.end), - helperText: formik.touched.end && formik.errors.end - } - }} - /> - - - - - + />} + + + + + + + {field => { + if (newValue) { + field.handleChange(newValue); + } + }} + slotProps={{ + textField: { + variant: "standard", + fullWidth: true, + onBlur: field.handleBlur, + error: fieldError(field) !== undefined, + helperText: fieldError(field) + } + }} + />} + + + + + + {field => } + + - - {t('routines.impression')} - + {t('routines.impression')} + + {field => + - - - - - - - - - - + + {t('routines.impressionBad')} + + - - } + } + -
- )} -
+ + + + + + + + + } + + ); }; diff --git a/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx b/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx index 69e7979b8..2c6567817 100644 --- a/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx +++ b/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx @@ -155,4 +155,45 @@ describe('SessionLogsForm', () => { // Assert expect(screen.queryByText('Squats')).not.toBeInTheDocument(); }); + + test('removing a single set drops just that log', async () => { + // Arrange + const user = userEvent.setup(); + + // Act: the first delete icon belongs to the exercise, the second to its first set + render(); + await user.click(screen.getAllByTestId('DeleteOutlinedIcon')[1]); + await user.click(screen.getByRole('button', { name: /submit/i })); + + // Assert + expect(screen.getByText('Squats')).toBeInTheDocument(); + expect(mockMutateAsync.mock.calls[0][0].length).toEqual(3); + }); + + test('a weight that is not a number shows the error and blocks the submit', async () => { + // Arrange + const user = userEvent.setup(); + + // Act + render(); + const weightInput = screen.getAllByRole('textbox').filter(input => (input as HTMLInputElement).value === '20')[0]; + await user.clear(weightInput); + await user.type(weightInput, 'heavy'); + await user.tab(); + + // Assert + expect(await screen.findByText('forms.enterNumber')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled(); + expect(mockMutateAsync).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/Routines/widgets/forms/SessionLogsForm.tsx b/src/components/Routines/widgets/forms/SessionLogsForm.tsx index cbd74bb54..dffbcf4f9 100644 --- a/src/components/Routines/widgets/forms/SessionLogsForm.tsx +++ b/src/components/Routines/widgets/forms/SessionLogsForm.tsx @@ -1,16 +1,27 @@ -import { WgerTextField } from "@/core/forms/WgerTextField"; -import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; -import { Exercise, getLanguageByShortName, NameAutocompleter, useLanguageQuery } from "@/components/Exercises"; +import { + Exercise, + getLanguageByShortName, + Language, + NameAutocompleter, + useLanguageQuery +} from "@/components/Exercises"; import { RIR_VALUES_SELECT } from "@/components/Routines/models/BaseConfig"; import { LogEntryForm } from "@/components/Routines/models/WorkoutLog"; import { useAddRoutineLogsQuery, useRoutineDetailQuery, useSessionOfDay } from "@/components/Routines/queries"; +import { + logsPayload, + plannedLogs, + SessionLogsFormValues +} from "@/components/Routines/widgets/forms/sessionLogsFormData"; +import { useAppForm } from "@/core/forms/appForm"; +import { defaultsKey, submitHandler, yupSchema } from "@/core/forms/formUtils"; import { REP_UNIT_REPETITIONS, SNACKBAR_AUTO_HIDE_DURATION } from "@/core/lib/consts"; +import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import { SwapHoriz } from "@mui/icons-material"; import AddIcon from "@mui/icons-material/Add"; import DeleteIcon from "@mui/icons-material/DeleteOutlined"; import { Alert, Button, IconButton, InputAdornment, MenuItem, Snackbar, TextField, Typography } from "@mui/material"; import Grid from '@mui/material/Grid'; -import { FieldArray, Form, Formik, FormikProps } from "formik"; import { DateTime } from "luxon"; import React, { useRef, useState } from 'react'; import { useTranslation } from "react-i18next"; @@ -26,19 +37,12 @@ interface SessionLogsFormProps { export const SessionLogsForm = ({ dayId, routineId, selectedDate, chosenSessionId }: SessionLogsFormProps) => { const { t, i18n } = useTranslation(); - const [snackbarOpen, setSnackbarOpen] = useState(false); const routineQuery = useRoutineDetailQuery(routineId); // The session the form above works on, so the logs end up in the one the // user has in front of them. Without it the server would sort them into a // session by their time, which on a day with several of them is a guess const { session } = useSessionOfDay(routineId, dayId, selectedDate, chosenSessionId); - const addLogsQuery = useAddRoutineLogsQuery(routineId); const languageQuery = useLanguageQuery(); - const handleSnackbarClose = () => setSnackbarOpen(false); - const [exerciseIdToSwap, setExerciseIdToSwap] = useState(null); - - // Counter for the keys of the logs the user adds on top of the planned ones - const extraLogKey = useRef(0); let language = undefined; if (languageQuery.isSuccess) { @@ -52,9 +56,46 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate, chosenSessionI return ; } - const routine = routineQuery.data!; - const iterationDayData = routine?.getDayData(dayId, selectedDate.toJSDate()) ?? []; - const hasNoIterationData = iterationDayData.length === 0; + const { logs: defaultLogs, iteration } = plannedLogs(routineQuery.data!, dayId, selectedDate.toJSDate()); + + return (<> + {iteration === null && + {t('routines.weightLogNotPlanned')} + } + + {/* The form freezes its default values, so a changed plan for the day + * gets a fresh form via the key */} + [log.clientKey, log.repetitions, log.weight, log.rir]))} + dayId={dayId} + routineId={routineId} + selectedDate={selectedDate} + sessionId={session?.id} + iteration={iteration} + language={language} + defaultLogs={defaultLogs} + /> + ); +}; + +const SessionLogsFields = ({ dayId, routineId, selectedDate, sessionId, iteration, language, defaultLogs }: { + dayId: number, + routineId: number, + selectedDate: DateTime, + sessionId: string | null | undefined, + iteration: number | null, + language: Language | undefined, + defaultLogs: LogEntryForm[], +}) => { + + const { t } = useTranslation(); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const addLogsQuery = useAddRoutineLogsQuery(routineId); + const handleSnackbarClose = () => setSnackbarOpen(false); + const [exerciseIdToSwap, setExerciseIdToSwap] = useState(null); + + // Counter for the keys of the logs the user adds on top of the planned ones + const extraLogKey = useRef(0); const validationSchema = yup.object({ logs: yup.array().of( @@ -66,51 +107,29 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate, chosenSessionI ), }); - const handleSubmit = async (values: { logs: LogEntryForm[] }) => { - const iteration = hasNoIterationData ? null : iterationDayData[0].iteration; - const data = values.logs - .filter(l => l.rir !== '' || l.repetitions !== '' || l.weight !== '') - .map(l => ({ - date: selectedDate.toISO(), - session: session?.id, - iteration: iteration, - exercise: l.exercise?.id, - day: dayId, - routine: routineId, - // eslint-disable-next-line camelcase - slot_entry: l.slotEntry, - - rir: l.rir !== '' ? l.rir : null, - // eslint-disable-next-line camelcase - rir_target: l.rirTarget !== '' ? l.rirTarget : null, - - // eslint-disable-next-line camelcase - repetitions_unit: l.repetitionsUnit?.id, - repetitions: l.repetitions !== '' ? l.repetitions : null, - // eslint-disable-next-line camelcase - repetitions_target: l.repetitionsTarget !== '' ? l.repetitionsTarget : null, - - // eslint-disable-next-line camelcase - weight_unit: l.weightUnit?.id, - weight: l.weight !== '' ? l.weight : null, - // eslint-disable-next-line camelcase - weight_target: l.weightTarget !== '' ? l.weightTarget : null, - } - )); - - await addLogsQuery.mutateAsync(data); + const handleSubmit = async (values: SessionLogsFormValues) => { + await addLogsQuery.mutateAsync(logsPayload(values.logs, { + date: selectedDate, + sessionId, + iteration, + dayId, + routineId, + })); setSnackbarOpen(true); }; - const handleCallback = async (exercise: Exercise | null, formik: FormikProps<{ - logs: LogEntryForm[] - }>) => { + const form = useAppForm({ + defaultValues: { logs: defaultLogs } as SessionLogsFormValues, + validators: { onChange: yupSchema(validationSchema) }, + onSubmit: async ({ value }) => handleSubmit(value), + }); + const handleCallback = (exercise: Exercise | null) => { if (exercise === null) { return; } - const updatedLogs = formik.values.logs.map((log) => { + const updatedLogs = form.state.values.logs.map((log) => { if (exerciseIdToSwap === log.exercise!.id) { // Empty the rest of the values, this is a new exercise not in the routine return { @@ -127,208 +146,163 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate, chosenSessionI return log; }); - await formik.setValues({ - ...formik.values, - logs: updatedLogs - }); - + form.setFieldValue('logs', updatedLogs); setExerciseIdToSwap(null); }; - // Compute initial values - const initialValues = { - logs: [] as LogEntryForm[] - }; - - const dayDataList = hasNoIterationData ? routine.dayDataCurrentIteration.filter(dayData => dayData.day?.id === dayId) : iterationDayData; - - for (const dayData of dayDataList) { - for (const slot of dayData.slots) { - for (const config of slot.setConfigs) { - for (let i = 0; i < config.nrOfSets; i++) { - - initialValues.logs.push({ - clientKey: `${dayData.iteration}-${config.slotEntryId}-${config.exerciseId}-${i}`, - exercise: config.exercise!, - repetitionsUnit: config.repetitionsUnit!, - weightUnit: config.weightUnit!, - slotEntry: config.slotEntryId, - - rir: !hasNoIterationData && config.rir !== null ? config.rir : '', - rirTarget: !hasNoIterationData && config.rir !== null ? config.rir : '', - repetitions: !hasNoIterationData && config.repetitions !== null ? config.repetitions : '', - repetitionsTarget: !hasNoIterationData && config.repetitions !== null ? config.repetitions : '', - weight: !hasNoIterationData && config.weight !== null ? config.weight : '', - weightTarget: !hasNoIterationData && config.weight !== null ? config.weight : '' - }); - } - } - } - } - return (<> - {hasNoIterationData && - {t('routines.weightLogNotPlanned')} - } - - - - {formik => ( -
- - {({ insert, remove }) => (<> - - {formik.values.logs.map((log, index) => ( - - - {/* Only show the exercise name the first time it appears */} - {(index === 0 || (index > 0 && formik.values.logs[index - 1].exercise!.id !== formik.values.logs[index].exercise!.id)) && <> - - {exerciseIdToSwap !== formik.values.logs[index].exercise!.id && - - {formik.values.logs[index].exercise?.getTranslation(language).name} - } - - {exerciseIdToSwap === formik.values.logs[index].exercise!.id && - handleCallback(searchResponse, formik)} - />} - - - - - - - - - } - - - {/* Only show reps that are not "repetitions" */} - {formik.values.logs[index].repetitionsUnit?.id !== REP_UNIT_REPETITIONS - ? - {formik.values.logs[index].repetitionsUnit?.name} - - : null} - - }, - htmlInput: { - inputMode: 'decimal' - } - } - }} - /> - - - - - {formik.values.logs[index].weightUnit?.name} - - - }, - htmlInput: { - inputMode: 'decimal' - } - } - }} - /> - + + {/* The rows read every value of every log, which an array field does + * not re-render for: it only follows the array's length */} + state.values.logs}> + {logs => logs.map((log, index) => ( + + + {/* Only show the exercise name the first time it appears */} + {(index === 0 || logs[index - 1].exercise!.id !== log.exercise!.id) && <> + + {exerciseIdToSwap !== log.exercise!.id && + + {log.exercise?.getTranslation(language).name} + } + + {exerciseIdToSwap === log.exercise!.id && + } + + + + + + + + + } + + + {field => + {/* Only show reps that are not "repetitions" */} + {log.repetitionsUnit?.id !== REP_UNIT_REPETITIONS + ? + + {log.repetitionsUnit?.name} + + : null} + + }, + htmlInput: { + inputMode: 'decimal' + } + } + }} + />} + + + + + {field => + + {log.weightUnit?.name} + + + }, + htmlInput: { + inputMode: 'decimal' + } + } + }} + />} + + - - - {RIR_VALUES_SELECT.map((option) => ( - - {option.label} - - ))} - - - - remove(index)}> - - - - - ))} - - )} - - - - + + + {field => field.handleChange(event.target.value)} + onBlur={field.handleBlur} + > + {RIR_VALUES_SELECT.map((option) => ( + + {option.label} + + ))} + } + + + + form.removeFieldValue('logs', index)}> + + - - )} -
+ ))} + + + + ({ isValid: state.isValid, isSubmitting: state.isSubmitting })}> + {({ isValid, isSubmitting }) => } + + + + ({ + forceInteger: forceInteger, + + edited: edited, + iteration: iteration, + + id: null, + idMax: null, + value: '', + valueMax: '', + operation: OPERATION_REPLACE, + operationMax: OPERATION_REPLACE, + step: "abs", + stepMax: "abs", + requirements: [], + requirementsMax: [], + repeat: false, + repeatMax: false, +}); + +/** One row per iteration, filled from the stored min and max configs */ +export function progressionEntries( + iterations: number[], + configs: BaseConfig[], + configsMax: BaseConfig[], + forceInteger: boolean, +): BaseConfigEntryForm[] { + return iterations.map(iteration => { + const config = configs.find((c) => c.iteration === iteration); + const configMax = configsMax.find((c) => c.iteration === iteration); + + if (config === undefined) { + return emptyEntry(iteration, false, forceInteger); + } + return { + forceInteger: forceInteger, + + edited: true, + id: config.id, + idMax: configMax === undefined ? null : configMax.id, + iteration: iteration, + value: String(config.value), + valueMax: configMax === undefined ? '' : String(configMax.value), + operation: config.operation, + operationMax: configMax === undefined ? OPERATION_REPLACE : config.operation, + step: config.step, + stepMax: configMax === undefined ? "abs" : configMax.step, + requirements: config.requirements?.rules ?? [], + requirementsMax: configMax === undefined ? [] : configMax.requirements?.rules ?? [], + repeat: config.repeat, + repeatMax: configMax === undefined ? false : config.repeat, + }; + }); +} + +export const progressionSchema = (t: TFunction) => yup.object({ + entries: yup.array().of( + yup.object().shape({ + edited: yup.boolean(), + iteration: yup.number().required(), + + // Conditionally apply integer validation e.g. for sets + value: yup.number() + .when('forceInteger', { + is: true, + then: schema => schema.integer(t('forms.enterInteger')).typeError(t('forms.enterNumber')), + otherwise: schema => schema.typeError(t('forms.enterNumber')).nullable().notRequired(), + }), + + // only check that the max number is higher when replacing. In other cases allow the max + // weight to e.g. increase less than the min weight + valueMax: yup.number().typeError(t('forms.enterNumber')).nullable() + .when('forceInteger', { + is: true, + then: schema => schema.integer(t('forms.enterInteger')).typeError(t('forms.enterNumber')), + otherwise: schema => schema.typeError(t('forms.enterNumber')).nullable().notRequired(), + }) + // Conditionally apply integer validation e.g. for sets + .when('operation', { + is: OPERATION_REPLACE, + then: schema => schema.min(yup.ref('value'), t('forms.maxLessThanMin')), + otherwise: schema => schema, + }), + operation: yup.string().required(), + operationMax: yup.string().required(), + requirements: yup.array().of(yup.string().oneOf(REQUIREMENTS_VALUES)), + requirementsMax: yup.array().of(yup.string().oneOf(REQUIREMENTS_VALUES)), + repeat: yup.boolean(), + repeatMax: yup.boolean() + }) + ) + .test( + 'inter-entry-validation', + 'Your error message here', + function (entries) { // Use 'function' to access 'this' + const { createError } = this; + + const data = entries as unknown as BaseConfigEntryForm[]; + + for (let i = 0; i < data.length; i++) { + const entry = data[i]; + + if (entry.iteration > 1 && entry.operation !== OPERATION_REPLACE) { + let hasValuePreviousReplace = false; + let hasMaxValuePreviousReplace = false; + + for (let j = 0; j < i; j++) { + if (data[j].operation === OPERATION_REPLACE && data[j].value !== '' && data[j].edited) { + hasValuePreviousReplace = true; + } + + if (data[j].operation === OPERATION_REPLACE && data[j].valueMax !== '' && data[j].edited) { + hasMaxValuePreviousReplace = true; + } + } + + if (!hasValuePreviousReplace) { + return createError({ + path: `entries[${i}].value`, + message: t('routines.progressionNeedsReplace') + }); + } + if (!hasMaxValuePreviousReplace) { + return createError({ + path: `entries[${i}].valueMax`, + message: t('routines.progressionNeedsReplace') + }); + } + } + } + + // All entries valid + return true; + } + ) + , +}); + +interface PayloadContext { + slotEntryId: number, + configs: BaseConfig[], + configsMax: BaseConfig[], + /** Iterations whose stored config the user deleted in the form */ + iterationsToDelete: number[], + apiPath: ApiPath, + apiPathMax: ApiPath, +} + +/** What the server has to add, edit and delete so it holds these entries */ +export function progressionPayload( + entries: BaseConfigEntryForm[], + { slotEntryId, configs, configsMax, iterationsToDelete, apiPath, apiPathMax }: PayloadContext, +): { values: ProcessBaseConfigsParams, maxValues: ProcessBaseConfigsParams } { + // Remove empty entries + const data = entries.filter(e => e.edited); + + // Split between min and max values + const editList: EditBaseConfigParams[] = data.filter(data => data.id !== null).map(data => ({ + id: data.id!, + // eslint-disable-next-line camelcase + slot_entry: slotEntryId, + value: data.value as number, + iteration: data.iteration, + operation: data.operation, + step: data.step, + repeat: data.repeat, + requirements: { rules: data.requirements ?? [] } + })); + const addList: AddBaseConfigParams[] = data.filter(data => data.id === null && data.value !== '').map(data => ({ + // eslint-disable-next-line camelcase + slot_entry: slotEntryId, + value: data.value as number, + iteration: data.iteration, + operation: data.operation, + step: data.step, + repeat: data.repeat, + requirements: { rules: data.requirements ?? [] } + })); + // Items to delete, also includes all where the value is empty + const deleteList = configs.filter(c => iterationsToDelete.includes(c.iteration)).map(c => c.id); + data.forEach(entry => { + if (entry.value === "" && entry.id !== null && !deleteList.includes(entry.id)) { + deleteList.push(entry.id); + } + }); + + // Max values + const editListMax: EditBaseConfigParams[] = data.filter(data => data.idMax !== null && data.valueMax !== '').map(data => ({ + id: data.idMax!, + // eslint-disable-next-line camelcase + slot_entry: slotEntryId, + value: data.valueMax as number, + iteration: data.iteration, + operation: data.operation, + step: data.step, + repeat: data.repeat, + requirements: { rules: data.requirements ?? [] } + })); + const addListMax: AddBaseConfigParams[] = data.filter(data => data.idMax === null && data.valueMax !== '').map(data => ({ + iteration: data.iteration, + // eslint-disable-next-line camelcase + slot_entry: slotEntryId, + value: data.valueMax as number, + operation: data.operation, + step: data.stepMax, + repeat: data.repeat, + requirements: { rules: data.requirements ?? [] } + })); + // Items to delete, also includes all where the value is empty + const deleteListMax = configsMax.filter(c => iterationsToDelete.includes(c.iteration)).map(c => c.id); + data.forEach(entry => { + if (entry.valueMax === "" && entry.idMax !== null && !deleteList.includes(entry.idMax)) { + deleteListMax.push(entry.idMax); + } + }); + + return { + values: { + toAdd: addList, + toDelete: deleteList, + toEdit: editList, + apiPath: apiPath + }, + maxValues: { + toAdd: addListMax, + toDelete: deleteListMax, + toEdit: editListMax, + apiPath: apiPathMax + } + }; +} diff --git a/src/components/Routines/widgets/forms/sessionLogsFormData.ts b/src/components/Routines/widgets/forms/sessionLogsFormData.ts new file mode 100644 index 000000000..137dfcb76 --- /dev/null +++ b/src/components/Routines/widgets/forms/sessionLogsFormData.ts @@ -0,0 +1,91 @@ +import { Routine } from "@/components/Routines/models/Routine"; +import { LogEntryForm } from "@/components/Routines/models/WorkoutLog"; +import { DateTime } from "luxon"; + +export interface SessionLogsFormValues { + logs: LogEntryForm[], +} + +/** + * One log per planned set of the day. Where the routine has an iteration for + * the date, the logs carry its values as targets; otherwise the current + * iteration's sets are offered empty and the iteration stays null. + */ +export function plannedLogs(routine: Routine, dayId: number, date: Date): { + logs: LogEntryForm[], + iteration: number | null, +} { + const iterationDayData = routine.getDayData(dayId, date) ?? []; + const hasNoIterationData = iterationDayData.length === 0; + const dayDataList = hasNoIterationData + ? routine.dayDataCurrentIteration.filter(dayData => dayData.day?.id === dayId) + : iterationDayData; + + const logs: LogEntryForm[] = []; + for (const dayData of dayDataList) { + for (const slot of dayData.slots) { + for (const config of slot.setConfigs) { + for (let i = 0; i < config.nrOfSets; i++) { + + logs.push({ + clientKey: `${dayData.iteration}-${config.slotEntryId}-${config.exerciseId}-${i}`, + exercise: config.exercise!, + repetitionsUnit: config.repetitionsUnit!, + weightUnit: config.weightUnit!, + slotEntry: config.slotEntryId, + + rir: !hasNoIterationData && config.rir !== null ? config.rir : '', + rirTarget: !hasNoIterationData && config.rir !== null ? config.rir : '', + repetitions: !hasNoIterationData && config.repetitions !== null ? config.repetitions : '', + repetitionsTarget: !hasNoIterationData && config.repetitions !== null ? config.repetitions : '', + weight: !hasNoIterationData && config.weight !== null ? config.weight : '', + weightTarget: !hasNoIterationData && config.weight !== null ? config.weight : '' + }); + } + } + } + } + + return { logs, iteration: hasNoIterationData ? null : iterationDayData[0].iteration }; +} + +interface PayloadContext { + date: DateTime, + sessionId: string | null | undefined, + iteration: number | null, + dayId: number, + routineId: number, +} + +/** The logs the user filled in, as the server takes them; untouched sets are left out */ +export function logsPayload(logs: LogEntryForm[], { date, sessionId, iteration, dayId, routineId }: PayloadContext) { + return logs + .filter(l => l.rir !== '' || l.repetitions !== '' || l.weight !== '') + .map(l => ({ + date: date.toISO(), + session: sessionId, + iteration: iteration, + exercise: l.exercise?.id, + day: dayId, + routine: routineId, + // eslint-disable-next-line camelcase + slot_entry: l.slotEntry, + + rir: l.rir !== '' ? l.rir : null, + // eslint-disable-next-line camelcase + rir_target: l.rirTarget !== '' ? l.rirTarget : null, + + // eslint-disable-next-line camelcase + repetitions_unit: l.repetitionsUnit?.id, + repetitions: l.repetitions !== '' ? l.repetitions : null, + // eslint-disable-next-line camelcase + repetitions_target: l.repetitionsTarget !== '' ? l.repetitionsTarget : null, + + // eslint-disable-next-line camelcase + weight_unit: l.weightUnit?.id, + weight: l.weight !== '' ? l.weight : null, + // eslint-disable-next-line camelcase + weight_target: l.weightTarget !== '' ? l.weightTarget : null, + } + )); +} diff --git a/src/core/forms/LicenseAuthor.tsx b/src/core/forms/LicenseAuthor.tsx deleted file mode 100644 index d017e832b..000000000 --- a/src/core/forms/LicenseAuthor.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { TextField } from "@mui/material"; -import React from "react"; -import { useField } from "formik"; - -export function LicenseAuthor(props: { fieldName: string }) { - const [t] = useTranslation(); - const [field, meta] = useField(props.fieldName); - - return ; -} \ No newline at end of file diff --git a/src/core/forms/LicenseAuthorUrl.tsx b/src/core/forms/LicenseAuthorUrl.tsx deleted file mode 100644 index 55f03099c..000000000 --- a/src/core/forms/LicenseAuthorUrl.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { TextField } from "@mui/material"; -import React from "react"; -import { useField } from "formik"; - -export function LicenseAuthorUrl(props: { fieldName: string }) { - const [t] = useTranslation(); - const [field, meta] = useField(props.fieldName); - - return ; -} \ No newline at end of file diff --git a/src/core/forms/LicenseDerivativeSourceUrl.tsx b/src/core/forms/LicenseDerivativeSourceUrl.tsx deleted file mode 100644 index 6c40b0f10..000000000 --- a/src/core/forms/LicenseDerivativeSourceUrl.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { TextField } from "@mui/material"; -import React from "react"; -import { useField } from "formik"; - -export function LicenseDerivativeSourceUrl(props: { fieldName: string }) { - const [t] = useTranslation(); - const [field, meta] = useField(props.fieldName); - - return ; -} \ No newline at end of file diff --git a/src/core/forms/LicenseObjectUrl.tsx b/src/core/forms/LicenseObjectUrl.tsx deleted file mode 100644 index 800e98fb7..000000000 --- a/src/core/forms/LicenseObjectUrl.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { TextField } from "@mui/material"; -import React from "react"; -import { useField } from "formik"; - -export function LicenseObjectUrl(props: { fieldName: string }) { - const [t] = useTranslation(); - const [field, meta] = useField(props.fieldName); - - return ; -} \ No newline at end of file diff --git a/src/core/forms/LicenseTitle.tsx b/src/core/forms/LicenseTitle.tsx deleted file mode 100644 index fe6817d0e..000000000 --- a/src/core/forms/LicenseTitle.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { TextField } from "@mui/material"; -import React from "react"; -import { useField } from "formik"; - -export function LicenseTitle(props: { fieldName: string }) { - const [t] = useTranslation(); - const [field, meta] = useField(props.fieldName); - - return ; -} \ No newline at end of file diff --git a/src/core/forms/WgerTextField.tsx b/src/core/forms/WgerTextField.tsx index e69516b63..a82ff87c2 100644 --- a/src/core/forms/WgerTextField.tsx +++ b/src/core/forms/WgerTextField.tsx @@ -1,26 +1,49 @@ import { TextField, TextFieldProps } from "@mui/material"; -import { useField } from "formik"; +import { useFieldContext } from "@/core/forms/formContexts"; +import { fieldError } from "@/core/forms/formUtils"; import React from "react"; interface WgerTextFieldProps { - fieldName: string, title: string, + variant?: TextFieldProps['variant'], + /** Shown under the field while it has no error */ + helperText?: string, + /** Called after the form took the new value */ + onValueChange?: (value: string) => void, + /** Anything else MUI should get; the binding props below win */ fieldProps?: TextFieldProps, fullwidth?: boolean, } -export function WgerTextField(props: WgerTextFieldProps) { - const [field, meta] = useField(props.fieldName); - const fullwidth = props.fullwidth ?? true; +/** + * The generic text field, bound to the form field it is rendered in via + * form.AppField. + */ +export function WgerTextField({ + title, + variant, + helperText, + onValueChange, + fieldProps, + fullwidth = true + }: WgerTextFieldProps) { + const field = useFieldContext(); + const error = fieldError(field); return { + field.handleChange(e.target.value); + onValueChange?.(e.target.value); + }} + onBlur={field.handleBlur} + error={error !== undefined} + helperText={error ?? helperText} />; -} \ No newline at end of file +} diff --git a/src/core/forms/appForm.ts b/src/core/forms/appForm.ts new file mode 100644 index 000000000..417298e5d --- /dev/null +++ b/src/core/forms/appForm.ts @@ -0,0 +1,12 @@ +import { createFormHook } from "@tanstack/react-form"; +import { WgerTextField } from "@/core/forms/WgerTextField"; +import { fieldContext, formContext } from "@/core/forms/formContexts"; + +// Field components registered here are available as +// inside . Only useAppForm is exported: withForm is gone in v2. +export const { useAppForm } = createFormHook({ + fieldContext, + formContext, + fieldComponents: { WgerTextField }, + formComponents: {}, +}); diff --git a/src/core/forms/formContexts.ts b/src/core/forms/formContexts.ts new file mode 100644 index 000000000..2a5037e8b --- /dev/null +++ b/src/core/forms/formContexts.ts @@ -0,0 +1,5 @@ +import { createFormHookContexts } from "@tanstack/react-form"; + +// Separate from appForm.ts so the bound field components can import the +// contexts without importing the hook that registers them +export const { fieldContext, formContext, useFieldContext } = createFormHookContexts(); diff --git a/src/core/forms/formUtils.test.ts b/src/core/forms/formUtils.test.ts new file mode 100644 index 000000000..905641c3d --- /dev/null +++ b/src/core/forms/formUtils.test.ts @@ -0,0 +1,141 @@ +import type { AnyFieldApi, AnyFormApi } from "@tanstack/react-form"; +import { fieldError, fieldErrorMessage, setServerError, submitHandler, yupSchema } from "@/core/forms/formUtils"; +import * as yup from "yup"; + +/** Runs the adapted schema and returns the issues, or none when it passes */ +const validate = (schema: yup.AnySchema, value: unknown) => { + const result = yupSchema(schema)['~standard'].validate(value); + if (result instanceof Promise) { + throw new Error('the adapter must validate synchronously'); + } + return result.issues; +}; + +describe('yupSchema', () => { + + test('passes valid input without issues', () => { + const schema = yup.object({ name: yup.string().required() }); + + expect(validate(schema, { name: 'Squats' })).toBeUndefined(); + }); + + test('reports nested errors with the path as segments', () => { + const schema = yup.object({ + logs: yup.array().of(yup.object({ weight: yup.number().typeError('not a number') })), + }); + + const issues = validate(schema, { logs: [{ weight: 20 }, { weight: 'heavy' }] }); + + expect(issues).toEqual([{ message: 'not a number', path: ['logs', 1, 'weight'] }]); + }); + + test('reports errors of a custom test on the path it names', () => { + const schema = yup.object({ + entries: yup.array().test('needs-first', 'first is required', function (entries) { + return (entries?.length ?? 0) > 0 || this.createError({ + path: 'entries[0].value', + message: 'first is required' + }); + }), + }); + + const issues = validate(schema, { entries: [] }); + + expect(issues).toEqual([{ message: 'first is required', path: ['entries', 0, 'value'] }]); + }); + + test('treats an empty string as not filled in, like Formik did', () => { + const schema = yup.object({ + optional: yup.number().notRequired().positive(), + required: yup.number().required('required'), + list: yup.array().of(yup.number().nullable()), + }); + + expect(validate(schema, { optional: '', required: 5, list: ['', 3] })).toBeUndefined(); + expect(validate(schema, { optional: '', required: '', list: [] })).toEqual([ + { message: 'required', path: ['required'] }, + ]); + }); + + test('leaves class instances alone while clearing empty strings', () => { + const date = new Date('2026-09-07'); + const schema = yup.object({ date: yup.date().required() }); + + expect(validate(schema, { date })).toBeUndefined(); + }); + + test('rethrows anything that is not a validation error', () => { + const schema = yup.object({ + name: yup.string().test('boom', 'boom', () => { + throw new TypeError('broken test'); + }), + }); + + expect(() => validate(schema, { name: 'x' })).toThrow(TypeError); + }); +}); + +describe('fieldErrorMessage', () => { + + test('returns a plain string error as it is', () => { + expect(fieldErrorMessage(['too short'])).toBe('too short'); + }); + + test('returns the message of a Standard Schema issue', () => { + expect(fieldErrorMessage([{ message: 'too short', path: ['name'] }])).toBe('too short'); + }); + + test('skips the empty slots other validators leave behind', () => { + expect(fieldErrorMessage([undefined, 'late error'])).toBe('late error'); + }); + + test('returns nothing for a valid field', () => { + expect(fieldErrorMessage([])).toBeUndefined(); + }); +}); + +describe('fieldError', () => { + + const fieldWith = (isTouched: boolean, errors: unknown[]) => + ({ state: { meta: { isTouched, errors } } }) as unknown as AnyFieldApi; + + test('shows nothing before the user touched the field', () => { + expect(fieldError(fieldWith(false, ['too short']))).toBeUndefined(); + }); + + test('shows the first error once touched', () => { + expect(fieldError(fieldWith(true, ['too short', 'also too long']))).toBe('too short'); + }); +}); + +describe('submitHandler', () => { + + test('keeps the browser from submitting and hands over to the form', () => { + const form = { handleSubmit: vi.fn() } as unknown as AnyFormApi; + const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() }; + + submitHandler(form)(event as unknown as React.FormEvent); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(form.handleSubmit).toHaveBeenCalled(); + }); +}); + +describe('setServerError', () => { + + test('writes the message into the server slot and keeps the other errors', () => { + let meta = { errorMap: { onChange: 'too short' } }; + const form = { + setFieldMeta: vi.fn((_field: string, updater: (prev: typeof meta) => typeof meta) => { + meta = updater(meta); + }), + } as unknown as AnyFormApi; + + setServerError(form, 'description', 'not English'); + expect(meta.errorMap).toEqual({ onChange: 'too short', onServer: 'not English' }); + + setServerError(form, 'description', undefined); + expect(meta.errorMap).toEqual({ onChange: 'too short', onServer: undefined }); + }); +}); diff --git a/src/core/forms/formUtils.ts b/src/core/forms/formUtils.ts new file mode 100644 index 000000000..18a6a7626 --- /dev/null +++ b/src/core/forms/formUtils.ts @@ -0,0 +1,117 @@ +import type { AnyFieldApi, AnyFormApi, StandardSchemaV1 } from "@tanstack/react-form"; +import type { FormEvent } from "react"; +import { AnySchema, ValidationError } from "yup"; + +interface Issue { + message: string, + path?: Array, +} + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype; + +/** + * What Formik did before validating: an empty string means "not filled in", + * so an optional number field left blank passes instead of casting to NaN. + */ +function emptyStringsToUndefined(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(emptyStringsToUndefined); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, emptyStringsToUndefined(entry)]) + ); + } + return value === '' ? undefined : value; +} + +/** "logs[0].weight" as the segments a Standard Schema issue carries */ +const pathSegments = (path: string | undefined): Array | undefined => + path?.match(/[^.[\]]+/g)?.map(segment => /^\d+$/.test(segment) ? Number(segment) : segment); + +/** Yup's own conversion, which it only runs for its async Standard Schema adapter */ +function issuesOf(error: ValidationError, parentPath?: string): Issue[] { + if (error.inner.length === 0 && error.errors.length > 0) { + const path = parentPath ? `${parentPath}.${error.path}` : error.path; + return error.errors.map(message => ({ message, path: pathSegments(path) })); + } + const path = parentPath ? `${parentPath}.${error.path}` : error.path; + return error.inner.flatMap(inner => issuesOf(inner, path)); +} + +/** + * A yup schema as synchronous form validator. + * + * Yup's own Standard Schema adapter is async, and TanStack drops a submit + * while an async validation is still running, e.g. when the user saves right + * after typing. Our schemas have no async rules, so validateSync closes that + * window. Yup also types its input as the cast output (weight: number) while + * the form holds what the inputs hand it (weight: string), so the input type + * is asserted to the form's. + */ +export function yupSchema(schema: AnySchema): StandardSchemaV1 { + const adapted = { + '~standard': { + version: 1, + vendor: 'yup', + validate: (value: unknown) => { + try { + return { value: schema.validateSync(emptyStringsToUndefined(value), { abortEarly: false }) }; + } catch (error) { + if (error instanceof ValidationError) { + return { issues: issuesOf(error) }; + } + throw error; + } + }, + }, + }; + return adapted as StandardSchemaV1; +} + +/** + * The text of a field's first error: plain validators return strings, + * Standard Schema validators issue objects. + */ +export function fieldErrorMessage(errors: ReadonlyArray): string | undefined { + const first = errors.find(error => error !== undefined); + if (typeof first === 'string') { + return first; + } + if (typeof first === 'object' && first !== null && 'message' in first) { + return String(first.message); + } + return undefined; +} + +/** + * An error the server reported for one field, shown until it is cleared + * again. Replaces Formik's setFieldError. + */ +export function setServerError(form: AnyFormApi, field: string, message: string | undefined) { + form.setFieldMeta(field, prev => ({ + ...prev, + errorMap: { ...prev.errorMap, onServer: message }, + })); +} + +/** The field's error text once the user touched it, nothing before that or while it is valid */ +export function fieldError(field: AnyFieldApi): string | undefined { + return field.state.meta.isTouched ? fieldErrorMessage(field.state.meta.errors) : undefined; +} + +/** What the form element does on submit: keeps the browser out of it and lets TanStack validate and submit */ +export function submitHandler(form: AnyFormApi) { + return (event: FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }; +} + +/** + * A key for a form component whose defaults come from loaded data: a change + * remounts it with fresh defaults, which is what enableReinitialize did. + */ +export const defaultsKey = (...parts: unknown[]): string => JSON.stringify(parts); diff --git a/src/core/forms/useNestedFieldError.ts b/src/core/forms/useNestedFieldError.ts new file mode 100644 index 000000000..a3475d103 --- /dev/null +++ b/src/core/forms/useNestedFieldError.ts @@ -0,0 +1,18 @@ +import { AnyFieldApi, useSelector } from "@tanstack/react-form"; +import { fieldErrorMessage } from "@/core/forms/formUtils"; + +/** + * The first error of a field or of anything nested in it, e.g. the alias + * inside an alias list: the schema reports those on `aliases[0].alias`, + * which has no field of its own to show them. + */ +export function useNestedFieldError(field: AnyFieldApi): string | undefined { + const name: string = field.name; + + return useSelector(field.form.store, state => { + const errors = Object.entries(state.fieldMeta) + .filter(([key]) => key === name || key.startsWith(`${name}[`) || key.startsWith(`${name}.`)) + .flatMap(([, meta]) => meta?.errors ?? []); + return fieldErrorMessage(errors); + }); +} diff --git a/vite.config.ts b/vite.config.ts index 451cd139a..1101a3d5f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -100,7 +100,6 @@ export default defineConfig(({ mode }) => { '@emotion/react', '@emotion/styled', 'recharts', - 'formik', 'react-i18next', 'react-router-dom', '@tanstack/react-query',