Fix crash on blank aliases/urls (#4344)

* Fix crash on blank alias/url
* Fix StringListInput clear issue
This commit is contained in:
DingDongSoLong4 2023-12-12 02:28:00 +02:00 committed by GitHub
parent eca5838ce0
commit d37de0e49b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 163 additions and 76 deletions

View file

@ -87,7 +87,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
const schema = yup.object({ const schema = yup.object({
title: titleRequired ? yup.string().required() : yup.string().ensure(), title: titleRequired ? yup.string().required() : yup.string().ensure(),
code: yup.string().ensure(), code: yup.string().ensure(),
urls: yupUniqueStringList("urls"), urls: yupUniqueStringList(intl),
date: yupDateString(intl), date: yupDateString(intl),
photographer: yup.string().ensure(), photographer: yup.string().ensure(),
rating100: yup.number().integer().nullable().defined(), rating100: yup.number().integer().nullable().defined(),
@ -504,12 +504,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
{renderInputField("title")} {renderInputField("title")}
{renderInputField("code", "text", "scene_code")} {renderInputField("code", "text", "scene_code")}
{renderURLListField( {renderURLListField("urls", onScrapeGalleryURL, urlScrapable)}
"urls",
"validation.urls_must_be_unique",
onScrapeGalleryURL,
urlScrapable
)}
{renderDateField("date")} {renderDateField("date")}
{renderInputField("photographer")} {renderInputField("photographer")}

View file

@ -49,7 +49,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
const schema = yup.object({ const schema = yup.object({
title: yup.string().ensure(), title: yup.string().ensure(),
code: yup.string().ensure(), code: yup.string().ensure(),
urls: yupUniqueStringList("urls"), urls: yupUniqueStringList(intl),
date: yupDateString(intl), date: yupDateString(intl),
details: yup.string().ensure(), details: yup.string().ensure(),
photographer: yup.string().ensure(), photographer: yup.string().ensure(),
@ -258,7 +258,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
{renderInputField("title")} {renderInputField("title")}
{renderInputField("code", "text", "scene_code")} {renderInputField("code", "text", "scene_code")}
{renderURLListField("urls", "validation.urls_must_be_unique")} {renderURLListField("urls")}
{renderDateField("date")} {renderDateField("date")}
{renderInputField("photographer")} {renderInputField("photographer")}

View file

@ -96,7 +96,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
disambiguation: yup.string().ensure(), disambiguation: yup.string().ensure(),
alias_list: yupUniqueAliases("alias_list", "name"), alias_list: yupUniqueAliases(intl, "name"),
gender: yupInputEnum(GQL.GenderEnum).nullable().defined(), gender: yupInputEnum(GQL.GenderEnum).nullable().defined(),
birthdate: yupDateString(intl), birthdate: yupDateString(intl),
death_date: yupDateString(intl), death_date: yupDateString(intl),
@ -755,11 +755,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{renderInputField("name")} {renderInputField("name")}
{renderInputField("disambiguation")} {renderInputField("disambiguation")}
{renderStringListField( {renderStringListField("alias_list", "aliases")}
"alias_list",
"validation.aliases_must_be_unique",
"aliases"
)}
{renderSelectField("gender", stringGenderMap)} {renderSelectField("gender", stringGenderMap)}

View file

@ -112,7 +112,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
const schema = yup.object({ const schema = yup.object({
title: yup.string().ensure(), title: yup.string().ensure(),
code: yup.string().ensure(), code: yup.string().ensure(),
urls: yupUniqueStringList("urls"), urls: yupUniqueStringList(intl),
date: yupDateString(intl), date: yupDateString(intl),
director: yup.string().ensure(), director: yup.string().ensure(),
rating100: yup.number().integer().nullable().defined(), rating100: yup.number().integer().nullable().defined(),
@ -824,12 +824,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
{renderInputField("title")} {renderInputField("title")}
{renderInputField("code", "text", "scene_code")} {renderInputField("code", "text", "scene_code")}
{renderURLListField( {renderURLListField("urls", onScrapeSceneURL, urlScrapable)}
"urls",
"validation.urls_must_be_unique",
onScrapeSceneURL,
urlScrapable
)}
{renderDateField("date")} {renderDateField("date")}
{renderInputField("director")} {renderInputField("director")}

View file

@ -53,12 +53,14 @@ export const StringListInput: React.FC<IStringListInputProps> = (props) => {
const values = props.value.concat(""); const values = props.value.concat("");
function valueChanged(idx: number, value: string) { function valueChanged(idx: number, value: string) {
const newValues = values const newValues = props.value.slice();
.map((v, i) => { newValues[idx] = value;
const ret = idx !== i ? v : value;
return ret; // if we cleared the last string, delete it from the array entirely
}) if (!value && idx === newValues.length - 1) {
.filter((v, i) => i < values.length - 2 || v); newValues.splice(newValues.length - 1);
}
props.setValue(newValues); props.setValue(newValues);
} }

View file

@ -47,7 +47,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
url: yup.string().ensure(), url: yup.string().ensure(),
details: yup.string().ensure(), details: yup.string().ensure(),
parent_id: yup.string().required().nullable(), parent_id: yup.string().required().nullable(),
aliases: yupUniqueAliases("aliases", "name"), aliases: yupUniqueAliases(intl, "name"),
ignore_auto_tag: yup.boolean().defined(), ignore_auto_tag: yup.boolean().defined(),
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(), stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
image: yup.string().nullable().optional(), image: yup.string().nullable().optional(),
@ -158,7 +158,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
<Form noValidate onSubmit={formik.handleSubmit} id="studio-edit"> <Form noValidate onSubmit={formik.handleSubmit} id="studio-edit">
{renderInputField("name")} {renderInputField("name")}
{renderStringListField("aliases", "validation.aliases_must_be_unique")} {renderStringListField("aliases")}
{renderInputField("url")} {renderInputField("url")}
{renderInputField("details", "textarea")} {renderInputField("details", "textarea")}
{renderParentStudioField()} {renderParentStudioField()}

View file

@ -43,7 +43,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
aliases: yupUniqueAliases("aliases", "name"), aliases: yupUniqueAliases(intl, "name"),
description: yup.string().ensure(), description: yup.string().ensure(),
parent_ids: yup.array(yup.string().required()).defined(), parent_ids: yup.array(yup.string().required()).defined(),
child_ids: yup.array(yup.string().required()).defined(), child_ids: yup.array(yup.string().required()).defined(),
@ -186,7 +186,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
<Form noValidate onSubmit={formik.handleSubmit} id="tag-edit"> <Form noValidate onSubmit={formik.handleSubmit} id="tag-edit">
{renderInputField("name")} {renderInputField("name")}
{renderStringListField("aliases", "validation.aliases_must_be_unique")} {renderStringListField("aliases")}
{renderInputField("description", "textarea")} {renderInputField("description", "textarea")}
{renderParentTagsField()} {renderParentTagsField()}
{renderSubTagsField()} {renderSubTagsField()}

View file

@ -1346,6 +1346,7 @@ dl.details-list {
.invalid-feedback { .invalid-feedback {
display: block; display: block;
white-space: pre-wrap;
&:empty { &:empty {
display: none; display: none;

View file

@ -1390,11 +1390,10 @@
"url": "URL", "url": "URL",
"urls": "URLs", "urls": "URLs",
"validation": { "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", "date_invalid_form": "${path} must be in YYYY-MM-DD form",
"required": "${path} is a required field", "required": "${path} is a required field",
"unique": "${path} must be unique", "unique": "${path} must be unique"
"urls_must_be_unique": "URLs must be unique"
}, },
"videos": "Videos", "videos": "Videos",
"video_codec": "Video Codec", "video_codec": "Video Codec",

View file

@ -64,10 +64,11 @@ export function formikUtils<V extends FormikValues>(
}: IProps = {} }: IProps = {}
) { ) {
type Field = keyof V & string; type Field = keyof V & string;
type ErrorMessage = string | undefined;
function renderFormControl(field: Field, type: string, placeholder: string) { function renderFormControl(field: Field, type: string, placeholder: string) {
const formikProps = formik.getFieldProps({ name: field, type: type }); const formikProps = formik.getFieldProps({ name: field, type: type });
const { error } = formik.getFieldMeta(field); const error = formik.errors[field] as ErrorMessage;
let { value } = formikProps; let { value } = formikProps;
if (value === null) { if (value === null) {
@ -181,7 +182,7 @@ export function formikUtils<V extends FormikValues>(
props?: IProps props?: IProps
) { ) {
const value = formik.values[field] as string; 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 title = intl.formatMessage({ id: messageID });
const control = ( const control = (
@ -201,7 +202,7 @@ export function formikUtils<V extends FormikValues>(
props?: IProps props?: IProps
) { ) {
const value = formik.values[field] as number | null; 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 title = intl.formatMessage({ id: messageID });
const control = ( const control = (
@ -233,24 +234,43 @@ export function formikUtils<V extends FormikValues>(
return renderField(field, title, control, props); 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( function renderStringListField(
field: Field, field: Field,
errorMessageID: string,
messageID: string = field, messageID: string = field,
props?: IProps props?: IProps
) { ) {
const formikProps = formik.getFieldProps(field); const value = formik.values[field] as string[];
const { error } = formik.getFieldMeta(field); const error = formik.errors[field] as ErrorMessage[] | ErrorMessage;
const errorMsg = error const [errorMsg, errorIdx] = flattenError(error);
? intl.formatMessage({ id: errorMessageID })
: undefined;
const errorIdx = error?.split(" ").map((e) => parseInt(e));
const title = intl.formatMessage({ id: messageID }); const title = intl.formatMessage({ id: messageID });
const control = ( const control = (
<StringListInput <StringListInput
value={formikProps.value ?? []} value={value}
setValue={(v) => formik.setFieldValue(field, v)} setValue={(v) => formik.setFieldValue(field, v)}
errors={errorMsg} errors={errorMsg}
errorIdx={errorIdx} errorIdx={errorIdx}
@ -262,19 +282,15 @@ export function formikUtils<V extends FormikValues>(
function renderURLListField( function renderURLListField(
field: Field, field: Field,
errorMessageID: string,
onScrapeClick?: (url: string) => void, onScrapeClick?: (url: string) => void,
urlScrapable?: (url: string) => boolean, urlScrapable?: (url: string) => boolean,
messageID: string = field, messageID: string = field,
props?: IProps props?: IProps
) { ) {
const value = formik.values[field] as string[]; const value = formik.values[field] as string[];
const { error } = formik.getFieldMeta(field); const error = formik.errors[field] as ErrorMessage[] | ErrorMessage;
const errorMsg = error const [errorMsg, errorIdx] = flattenError(error);
? intl.formatMessage({ id: errorMessageID })
: undefined;
const errorIdx = error?.split(" ").map((e) => parseInt(e));
const title = intl.formatMessage({ id: messageID }); const title = intl.formatMessage({ id: messageID });
const control = ( const control = (

View file

@ -2,48 +2,131 @@ import { FormikErrors, yupToFormErrors } from "formik";
import { IntlShape } from "react-intl"; import { IntlShape } from "react-intl";
import * as yup from "yup"; 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 return yup
.array(yup.string().required()) .array(
.defined() // 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<string>
)
.test({ .test({
name: "unique", name: "blank",
test: (value) => { test(value) {
const values: string[] = []; if (!value || !value.length) return true;
const dupes: number[] = [];
const blanks: number[] = [];
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
const a = value[i]; const s = value[i];
if (values.includes(a)) { if (!s) {
dupes.push(i); blanks.push(i);
} else {
values.push(a);
} }
} }
if (dupes.length === 0) return true; if (blanks.length === 0) return true;
return new yup.ValidationError(dupes.join(" "), value, fieldName);
// 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 yupUniqueAliases(fieldName: string, nameField: string) { export function yupUniqueStringList(intl: IntlShape) {
return yup return yupRequiredStringArray(intl)
.array(yup.string().required())
.defined() .defined()
.test({ .test({
name: "unique", name: "unique",
test: (value, context) => { test(value) {
const aliases = [context.parent[nameField].toLowerCase()]; const values: string[] = [];
const dupes: number[] = []; const dupes: number[] = [];
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
const a = value[i].toLowerCase(); const s = value[i];
if (aliases.includes(a)) { if (values.includes(s)) {
dupes.push(i); dupes.push(i);
} else { } else {
aliases.push(a); values.push(s);
} }
} }
if (dupes.length === 0) return true; 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(intl: IntlShape, nameField: string) {
return yupRequiredStringArray(intl)
.defined()
.test({
name: "unique",
test(value) {
const aliases = [this.parent[nameField].toLowerCase()];
const dupes: number[] = [];
for (let i = 0; i < value.length; i++) {
const s = value[i].toLowerCase();
if (aliases.includes(s)) {
dupes.push(i);
} else {
aliases.push(s);
}
}
if (dupes.length === 0) return true;
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() .ensure()
.test({ .test({
name: "date", name: "date",
test: (value) => { test(value) {
if (!value) return true; if (!value) return true;
if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false; if (!value.match(/^\d{4}-\d{2}-\d{2}$/)) return false;
if (Number.isNaN(Date.parse(value))) return false; if (Number.isNaN(Date.parse(value))) return false;