mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Fix crash on blank aliases/urls (#4344)
* Fix crash on blank alias/url * Fix StringListInput clear issue
This commit is contained in:
parent
eca5838ce0
commit
d37de0e49b
11 changed files with 163 additions and 76 deletions
|
|
@ -87,7 +87,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
|||
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<IProps> = ({
|
|||
{renderInputField("title")}
|
||||
{renderInputField("code", "text", "scene_code")}
|
||||
|
||||
{renderURLListField(
|
||||
"urls",
|
||||
"validation.urls_must_be_unique",
|
||||
onScrapeGalleryURL,
|
||||
urlScrapable
|
||||
)}
|
||||
{renderURLListField("urls", onScrapeGalleryURL, urlScrapable)}
|
||||
|
||||
{renderDateField("date")}
|
||||
{renderInputField("photographer")}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
|||
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<IProps> = ({
|
|||
{renderInputField("title")}
|
||||
{renderInputField("code", "text", "scene_code")}
|
||||
|
||||
{renderURLListField("urls", "validation.urls_must_be_unique")}
|
||||
{renderURLListField("urls")}
|
||||
|
||||
{renderDateField("date")}
|
||||
{renderInputField("photographer")}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
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<IPerformerDetails> = ({
|
|||
{renderInputField("name")}
|
||||
{renderInputField("disambiguation")}
|
||||
|
||||
{renderStringListField(
|
||||
"alias_list",
|
||||
"validation.aliases_must_be_unique",
|
||||
"aliases"
|
||||
)}
|
||||
{renderStringListField("alias_list", "aliases")}
|
||||
|
||||
{renderSelectField("gender", stringGenderMap)}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
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<IProps> = ({
|
|||
{renderInputField("title")}
|
||||
{renderInputField("code", "text", "scene_code")}
|
||||
|
||||
{renderURLListField(
|
||||
"urls",
|
||||
"validation.urls_must_be_unique",
|
||||
onScrapeSceneURL,
|
||||
urlScrapable
|
||||
)}
|
||||
{renderURLListField("urls", onScrapeSceneURL, urlScrapable)}
|
||||
|
||||
{renderDateField("date")}
|
||||
{renderInputField("director")}
|
||||
|
|
|
|||
|
|
@ -53,12 +53,14 @@ export const StringListInput: React.FC<IStringListInputProps> = (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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
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<GQL.StashIdInput[]>().defined(),
|
||||
image: yup.string().nullable().optional(),
|
||||
|
|
@ -158,7 +158,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
|
||||
<Form noValidate onSubmit={formik.handleSubmit} id="studio-edit">
|
||||
{renderInputField("name")}
|
||||
{renderStringListField("aliases", "validation.aliases_must_be_unique")}
|
||||
{renderStringListField("aliases")}
|
||||
{renderInputField("url")}
|
||||
{renderInputField("details", "textarea")}
|
||||
{renderParentStudioField()}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
|
||||
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<ITagEditPanel> = ({
|
|||
|
||||
<Form noValidate onSubmit={formik.handleSubmit} id="tag-edit">
|
||||
{renderInputField("name")}
|
||||
{renderStringListField("aliases", "validation.aliases_must_be_unique")}
|
||||
{renderStringListField("aliases")}
|
||||
{renderInputField("description", "textarea")}
|
||||
{renderParentTagsField()}
|
||||
{renderSubTagsField()}
|
||||
|
|
|
|||
|
|
@ -1346,6 +1346,7 @@ dl.details-list {
|
|||
|
||||
.invalid-feedback {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -64,10 +64,11 @@ export function formikUtils<V extends FormikValues>(
|
|||
}: 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<V extends FormikValues>(
|
|||
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<V extends FormikValues>(
|
|||
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<V extends FormikValues>(
|
|||
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 = (
|
||||
<StringListInput
|
||||
value={formikProps.value ?? []}
|
||||
value={value}
|
||||
setValue={(v) => formik.setFieldValue(field, v)}
|
||||
errors={errorMsg}
|
||||
errorIdx={errorIdx}
|
||||
|
|
@ -262,19 +282,15 @@ export function formikUtils<V extends FormikValues>(
|
|||
|
||||
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 = (
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
.defined()
|
||||
.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<string>
|
||||
)
|
||||
.test({
|
||||
name: "unique",
|
||||
test: (value) => {
|
||||
const values: string[] = [];
|
||||
const dupes: number[] = [];
|
||||
name: "blank",
|
||||
test(value) {
|
||||
if (!value || !value.length) return true;
|
||||
|
||||
const blanks: number[] = [];
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const a = value[i];
|
||||
if (values.includes(a)) {
|
||||
dupes.push(i);
|
||||
} else {
|
||||
values.push(a);
|
||||
const s = value[i];
|
||||
if (!s) {
|
||||
blanks.push(i);
|
||||
}
|
||||
}
|
||||
if (dupes.length === 0) return true;
|
||||
return new yup.ValidationError(dupes.join(" "), value, fieldName);
|
||||
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 yupUniqueAliases(fieldName: string, nameField: string) {
|
||||
return yup
|
||||
.array(yup.string().required())
|
||||
export function yupUniqueStringList(intl: IntlShape) {
|
||||
return yupRequiredStringArray(intl)
|
||||
.defined()
|
||||
.test({
|
||||
name: "unique",
|
||||
test: (value, context) => {
|
||||
const aliases = [context.parent[nameField].toLowerCase()];
|
||||
test(value) {
|
||||
const values: string[] = [];
|
||||
const dupes: number[] = [];
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const a = value[i].toLowerCase();
|
||||
if (aliases.includes(a)) {
|
||||
const s = value[i];
|
||||
if (values.includes(s)) {
|
||||
dupes.push(i);
|
||||
} else {
|
||||
aliases.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(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()
|
||||
.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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue