import AddIcon from "@mui/icons-material/Add"; import DeleteIcon from "@mui/icons-material/Delete"; 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 { FormQueryErrors } from "@/core/ui/Widgets/FormError"; import { BaseConfig, BaseConfigEntryForm, OPERATION_REPLACE, OPERATION_VALUES_SELECT, REQUIREMENTS_VALUES, STEP_VALUES_SELECT } 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 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: { configs: BaseConfig[], configsMax: BaseConfig[], type: ConfigType, slotEntryId: number, routineId: number, iterations: number[], forceInteger?: boolean; isWeeklyCycle: boolean; }) => { const { t } = useTranslation(); const [iterationsToDelete, setIterationsToDelete] = useState([]); const processEntriesQuery = useProcessConfigsQuery(props.routineId); const forceInteger = props.forceInteger ?? false; let apiPath: ApiPath; let apiPathMax: ApiPath; let title = ''; switch (props.type) { case "weight": apiPath = ApiPath.WEIGHT_CONFIG; apiPathMax = ApiPath.MAX_WEIGHT_CONFIG; title = t('weight'); break; case "reps": apiPath = ApiPath.REPETITIONS_CONFIG; apiPathMax = ApiPath.MAX_REPS_CONFIG; title = t('server.repetitions'); break; case "sets": apiPath = ApiPath.NR_OF_SETS_CONFIG; apiPathMax = ApiPath.MAX_NR_OF_SETS_CONFIG; title = t('routines.sets'); break; case "rest": apiPath = ApiPath.REST_CONFIG; apiPathMax = ApiPath.MAX_REST_CONFIG; title = t('routines.restTime'); break; case "rir": apiPath = ApiPath.RIR_CONFIG; apiPathMax = ApiPath.MAX_RIR_CONFIG; title = t('routines.rir'); 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 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('routines.operation')} {t('routines.step')} {t('routines.requirements')}
{ }}>
{t('routines.repeat')}
{ }}>
{({ 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, index) => ( {requirement}   ))}
{log.edited && }
))} )}
{processEntriesQuery.isError && }
)}
; };