From 0d59d5be0fb48ca6848efa1cb36cd516f35e68aa Mon Sep 17 00:00:00 2001 From: Hatim Dinia Date: Thu, 14 Mar 2024 10:29:59 +0100 Subject: [PATCH] feat(ui): enhance and refactor validation across UI components (#1956) --- webapp/public/locales/en/main.json | 8 + webapp/public/locales/fr/main.json | 8 + .../dialog/GroupFormDialog/GroupForm.tsx | 31 +-- .../Users/dialog/UserFormDialog/UserForm.tsx | 49 +++-- .../InformationView/CreateVariantDialog.tsx | 12 +- .../App/Singlestudy/PropertiesDialog.tsx | 3 +- .../BindingConstraints/AddDialog.tsx | 11 +- .../Modelization/Map/CreateAreaDialog.tsx | 15 +- .../Districts/CreateDistrictDialog.tsx | 17 +- .../MapConfig/Layers/CreateLayerDialog.tsx | 14 +- .../MapConfig/Layers/UpdateLayerDialog.tsx | 31 +-- .../explore/Modelization/Map/index.tsx | 8 +- .../dialogs/TableTemplateFormDialog.tsx | 26 ++- .../Candidates/CreateCandidateDialog.tsx | 20 +- .../explore/Xpansion/Candidates/index.tsx | 1 + .../common/GroupedDataTable/CreateDialog.tsx | 16 +- .../GroupedDataTable/DuplicateDialog.tsx | 16 +- webapp/src/utils/validationUtils.ts | 182 ++++++++++++++++++ 18 files changed, 346 insertions(+), 122 deletions(-) create mode 100644 webapp/src/utils/validationUtils.ts diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index b64b0d86bb..ea870f13ac 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -118,6 +118,12 @@ "form.field.maxValue": "The maximum value is {{0}}", "form.field.notAllowedValue": "Not allowed value", "form.field.specialChars": "Special characters allowed: {{0}}", + "form.field.specialCharsNotAllowed": "Special characters are not allowed", + "form.field.spacesNotAllowed": "Spaces are not allowed", + "form.field.requireLowercase": "Must contain at least one lowercase letter.", + "form.field.requireUppercase": "Must contain at least one uppercase letter.", + "form.field.requireDigit": "Must contain at least one digit.", + "form.field.requireSpecialChars": "Must contain at least one special character.", "matrix.graphSelector": "Columns", "matrix.message.importHint": "Click or drag and drop a matrix here", "matrix.importNewMatrix": "Import a new matrix", @@ -186,6 +192,8 @@ "settings.error.groupRolesSave": "Role(s) for group '{{0}}' not saved", "settings.error.tokenSave": "'{{0}}' token not saved", "settings.error.updateMaintenance": "Maintenance mode not updated", + "settings.user.form.confirmPassword":"Confirm password", + "settings.user.form.error.passwordMismatch": "Passwords do not match", "launcher.additionalModes": "Additional modes", "launcher.autoUnzip": "Automatically unzip", "launcher.xpress": "Xpress (>= 8.3)", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index cf8577422e..6fb34f913f 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -118,6 +118,12 @@ "form.field.maxValue": "La valeur maximum est {{0}}", "form.field.notAllowedValue": "Valeur non autorisée", "form.field.specialChars": "Caractères spéciaux autorisés: {{0}}", + "form.field.specialCharsNotAllowed": "Les caractères spéciaux ne sont pas autorisés", + "form.field.spacesNotAllowed": "Les espaces ne sont pas autorisés", + "form.field.requireLowercase": "Doit contenir au moins une lettre minuscule.", + "form.field.requireUppercase": "Doit contenir au moins une lettre majuscule.", + "form.field.requireDigit": "Doit contenir au moins un chiffre.", + "form.field.requireSpecialChars": "Doit contenir au moins un caractère spécial.", "matrix.graphSelector": "Colonnes", "matrix.message.importHint": "Cliquer ou glisser une matrice ici", "matrix.importNewMatrix": "Import d'une nouvelle matrice", @@ -186,6 +192,8 @@ "settings.error.groupRolesSave": "Role(s) pour le groupe '{{0}}' non sauvegardé", "settings.error.tokenSave": "Token '{{0}}' non sauvegardé", "settings.error.updateMaintenance": "Erreur lors du changement du status de maintenance", + "settings.user.form.confirmPassword": "Confirmer le mot de passe", + "settings.user.form.error.passwordMismatch": "Les mots de passe ne correspondent pas", "launcher.additionalModes": "Mode additionnels", "launcher.autoUnzip": "Dézippage automatique", "launcher.xpress": "Xpress (>= 8.3)", diff --git a/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx b/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx index 6a360e2d5e..e17dee147b 100644 --- a/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx +++ b/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx @@ -31,10 +31,11 @@ import { import { RoleType, UserDTO } from "../../../../../../common/types"; import { roleToString, sortByName } from "../../../../../../services/utils"; import usePromise from "../../../../../../hooks/usePromise"; -import { getUsers } from "../../../../../../services/api/user"; +import { getGroups, getUsers } from "../../../../../../services/api/user"; import { getAuthUser } from "../../../../../../redux/selectors"; import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { UseFormReturnPlus } from "../../../../../common/Form/types"; +import { validateString } from "../../../../../../utils/validationUtils"; function GroupForm(props: UseFormReturnPlus) { const { @@ -44,15 +45,23 @@ function GroupForm(props: UseFormReturnPlus) { formState: { errors, defaultValues }, } = props; + const { t } = useTranslation(); + const authUser = useAppSelector(getAuthUser); const userLabelId = useRef(uuidv4()).current; + const [selectedUser, setSelectedUser] = useState(); + const { data: users, isLoading: isUsersLoading } = usePromise(getUsers); + const { data: groups } = usePromise(getGroups); + + const existingGroups = useMemo( + () => groups?.map((group) => group.name), + [groups], + ); + const { fields, append, remove } = useFieldArray({ control, name: "permissions", }); - const [selectedUser, setSelectedUser] = useState(); - const { data: users, isLoading: isUsersLoading } = usePromise(getUsers); - const { t } = useTranslation(); - const authUser = useAppSelector(getAuthUser); + const allowToAddPermission = selectedUser && !getValues("permissions").some( @@ -63,6 +72,7 @@ function GroupForm(props: UseFormReturnPlus) { if (!users) { return []; } + return sortByName( users.filter( (user) => @@ -101,12 +111,11 @@ function GroupForm(props: UseFormReturnPlus) { } fullWidth {...register("name", { - required: t("form.field.required") as string, - validate: (value) => { - if (RESERVED_GROUP_NAMES.includes(value)) { - return t("form.field.notAllowedValue") as string; - } - }, + validate: (v) => + validateString(v, { + existingValues: existingGroups, + excludedValues: RESERVED_GROUP_NAMES, + }), })} /> {/* Permissions */} diff --git a/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx b/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx index 43d7f915ef..59acfc6dc5 100644 --- a/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx +++ b/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx @@ -31,16 +31,18 @@ import { import { GroupDTO, RoleType } from "../../../../../../common/types"; import { roleToString, sortByName } from "../../../../../../services/utils"; import usePromise from "../../../../../../hooks/usePromise"; -import { getGroups } from "../../../../../../services/api/user"; +import { getGroups, getUsers } from "../../../../../../services/api/user"; import { UserFormDialogProps } from "."; import { UseFormReturnPlus } from "../../../../../common/Form/types"; +import { + validatePassword, + validateString, +} from "../../../../../../utils/validationUtils"; interface Props extends UseFormReturnPlus { onlyPermissions?: UserFormDialogProps["onlyPermissions"]; } -const PASSWORD_MIN_LENGTH = 8; - function UserForm(props: Props) { const { control, @@ -50,14 +52,19 @@ function UserForm(props: Props) { onlyPermissions, } = props; + const { t } = useTranslation(); const groupLabelId = useRef(uuidv4()).current; + const [selectedGroup, setSelectedGroup] = useState(); + const { data: groups, isLoading: isGroupsLoading } = usePromise(getGroups); + const { data: users } = usePromise(getUsers); + + const existingUsers = useMemo(() => users?.map(({ name }) => name), [users]); + const { fields, append, remove } = useFieldArray({ control, name: "permissions", }); - const [selectedGroup, setSelectedGroup] = useState(); - const { data: groups, isLoading: isGroupsLoading } = usePromise(getGroups); - const { t } = useTranslation(); + const commonTextFieldProps = { required: true, sx: { mx: 0 }, @@ -104,12 +111,11 @@ function UserForm(props: Props) { helperText={errors.username?.message?.toString()} {...commonTextFieldProps} {...register("username", { - required: t("form.field.required") as string, - validate: (value) => { - if (RESERVED_USER_NAMES.includes(value)) { - return t("form.field.notAllowedValue") as string; - } - }, + validate: (v) => + validateString(v, { + existingValues: existingUsers, + excludedValues: RESERVED_USER_NAMES, + }), })} /> validatePassword(v), + })} + /> + + v === getValues("password") || + t("settings.user.form.error.passwordMismatch"), })} /> diff --git a/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx b/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx index 68d570a302..5e7c1213f7 100644 --- a/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx +++ b/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import AddCircleIcon from "@mui/icons-material/AddCircle"; @@ -10,6 +10,7 @@ import StringFE from "../../../../common/fieldEditors/StringFE"; import Fieldset from "../../../../common/Fieldset"; import SelectFE from "../../../../common/fieldEditors/SelectFE"; import { SubmitHandlerPlus } from "../../../../common/Form/types"; +import { validateString } from "../../../../../utils/validationUtils"; interface Props { parentId: string; @@ -25,6 +26,11 @@ function CreateVariantDialog(props: Props) { const [sourceList, setSourceList] = useState([]); const defaultValues = { name: "", sourceId: parentId }; + const existingVariants = useMemo( + () => sourceList.map((variant) => variant.name), + [sourceList], + ); + useEffect(() => { setSourceList(createListFromTree(tree)); }, [tree]); @@ -67,8 +73,8 @@ function CreateVariantDialog(props: Props) { name="name" control={control} rules={{ - required: true, - validate: (val) => val.trim().length > 0, + validate: (v) => + validateString(v, { existingValues: existingVariants }), }} /> val.trim().length > 0 }} + rules={{ validate: (v) => validateString(v) }} sx={{ mx: 0 }} fullWidth /> diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx index 06056b3b4e..21e174ff96 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx @@ -14,6 +14,7 @@ import SelectFE from "../../../../../common/fieldEditors/SelectFE"; import StringFE from "../../../../../common/fieldEditors/StringFE"; import SwitchFE from "../../../../../common/fieldEditors/SwitchFE"; import { StudyMetadata } from "../../../../../../common/types"; +import { validateString } from "../../../../../../utils/validationUtils"; interface Props { studyId: StudyMetadata["id"]; @@ -102,14 +103,8 @@ function AddDialog({ studyId, existingConstraints, open, onClose }: Props) { label={t("global.name")} control={control} rules={{ - validate: (v) => { - if (v.trim().length <= 0) { - return t("form.field.required"); - } - if (existingConstraints.includes(v.trim().toLowerCase())) { - return t("form.field.duplicate", { 0: v }); - } - }, + validate: (v) => + validateString(v, { existingValues: existingConstraints }), }} /> void; createArea: (name: string) => void; } function CreateAreaDialog(props: Props) { - const { open, onClose, createArea } = props; + const { studyId, open, onClose, createArea } = props; const [t] = useTranslation(); + const existingAreas = useAppSelector((state) => + getAreas(state, studyId).map((area) => area.name), + ); const defaultValues = { name: "", @@ -23,7 +30,7 @@ function CreateAreaDialog(props: Props) { //////////////////////////////////////////////////////////////// const handleSubmit = (data: SubmitHandlerPlus) => { - createArea(data.values.name); + return createArea(data.values.name.trim()); }; //////////////////////////////////////////////////////////////// @@ -48,8 +55,8 @@ function CreateAreaDialog(props: Props) { control={control} fullWidth rules={{ - required: true, - validate: (val) => val.trim().length > 0, + validate: (v) => + validateString(v, { existingValues: existingAreas }), }} /> )} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx index e6ddb89687..f8b4191ac2 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx @@ -12,6 +12,7 @@ import useAppDispatch from "../../../../../../../../redux/hooks/useAppDispatch"; import { createStudyMapDistrict } from "../../../../../../../../redux/ducks/studyMaps"; import useAppSelector from "../../../../../../../../redux/hooks/useAppSelector"; import { getStudyMapDistrictsById } from "../../../../../../../../redux/selectors"; +import { validateString } from "../../../../../../../../utils/validationUtils"; interface Props { open: boolean; @@ -32,10 +33,7 @@ function CreateDistrictDialog(props: Props) { const districtsById = useAppSelector(getStudyMapDistrictsById); const existingDistricts = useMemo( - () => - Object.values(districtsById).map((district) => - district.name.toLowerCase(), - ), + () => Object.values(districtsById).map(({ name }) => name), [districtsById], ); @@ -81,15 +79,8 @@ function CreateDistrictDialog(props: Props) { control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - if (v.trim().length <= 0) { - return false; - } - if (existingDistricts.includes(v.toLowerCase())) { - return `The District "${v}" already exists`; - } - }, + validate: (v) => + validateString(v, { existingValues: existingDistricts }), }} sx={{ m: 0 }} /> diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx index 48071a7b57..46c01c2b45 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx @@ -12,6 +12,7 @@ import useAppDispatch from "../../../../../../../../redux/hooks/useAppDispatch"; import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; import useAppSelector from "../../../../../../../../redux/hooks/useAppSelector"; import { getStudyMapLayersById } from "../../../../../../../../redux/selectors"; +import { validateString } from "../../../../../../../../utils/validationUtils"; interface Props { open: boolean; @@ -31,7 +32,7 @@ function CreateLayerDialog(props: Props) { const layersById = useAppSelector(getStudyMapLayersById); const existingLayers = useMemo( - () => Object.values(layersById).map((layer) => layer.name.toLowerCase()), + () => Object.values(layersById).map(({ name }) => name), [layersById], ); @@ -73,15 +74,8 @@ function CreateLayerDialog(props: Props) { control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - if (v.trim().length <= 0) { - return false; - } - if (existingLayers.includes(v.toLowerCase())) { - return `The layer "${v}" already exists`; - } - }, + validate: (v) => + validateString(v, { existingValues: existingLayers }), }} /> )} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx index 1160b309bc..aa1c97b4eb 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx @@ -17,6 +17,7 @@ import { updateStudyMapLayer, } from "../../../../../../../../redux/ducks/studyMaps"; import useAppDispatch from "../../../../../../../../redux/hooks/useAppDispatch"; +import { validateString } from "../../../../../../../../utils/validationUtils"; interface Props { open: boolean; @@ -44,7 +45,7 @@ function UpdateLayerDialog(props: Props) { })); const existingLayers = useMemo( - () => Object.values(layersById).map((layer) => layer.name.toLowerCase()), + () => Object.values(layersById).map(({ name }) => name), [layersById], ); @@ -56,6 +57,7 @@ function UpdateLayerDialog(props: Props) { data: SubmitHandlerPlus, ) => { const { layerId, name } = data.values; + if (layerId && name) { return dispatch(updateStudyMapLayer({ studyId: study.id, layerId, name })) .unwrap() @@ -67,6 +69,7 @@ function UpdateLayerDialog(props: Props) { if (layerId) { dispatch(deleteStudyMapLayer({ studyId: study.id, layerId })); } + setOpenConfirmationModal(false); onClose(); }; @@ -86,7 +89,7 @@ function UpdateLayerDialog(props: Props) { defaultValues, }} > - {({ control, setValue, getValues }) => ( + {({ control, getValues, reset }) => (
- setValue("name", layersById[String(e.target.value)].name) - } + onChange={({ target: { value } }) => { + reset({ + layerId: value as string, + name: layersById[value as string].name, + }); + }} /> { - if (v.trim().length <= 0) { - return false; - } - if (existingLayers.includes(v.toLowerCase())) { - return `The Layer "${v}" already exists`; - } - }, + validate: (v) => + validateString(v, { + existingValues: existingLayers, + // Excludes the current layer's original name to allow edits without false duplicates. + editedValue: layersById[getValues("layerId")].name, + }), }} disabled={getValues("layerId") === ""} sx={{ mx: 0 }} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx index 4ca4ccdf1c..db03961ef4 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx @@ -116,10 +116,13 @@ function Map() { //////////////////////////////////////////////////////////////// const handleCreateArea = async (name: string) => { - setOpenDialog(false); try { if (study) { - dispatch(createStudyMapNode({ studyId: study.id, name })); + return dispatch(createStudyMapNode({ studyId: study.id, name })) + .unwrap() + .then(() => { + setOpenDialog(false); + }); } } catch (e) { enqueueErrorSnackbar(t("study.error.createArea"), e as AxiosError); @@ -206,6 +209,7 @@ function Map() { /> {openDialog && ( templates.map(({ name }) => name), + [templates], + ); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + return ( { - const id = getValues("id"); - const hasDuplicate = templates.find( - (tp) => tp.id !== id && tp.name.trim() === value.trim(), - ); - if (hasDuplicate) { - return t("form.field.notAllowedValue") as string; - } - }, - required: true, + validate: (v) => + validateString(v, { + existingValues: existingTables, + editedValue: config?.defaultValues?.name, + }), }} /> void; onSave: (candidate: XpansionCandidate) => void; + candidates: XpansionCandidate[]; } function CreateCandidateDialog(props: PropType) { - const { open, links, onClose, onSave } = props; + const { open, links, onClose, onSave, candidates } = props; const [t] = useTranslation(); const [isToggled, setToggle] = useState(true); + const existingCandidates = useMemo( + () => candidates.map(({ name }) => name), + [candidates], + ); + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// @@ -70,7 +77,14 @@ function CreateCandidateDialog(props: PropType) { label={t("global.name")} name="name" control={control} - rules={{ required: t("form.field.required") }} + rules={{ + validate: (v) => + validateString(v, { + existingValues: existingCandidates, + allowSpaces: false, + specialChars: "&_*", + }), + }} sx={{ mx: 0 }} /> )} {!!capacityViewDialog && ( diff --git a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx index d85cd669fd..5c8313a352 100644 --- a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx +++ b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx @@ -7,6 +7,7 @@ import { SubmitHandlerPlus } from "../Form/types"; import SelectFE from "../fieldEditors/SelectFE"; import { nameToId } from "../../../services/utils"; import { TRow } from "./utils"; +import { validateString } from "../../../utils/validationUtils"; interface Props { open: boolean; @@ -65,19 +66,8 @@ function CreateDialog({ control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - const regex = /^[a-zA-Z0-9_\-() &]+$/; - if (!regex.test(v.trim())) { - return t("form.field.specialChars", { 0: "&()_-" }); - } - if (v.trim().length <= 0) { - return t("form.field.required"); - } - if (existingNames.includes(v.trim().toLowerCase())) { - return t("form.field.duplicate", { 0: v }); - } - }, + validate: (v) => + validateString(v, { existingValues: existingNames }), }} sx={{ m: 0 }} /> diff --git a/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx b/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx index 93daa1a3bc..34664b8f3a 100644 --- a/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx +++ b/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx @@ -4,6 +4,7 @@ import Fieldset from "../Fieldset"; import FormDialog from "../dialogs/FormDialog"; import { SubmitHandlerPlus } from "../Form/types"; import StringFE from "../fieldEditors/StringFE"; +import { validateString } from "../../../utils/validationUtils"; interface Props { open: boolean; @@ -51,19 +52,8 @@ function DuplicateDialog(props: Props) { control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - const regex = /^[a-zA-Z0-9_\-() &]+$/; - if (!regex.test(v.trim())) { - return t("form.field.specialChars", { 0: "&()_-" }); - } - if (v.trim().length <= 0) { - return t("form.field.required"); - } - if (existingNames.includes(v.trim().toLowerCase())) { - return t("form.field.duplicate", { 0: v }); - } - }, + validate: (v) => + validateString(v, { existingValues: existingNames }), }} sx={{ m: 0 }} /> diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts new file mode 100644 index 0000000000..94f1f95c30 --- /dev/null +++ b/webapp/src/utils/validationUtils.ts @@ -0,0 +1,182 @@ +import { t } from "i18next"; + +//////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////// + +interface ValidationOptions { + existingValues?: string[]; + excludedValues?: string[]; + isCaseSensitive?: boolean; + allowSpecialChars?: boolean; + specialChars?: string; + allowSpaces?: boolean; + editedValue?: string; + min?: number; + max?: number; +} + +//////////////////////////////////////////////////////////////// +// Validators +//////////////////////////////////////////////////////////////// + +/** + * Validates a single string value against specified criteria. + * + * Validates the input string against a variety of checks including length restrictions, + * character validations, and uniqueness against provided arrays of existing and excluded values. + * + * @param value - The string to validate. Leading and trailing spaces will be trimmed. + * @param options - Configuration options for validation. (Optional) + * @param [options.existingValues=[]] - An array of strings to check against for duplicates. Comparison is case-insensitive by default. + * @param [options.excludedValues=[]] - An array of strings that the value should not match. + * @param [options.isCaseSensitive=false] - Whether the comparison with `existingValues` and `excludedValues` is case-sensitive. Defaults to false. + * @param [options.allowSpecialChars=true] - Flags if special characters are permitted in the value. + * @param [options.specialChars="&()_-"] - A string representing additional allowed characters outside the typical alphanumeric scope. + * @param [options.allowSpaces=true] - Flags if spaces are allowed in the value. + * @param [options.editedValue=""] - The current value being edited, to exclude it from duplicate checks. + * @param [options.min=0] - Minimum length required for the string. Defaults to 0. + * @param [options.max=255] - Maximum allowed length for the string. Defaults to 255. + * @returns True if validation is successful, or a localized error message if it fails. + */ +export function validateString( + value: string, + options?: ValidationOptions, +): string | true { + const { + existingValues = [], + excludedValues = [], + isCaseSensitive = false, + allowSpecialChars = true, + allowSpaces = true, + specialChars = "&()_-", + editedValue = "", + min = 0, + max = 255, + } = options || {}; + + const trimmedValue = value.trim(); + + if (!trimmedValue) { + return t("form.field.required"); + } + + if (!allowSpaces && trimmedValue.includes(" ")) { + return t("form.field.spacesNotAllowed"); + } + + if (trimmedValue.length < min) { + return t("form.field.minValue", { 0: min }); + } + + if (trimmedValue.length > max) { + return t("form.field.maxValue", { 0: max }); + } + + // Compiles a regex pattern based on allowed characters and flags. + const specialCharsPattern = new RegExp( + generatePattern(allowSpaces, allowSpecialChars, specialChars), + ); + + // Validates the string against the allowed characters regex. + if (!specialCharsPattern.test(trimmedValue)) { + return specialChars === "" || !allowSpecialChars + ? t("form.field.specialCharsNotAllowed") + : t("form.field.specialChars", { 0: specialChars }); + } + + // Normalize the value for comparison, based on case sensitivity option. + const normalize = (v: string) => + isCaseSensitive ? v.trim() : v.toLowerCase().trim(); + + // Prepare the value for duplicate and exclusion checks. + const comparisonValue = normalize(trimmedValue); + + // Some forms requires to keep the original value while updating other fields. + if (normalize(editedValue) === comparisonValue) { + return true; + } + + // Check for duplication against existing values. + if (existingValues.map(normalize).includes(comparisonValue)) { + return t("form.field.duplicate", { 0: value }); + } + + // Check for inclusion in the list of excluded values. + if (excludedValues.map(normalize).includes(comparisonValue)) { + return t("form.field.notAllowedValue", { 0: value }); + } + + return true; +} + +/** + * Validates a password string for strong security criteria. + * + * @param password - The password to validate. + * @returns True if validation is successful, or a localized error message if it fails. + */ +export function validatePassword(password: string): string | true { + const trimmedPassword = password.trim(); + + if (!trimmedPassword) { + return t("form.field.required"); + } + + if (trimmedPassword.length < 8) { + return t("form.field.minValue", { 0: 8 }); + } + + if (trimmedPassword.length > 50) { + return t("form.field.maxValue", { 0: 50 }); + } + + if (!/[a-z]/.test(trimmedPassword)) { + return t("form.field.requireLowercase"); + } + + if (!/[A-Z]/.test(trimmedPassword)) { + return t("form.field.requireUppercase"); + } + + if (!/\d/.test(trimmedPassword)) { + return t("form.field.requireDigit"); + } + + if (!/[^\w\s]/.test(trimmedPassword)) { + return t("form.field.requireSpecialChars"); + } + + return true; +} + +//////////////////////////////////////////////////////////////// +// Utils +//////////////////////////////////////////////////////////////// + +// Escape special characters in specialChars +function escapeSpecialChars(chars: string) { + return chars.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); +} + +/** + * Generates a regular expression pattern for string validation based on specified criteria. + * This pattern includes considerations for allowing spaces, special characters, and any additional + * characters specified in `specialChars`. + * + * @param allowSpaces - Indicates if spaces are permitted in the string. + * @param allowSpecialChars - Indicates if special characters are permitted. + * @param specialChars - Specifies additional characters to allow in the string. + * @returns The regular expression pattern as a string. + */ +function generatePattern( + allowSpaces: boolean, + allowSpecialChars: boolean, + specialChars: string, +): string { + const basePattern = "^[a-zA-Z0-9"; + const spacePattern = allowSpaces ? " " : ""; + const specialCharsPattern = + allowSpecialChars && specialChars ? escapeSpecialChars(specialChars) : ""; + return basePattern + spacePattern + specialCharsPattern + "]*$"; +}