diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index cad23d5c8f7..6f40970c104 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -87,7 +87,7 @@ export const GalleryEditPanel: React.FC = ({ const schema = yup.object({ title: titleRequired ? yup.string().required() : yup.string().ensure(), code: yup.string().ensure(), - urls: yupUniqueStringList("urls"), + urls: yupUniqueStringList(intl), date: yupDateString(intl), photographer: yup.string().ensure(), rating100: yup.number().integer().nullable().defined(), @@ -504,12 +504,7 @@ export const GalleryEditPanel: React.FC = ({ {renderInputField("title")} {renderInputField("code", "text", "scene_code")} - {renderURLListField( - "urls", - "validation.urls_must_be_unique", - onScrapeGalleryURL, - urlScrapable - )} + {renderURLListField("urls", onScrapeGalleryURL, urlScrapable)} {renderDateField("date")} {renderInputField("photographer")} diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index a362f8ca1e6..3a3daf70841 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -49,7 +49,7 @@ export const ImageEditPanel: React.FC = ({ const schema = yup.object({ title: yup.string().ensure(), code: yup.string().ensure(), - urls: yupUniqueStringList("urls"), + urls: yupUniqueStringList(intl), date: yupDateString(intl), details: yup.string().ensure(), photographer: yup.string().ensure(), @@ -258,7 +258,7 @@ export const ImageEditPanel: React.FC = ({ {renderInputField("title")} {renderInputField("code", "text", "scene_code")} - {renderURLListField("urls", "validation.urls_must_be_unique")} + {renderURLListField("urls")} {renderDateField("date")} {renderInputField("photographer")} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 52997dbbac1..1466e82f8a8 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -96,7 +96,7 @@ export const PerformerEditPanel: React.FC = ({ const schema = yup.object({ name: yup.string().required(), disambiguation: yup.string().ensure(), - alias_list: yupUniqueAliases("alias_list", "name"), + alias_list: yupUniqueAliases(intl, "name"), gender: yupInputEnum(GQL.GenderEnum).nullable().defined(), birthdate: yupDateString(intl), death_date: yupDateString(intl), @@ -755,11 +755,7 @@ export const PerformerEditPanel: React.FC = ({ {renderInputField("name")} {renderInputField("disambiguation")} - {renderStringListField( - "alias_list", - "validation.aliases_must_be_unique", - "aliases" - )} + {renderStringListField("alias_list", "aliases")} {renderSelectField("gender", stringGenderMap)} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 4bbc8b861e9..01b752d8734 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -112,7 +112,7 @@ export const SceneEditPanel: React.FC = ({ const schema = yup.object({ title: yup.string().ensure(), code: yup.string().ensure(), - urls: yupUniqueStringList("urls"), + urls: yupUniqueStringList(intl), date: yupDateString(intl), director: yup.string().ensure(), rating100: yup.number().integer().nullable().defined(), @@ -824,12 +824,7 @@ export const SceneEditPanel: React.FC = ({ {renderInputField("title")} {renderInputField("code", "text", "scene_code")} - {renderURLListField( - "urls", - "validation.urls_must_be_unique", - onScrapeSceneURL, - urlScrapable - )} + {renderURLListField("urls", onScrapeSceneURL, urlScrapable)} {renderDateField("date")} {renderInputField("director")} diff --git a/ui/v2.5/src/components/Shared/StringListInput.tsx b/ui/v2.5/src/components/Shared/StringListInput.tsx index 2efa1bd4413..768f282a042 100644 --- a/ui/v2.5/src/components/Shared/StringListInput.tsx +++ b/ui/v2.5/src/components/Shared/StringListInput.tsx @@ -53,12 +53,14 @@ export const StringListInput: React.FC = (props) => { const values = props.value.concat(""); function valueChanged(idx: number, value: string) { - const newValues = values - .map((v, i) => { - const ret = idx !== i ? v : value; - return ret; - }) - .filter((v, i) => i < values.length - 2 || v); + const newValues = props.value.slice(); + newValues[idx] = value; + + // if we cleared the last string, delete it from the array entirely + if (!value && idx === newValues.length - 1) { + newValues.splice(newValues.length - 1); + } + props.setValue(newValues); } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index fd67e5b5fd0..439479e5420 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -47,7 +47,7 @@ export const StudioEditPanel: React.FC = ({ url: yup.string().ensure(), details: yup.string().ensure(), parent_id: yup.string().required().nullable(), - aliases: yupUniqueAliases("aliases", "name"), + aliases: yupUniqueAliases(intl, "name"), ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), @@ -158,7 +158,7 @@ export const StudioEditPanel: React.FC = ({
{renderInputField("name")} - {renderStringListField("aliases", "validation.aliases_must_be_unique")} + {renderStringListField("aliases")} {renderInputField("url")} {renderInputField("details", "textarea")} {renderParentStudioField()} diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 5611b05a990..c2be0581fd0 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -43,7 +43,7 @@ export const TagEditPanel: React.FC = ({ const schema = yup.object({ name: yup.string().required(), - aliases: yupUniqueAliases("aliases", "name"), + aliases: yupUniqueAliases(intl, "name"), description: yup.string().ensure(), parent_ids: yup.array(yup.string().required()).defined(), child_ids: yup.array(yup.string().required()).defined(), @@ -186,7 +186,7 @@ export const TagEditPanel: React.FC = ({ {renderInputField("name")} - {renderStringListField("aliases", "validation.aliases_must_be_unique")} + {renderStringListField("aliases")} {renderInputField("description", "textarea")} {renderParentTagsField()} {renderSubTagsField()} diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 8271d9e7fd0..ec11409c314 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -1346,6 +1346,7 @@ dl.details-list { .invalid-feedback { display: block; + white-space: pre-wrap; &:empty { display: none; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index ea12e07a978..e550395c4ec 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1390,11 +1390,10 @@ "url": "URL", "urls": "URLs", "validation": { - "aliases_must_be_unique": "aliases must be unique", + "blank": "${path} must not be blank", "date_invalid_form": "${path} must be in YYYY-MM-DD form", "required": "${path} is a required field", - "unique": "${path} must be unique", - "urls_must_be_unique": "URLs must be unique" + "unique": "${path} must be unique" }, "videos": "Videos", "video_codec": "Video Codec", diff --git a/ui/v2.5/src/utils/form.tsx b/ui/v2.5/src/utils/form.tsx index da3a33bb82d..93b46bae4eb 100644 --- a/ui/v2.5/src/utils/form.tsx +++ b/ui/v2.5/src/utils/form.tsx @@ -64,10 +64,11 @@ export function formikUtils( }: IProps = {} ) { type Field = keyof V & string; + type ErrorMessage = string | undefined; function renderFormControl(field: Field, type: string, placeholder: string) { const formikProps = formik.getFieldProps({ name: field, type: type }); - const { error } = formik.getFieldMeta(field); + const error = formik.errors[field] as ErrorMessage; let { value } = formikProps; if (value === null) { @@ -181,7 +182,7 @@ export function formikUtils( props?: IProps ) { const value = formik.values[field] as string; - const { error } = formik.getFieldMeta(field); + const error = formik.errors[field] as ErrorMessage; const title = intl.formatMessage({ id: messageID }); const control = ( @@ -201,7 +202,7 @@ export function formikUtils( props?: IProps ) { const value = formik.values[field] as number | null; - const { error } = formik.getFieldMeta(field); + const error = formik.errors[field] as ErrorMessage; const title = intl.formatMessage({ id: messageID }); const control = ( @@ -233,24 +234,43 @@ export function formikUtils( return renderField(field, title, control, props); } + // flattens a potential list of errors into a [errorMsg, errorIdx] tuple + // error messages are joined with newlines, and duplicate messages are skipped + function flattenError( + error: ErrorMessage[] | ErrorMessage + ): [string | undefined, number[] | undefined] { + if (Array.isArray(error)) { + let errors: string[] = []; + const errorIdx = []; + for (let i = 0; i < error.length; i++) { + const err = error[i]; + if (err) { + if (!errors.includes(err)) { + errors.push(err); + } + errorIdx.push(i); + } + } + return [errors.join("\n"), errorIdx]; + } else { + return [error, undefined]; + } + } + function renderStringListField( field: Field, - errorMessageID: string, messageID: string = field, props?: IProps ) { - const formikProps = formik.getFieldProps(field); - const { error } = formik.getFieldMeta(field); + const value = formik.values[field] as string[]; + const error = formik.errors[field] as ErrorMessage[] | ErrorMessage; - const errorMsg = error - ? intl.formatMessage({ id: errorMessageID }) - : undefined; - const errorIdx = error?.split(" ").map((e) => parseInt(e)); + const [errorMsg, errorIdx] = flattenError(error); const title = intl.formatMessage({ id: messageID }); const control = ( formik.setFieldValue(field, v)} errors={errorMsg} errorIdx={errorIdx} @@ -262,19 +282,15 @@ export function formikUtils( function renderURLListField( field: Field, - errorMessageID: string, onScrapeClick?: (url: string) => void, urlScrapable?: (url: string) => boolean, messageID: string = field, props?: IProps ) { const value = formik.values[field] as string[]; - const { error } = formik.getFieldMeta(field); + const error = formik.errors[field] as ErrorMessage[] | ErrorMessage; - const errorMsg = error - ? intl.formatMessage({ id: errorMessageID }) - : undefined; - const errorIdx = error?.split(" ").map((e) => parseInt(e)); + const [errorMsg, errorIdx] = flattenError(error); const title = intl.formatMessage({ id: messageID }); const control = ( diff --git a/ui/v2.5/src/utils/yup.ts b/ui/v2.5/src/utils/yup.ts index c3183e39376..e2c4987a8cd 100644 --- a/ui/v2.5/src/utils/yup.ts +++ b/ui/v2.5/src/utils/yup.ts @@ -2,48 +2,131 @@ import { FormikErrors, yupToFormErrors } from "formik"; import { IntlShape } from "react-intl"; import * as yup from "yup"; -export function yupUniqueStringList(fieldName: string) { +// equivalent to yup.array(yup.string().required()) +// except that error messages will be e.g. +// 'urls must not be blank' instead of +// 'urls["0"] is a required field' +export function yupRequiredStringArray(intl: IntlShape) { return yup - .array(yup.string().required()) + .array( + // we enforce that each string in the array is "required" in the outer test function + // so cast to avoid having to add a redundant `.required()` here + yup.string() as yup.StringSchema + ) + .test({ + name: "blank", + test(value) { + if (!value || !value.length) return true; + + const blanks: number[] = []; + for (let i = 0; i < value.length; i++) { + const s = value[i]; + if (!s) { + blanks.push(i); + } + } + if (blanks.length === 0) return true; + + // each error message is identical + const msg = yup.ValidationError.formatError( + intl.formatMessage({ id: "validation.blank" }), + { + label: this.schema.spec.label, + path: this.path, + } + ); + + // return multiple errors, one for each blank string + const errors = blanks.map( + (i) => + new yup.ValidationError( + msg, + value[i], + // the path to this "sub-error": e.g. 'urls["0"]' + `${this.path}["${i}"]`, + "blank" + ) + ); + + return new yup.ValidationError(errors, value, this.path, "blank"); + }, + }); +} + +export function yupUniqueStringList(intl: IntlShape) { + return yupRequiredStringArray(intl) .defined() .test({ name: "unique", - test: (value) => { + test(value) { const values: string[] = []; const dupes: number[] = []; for (let i = 0; i < value.length; i++) { - const a = value[i]; - if (values.includes(a)) { + const s = value[i]; + if (values.includes(s)) { dupes.push(i); } else { - values.push(a); + values.push(s); } } if (dupes.length === 0) return true; - return new yup.ValidationError(dupes.join(" "), value, fieldName); + + const msg = yup.ValidationError.formatError( + intl.formatMessage({ id: "validation.unique" }), + { + label: this.schema.spec.label, + path: this.path, + } + ); + const errors = dupes.map( + (i) => + new yup.ValidationError( + msg, + value[i], + `${this.path}["${i}"]`, + "unique" + ) + ); + return new yup.ValidationError(errors, value, this.path, "unique"); }, }); } -export function yupUniqueAliases(fieldName: string, nameField: string) { - return yup - .array(yup.string().required()) +export function yupUniqueAliases(intl: IntlShape, nameField: string) { + return yupRequiredStringArray(intl) .defined() .test({ name: "unique", - test: (value, context) => { - const aliases = [context.parent[nameField].toLowerCase()]; + test(value) { + const aliases = [this.parent[nameField].toLowerCase()]; const dupes: number[] = []; for (let i = 0; i < value.length; i++) { - const a = value[i].toLowerCase(); - if (aliases.includes(a)) { + const s = value[i].toLowerCase(); + if (aliases.includes(s)) { dupes.push(i); } else { - aliases.push(a); + aliases.push(s); } } if (dupes.length === 0) return true; - return new yup.ValidationError(dupes.join(" "), value, fieldName); + + const msg = yup.ValidationError.formatError( + intl.formatMessage({ id: "validation.unique" }), + { + label: this.schema.spec.label, + path: this.path, + } + ); + const errors = dupes.map( + (i) => + new yup.ValidationError( + msg, + value[i], + `${this.path}["${i}"]`, + "unique" + ) + ); + return new yup.ValidationError(errors, value, this.path, "unique"); }, }); } @@ -54,7 +137,7 @@ export function yupDateString(intl: IntlShape) { .ensure() .test({ name: "date", - test: (value) => { + test(value) { if (!value) return true; if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false; if (Number.isNaN(Date.parse(value))) return false;