mirror of
https://github.com/stashapp/stash.git
synced 2025-12-15 21:03:22 +01:00
404 lines
10 KiB
TypeScript
404 lines
10 KiB
TypeScript
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
|
import { FormikValues, useFormik } from "formik";
|
|
import React, { InputHTMLAttributes, useEffect, useRef } from "react";
|
|
import {
|
|
Button,
|
|
Col,
|
|
ColProps,
|
|
Form,
|
|
FormControlProps,
|
|
FormLabelProps,
|
|
Row,
|
|
} from "react-bootstrap";
|
|
import { IntlShape } from "react-intl";
|
|
import { DateInput } from "src/components/Shared/DateInput";
|
|
import { DurationInput } from "src/components/Shared/DurationInput";
|
|
import { Icon } from "src/components/Shared/Icon";
|
|
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
|
import { LinkType, StashIDPill } from "src/components/Shared/StashID";
|
|
import { StringListInput } from "src/components/Shared/StringListInput";
|
|
import { URLListInput } from "src/components/Shared/URLField";
|
|
import * as GQL from "src/core/generated-graphql";
|
|
|
|
function getLabelProps(labelProps?: FormLabelProps) {
|
|
let ret = labelProps;
|
|
if (!ret) {
|
|
ret = {
|
|
column: true,
|
|
xs: 3,
|
|
};
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
export function renderLabel(options: {
|
|
title: string;
|
|
labelProps?: FormLabelProps;
|
|
}) {
|
|
return (
|
|
<Form.Label column {...getLabelProps(options.labelProps)}>
|
|
{options.title}
|
|
</Form.Label>
|
|
);
|
|
}
|
|
|
|
// useStopWheelScroll is a hook to provide a workaround for a bug in React/Chrome.
|
|
// If a number field is focused and the mouse pointer is over the field, then scrolling
|
|
// the mouse wheel will change the field value _and_ scroll the window.
|
|
// This hook prevents the propagation that causes the window to scroll.
|
|
export function useStopWheelScroll(ref: React.RefObject<HTMLElement>) {
|
|
useEffect(() => {
|
|
const { current } = ref;
|
|
|
|
function stopWheelScroll(e: WheelEvent) {
|
|
if (current) {
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
|
|
if (current) {
|
|
current.addEventListener("wheel", stopWheelScroll);
|
|
}
|
|
|
|
return () => {
|
|
if (current) {
|
|
current.removeEventListener("wheel", stopWheelScroll);
|
|
}
|
|
};
|
|
}, [ref]);
|
|
}
|
|
|
|
const InputField: React.FC<
|
|
InputHTMLAttributes<HTMLInputElement> & FormControlProps
|
|
> = (props) => {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useStopWheelScroll(inputRef);
|
|
|
|
return <Form.Control {...props} ref={inputRef} />;
|
|
};
|
|
|
|
type Formik<V extends FormikValues> = ReturnType<typeof useFormik<V>>;
|
|
|
|
interface IProps {
|
|
labelProps?: FormLabelProps;
|
|
fieldProps?: ColProps;
|
|
}
|
|
|
|
export function formikUtils<V extends FormikValues>(
|
|
intl: IntlShape,
|
|
formik: Formik<V>,
|
|
{
|
|
labelProps = {
|
|
column: true,
|
|
sm: 3,
|
|
xl: 2,
|
|
},
|
|
fieldProps = {
|
|
sm: 9,
|
|
xl: 7,
|
|
},
|
|
}: 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.errors[field] as ErrorMessage;
|
|
|
|
let { value } = formikProps;
|
|
if (value === null) {
|
|
value = "";
|
|
}
|
|
|
|
let control: React.ReactNode;
|
|
if (type === "checkbox") {
|
|
control = (
|
|
<Form.Check
|
|
placeholder={placeholder}
|
|
{...formikProps}
|
|
value={value}
|
|
isInvalid={!!error}
|
|
/>
|
|
);
|
|
} else if (type === "textarea") {
|
|
control = (
|
|
<Form.Control
|
|
as="textarea"
|
|
className="text-input"
|
|
placeholder={placeholder}
|
|
{...formikProps}
|
|
value={value}
|
|
isInvalid={!!error}
|
|
/>
|
|
);
|
|
} else {
|
|
control = (
|
|
<InputField
|
|
type={type}
|
|
className="text-input"
|
|
placeholder={placeholder}
|
|
{...formikProps}
|
|
value={value}
|
|
isInvalid={!!error}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{control}
|
|
<Form.Control.Feedback type="invalid">{error}</Form.Control.Feedback>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function renderField(
|
|
field: Field,
|
|
title: string,
|
|
control: React.ReactNode,
|
|
props?: IProps
|
|
) {
|
|
return (
|
|
<Form.Group controlId={field} as={Row}>
|
|
<Form.Label {...(props?.labelProps ?? labelProps)}>{title}</Form.Label>
|
|
<Col {...(props?.fieldProps ?? fieldProps)}>{control}</Col>
|
|
</Form.Group>
|
|
);
|
|
}
|
|
|
|
function renderInputField(
|
|
field: Field,
|
|
type: string = "text",
|
|
messageID: string = field,
|
|
props?: IProps
|
|
) {
|
|
const title = intl.formatMessage({ id: messageID });
|
|
const control = renderFormControl(field, type, title);
|
|
|
|
return renderField(field, title, control, props);
|
|
}
|
|
|
|
function renderSelectField(
|
|
field: Field,
|
|
entries: Map<string, string>,
|
|
messageID: string = field,
|
|
props?: IProps
|
|
) {
|
|
const formikProps = formik.getFieldProps(field);
|
|
|
|
let { value } = formikProps;
|
|
if (value === null) {
|
|
value = "";
|
|
}
|
|
|
|
const title = intl.formatMessage({ id: messageID });
|
|
const control = (
|
|
<Form.Control
|
|
as="select"
|
|
className="input-control"
|
|
{...formikProps}
|
|
value={value}
|
|
>
|
|
<option value="" key=""></option>
|
|
{Array.from(entries).map(([k, v]) => (
|
|
<option value={v} key={v}>
|
|
{k}
|
|
</option>
|
|
))}
|
|
</Form.Control>
|
|
);
|
|
|
|
return renderField(field, title, control, props);
|
|
}
|
|
|
|
function renderDateField(
|
|
field: Field,
|
|
messageID: string = field,
|
|
props?: IProps
|
|
) {
|
|
const value = formik.values[field] as string;
|
|
const error = formik.errors[field] as ErrorMessage;
|
|
|
|
const title = intl.formatMessage({ id: messageID });
|
|
const control = (
|
|
<DateInput
|
|
value={value}
|
|
onValueChange={(v) => formik.setFieldValue(field, v)}
|
|
error={error}
|
|
/>
|
|
);
|
|
|
|
return renderField(field, title, control, props);
|
|
}
|
|
|
|
function renderDurationField(
|
|
field: Field,
|
|
messageID: string = field,
|
|
props?: IProps
|
|
) {
|
|
const value = formik.values[field] as number | null;
|
|
const error = formik.errors[field] as ErrorMessage;
|
|
|
|
const title = intl.formatMessage({ id: messageID });
|
|
const control = (
|
|
<DurationInput
|
|
value={value}
|
|
setValue={(v) => formik.setFieldValue(field, v)}
|
|
error={error}
|
|
/>
|
|
);
|
|
|
|
return renderField(field, title, control, props);
|
|
}
|
|
|
|
function renderRatingField(
|
|
field: Field,
|
|
messageID: string = field,
|
|
props?: IProps
|
|
) {
|
|
const value = formik.values[field] as number | null;
|
|
|
|
const title = intl.formatMessage({ id: messageID });
|
|
const control = (
|
|
<RatingSystem
|
|
value={value}
|
|
onSetRating={(v) => formik.setFieldValue(field, v)}
|
|
/>
|
|
);
|
|
|
|
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,
|
|
messageID: string = field,
|
|
props?: IProps
|
|
) {
|
|
const value = formik.values[field] as string[];
|
|
const error = formik.errors[field] as ErrorMessage[] | ErrorMessage;
|
|
|
|
const [errorMsg, errorIdx] = flattenError(error);
|
|
|
|
const title = intl.formatMessage({ id: messageID });
|
|
const control = (
|
|
<StringListInput
|
|
value={value}
|
|
setValue={(v) => formik.setFieldValue(field, v)}
|
|
errors={errorMsg}
|
|
errorIdx={errorIdx}
|
|
/>
|
|
);
|
|
|
|
return renderField(field, title, control, props);
|
|
}
|
|
|
|
function renderURLListField(
|
|
field: Field,
|
|
onScrapeClick?: (url: string) => void,
|
|
urlScrapable?: (url: string) => boolean,
|
|
messageID: string = field,
|
|
props?: IProps
|
|
) {
|
|
const value = formik.values[field] as string[];
|
|
const error = formik.errors[field] as ErrorMessage[] | ErrorMessage;
|
|
|
|
const [errorMsg, errorIdx] = flattenError(error);
|
|
|
|
const title = intl.formatMessage({ id: messageID });
|
|
const control = (
|
|
<URLListInput
|
|
value={value}
|
|
setValue={(v) => formik.setFieldValue(field, v)}
|
|
errors={errorMsg}
|
|
errorIdx={errorIdx}
|
|
onScrapeClick={onScrapeClick}
|
|
urlScrapable={urlScrapable}
|
|
/>
|
|
);
|
|
|
|
return renderField(field, title, control, props);
|
|
}
|
|
|
|
function renderStashIDsField(
|
|
field: Field,
|
|
linkType: LinkType,
|
|
messageID: string = field,
|
|
props?: IProps
|
|
) {
|
|
const values = formik.values[field] as GQL.StashIdInput[];
|
|
if (!values.length) {
|
|
return;
|
|
}
|
|
|
|
const title = intl.formatMessage({ id: messageID });
|
|
|
|
const removeStashID = (stashID: GQL.StashIdInput) => {
|
|
const v = values.filter((s) => s !== stashID);
|
|
formik.setFieldValue(field, v);
|
|
};
|
|
|
|
const control = (
|
|
<ul className="pl-0 mb-0">
|
|
{values.map((stashID) => {
|
|
return (
|
|
<Row as="li" key={stashID.stash_id} noGutters>
|
|
<Button
|
|
variant="danger"
|
|
className="mr-2 py-0"
|
|
title={intl.formatMessage(
|
|
{ id: "actions.delete_entity" },
|
|
{ entityType: intl.formatMessage({ id: "stash_id" }) }
|
|
)}
|
|
onClick={() => removeStashID(stashID)}
|
|
>
|
|
<Icon icon={faTrashAlt} />
|
|
</Button>
|
|
<StashIDPill stashID={stashID} linkType={linkType} />
|
|
</Row>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
|
|
return renderField(field, title, control, props);
|
|
}
|
|
|
|
return {
|
|
renderFormControl,
|
|
renderField,
|
|
renderInputField,
|
|
renderSelectField,
|
|
renderDateField,
|
|
renderDurationField,
|
|
renderRatingField,
|
|
renderStringListField,
|
|
renderURLListField,
|
|
renderStashIDsField,
|
|
};
|
|
}
|