stash/ui/v2.5/src/components/Shared/CustomFields.tsx
feederbox826 083ba25d04
ui package updates sprint 1 (#6777)
* minor package version bumps, remove string.replaceAll polyfill
* update universal-cookie
* bump flag-icons
* [apollo] replace cloneDeep with lodash-es/CloneDeep
* [apollo] partial upgrade to 3.14
* remove dom-screen-wake-lock
* switch videojs-vr library for xvr support. minor bumps
* vite 7
* bump ua-parser-js
* bump postcss
* bump polyfills
* partial bump eslint to v8, otherwise we lose airbnb
* bump typescript to 5.9
* ensure node engine, remove homepage
2026-04-24 14:14:07 +10:00

333 lines
9 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from "react";
import { CollapseButton } from "./CollapseButton";
import { DetailItem } from "./DetailItem";
import { Button, Col, Form, FormGroup, InputGroup, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import cloneDeep from "lodash-es/cloneDeep";
import { Icon } from "./Icon";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import cx from "classnames";
import { PatchComponent } from "src/patch";
import { TruncatedText } from "./TruncatedText";
const maxFieldNameLength = 64;
export type CustomFieldMap = {
[key: string]: unknown;
};
interface ICustomFields {
values: CustomFieldMap;
fullWidth?: boolean;
}
function convertValue(value: unknown): string {
if (typeof value === "string") {
return value;
} else if (typeof value === "number") {
return value.toString();
} else if (typeof value === "boolean") {
return value ? "true" : "false";
} else if (Array.isArray(value)) {
return value.join(", ");
} else {
return JSON.stringify(value);
}
}
const CustomField: React.FC<{ field: string; value: unknown }> = ({
field,
value,
}) => {
const valueStr = convertValue(value);
// replace spaces with hyphen characters for css id
const id = `custom-field-${field.toLowerCase().replace(/ /g, "-")}`;
return (
<DetailItem
id={id}
label={field}
labelTitle={field}
value={<TruncatedText lineCount={5} text={<>{valueStr}</>} />}
fullWidth={true}
showEmpty
/>
);
};
export const CustomFields: React.FC<ICustomFields> = PatchComponent(
"CustomFields",
({ values, fullWidth }) => {
const intl = useIntl();
if (Object.keys(values).length === 0) {
return null;
}
return (
// according to linter rule CSS classes shouldn't use underscores
<div className={cx("custom-fields", { "full-width": fullWidth })}>
<CollapseButton
text={intl.formatMessage({ id: "custom_fields.title" })}
>
{Object.entries(values).map(([key, value]) => (
<CustomField key={key} field={key} value={value} />
))}
</CollapseButton>
</div>
);
}
);
function isNumeric(v: string) {
return /^-?(?:0|(?:[1-9][0-9]*))(?:\.[0-9]+)?$/.test(v);
}
function convertCustomValue(v: string) {
// if the value is numeric, convert it to a number
if (isNumeric(v)) {
return Number(v);
} else {
return v;
}
}
const CustomFieldInput: React.FC<{
field: string;
value: unknown;
onChange: (field: string, value: unknown) => void;
isNew?: boolean;
error?: string;
}> = PatchComponent(
"CustomFieldInput",
({ field, value, onChange, isNew = false, error }) => {
const intl = useIntl();
const [currentField, setCurrentField] = useState(field);
const [currentValue, setCurrentValue] = useState(value as string);
const fieldRef = useRef<HTMLInputElement>(null);
const valueRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setCurrentField(field);
setCurrentValue(value as string);
}, [field, value]);
function onBlur() {
onChange(currentField, convertCustomValue(currentValue));
}
function onDelete() {
onChange("", "");
}
return (
<FormGroup>
<Row
className={cx("custom-fields-row", { "custom-fields-new": isNew })}
>
<Col className="custom-fields-field">
{isNew ? (
<>
<Form.Control
ref={fieldRef}
className="input-control"
type="text"
value={currentField ?? ""}
placeholder={intl.formatMessage({
id: "custom_fields.field",
})}
onChange={(event) =>
setCurrentField(event.currentTarget.value)
}
onBlur={onBlur}
/>
</>
) : (
<Form.Label title={currentField}>{currentField}</Form.Label>
)}
</Col>
<Col className="custom-fields-value">
<InputGroup>
<Form.Control
ref={valueRef}
className="input-control"
type="text"
value={(currentValue as string) ?? ""}
placeholder={currentField}
onChange={(event) => setCurrentValue(event.currentTarget.value)}
onBlur={onBlur}
/>
<InputGroup.Append>
{!isNew && (
<Button
className="custom-fields-remove"
variant="danger"
onClick={() => onDelete()}
>
<Icon icon={faMinus} />
</Button>
)}
</InputGroup.Append>
</InputGroup>
</Col>
</Row>
<Form.Control.Feedback type="invalid">{error}</Form.Control.Feedback>
</FormGroup>
);
}
);
interface ICustomField {
field: string;
value: unknown;
}
interface ICustomFieldsInput {
values: CustomFieldMap;
error?: string;
onChange: (values: CustomFieldMap) => void;
setError: (error?: string) => void;
}
export function formatCustomFieldInput(isNew: boolean, input: {}) {
if (isNew) {
return input;
} else {
return {
full: input,
};
}
}
export const CustomFieldsInput: React.FC<ICustomFieldsInput> = PatchComponent(
"CustomFieldsInput",
({ values, error, onChange, setError }) => {
const intl = useIntl();
const [newCustomField, setNewCustomField] = useState<ICustomField>({
field: "",
value: "",
});
const fields = useMemo(() => {
const valueCopy = cloneDeep(values);
if (newCustomField.field !== "" && error === undefined) {
delete valueCopy[newCustomField.field];
}
const ret = Object.keys(valueCopy);
ret.sort();
return ret;
}, [values, newCustomField, error]);
function onSetNewField(v: ICustomField) {
// validate the field name
let newError = undefined;
if (v.field.length > maxFieldNameLength) {
newError = intl.formatMessage({
id: "errors.custom_fields.field_name_length",
});
}
if (v.field.trim() === "" && v.value !== "") {
newError = intl.formatMessage({
id: "errors.custom_fields.field_name_required",
});
}
if (v.field.trim() !== v.field) {
newError = intl.formatMessage({
id: "errors.custom_fields.field_name_whitespace",
});
}
if (fields.includes(v.field)) {
newError = intl.formatMessage({
id: "errors.custom_fields.duplicate_field",
});
}
const oldField = newCustomField;
setNewCustomField(v);
const valuesCopy = cloneDeep(values);
if (oldField.field !== "" && error === undefined) {
delete valuesCopy[oldField.field];
}
// if valid, pass up
if (!newError && v.field !== "") {
valuesCopy[v.field] = v.value;
}
onChange(valuesCopy);
setError(newError);
}
function onAdd() {
const newValues = {
...values,
[newCustomField.field]: newCustomField.value,
};
setNewCustomField({ field: "", value: "" });
onChange(newValues);
}
function fieldChanged(
currentField: string,
newField: string,
value: unknown
) {
let newValues = cloneDeep(values);
delete newValues[currentField];
if (newField !== "") {
newValues[newField] = value;
}
onChange(newValues);
}
return (
<CollapseButton
className="custom-fields-input"
text={intl.formatMessage({ id: "custom_fields.title" })}
>
<Row>
<Col xl={12}>
<Row className="custom-fields-input-header">
<Form.Label column className="custom-fields-field">
<FormattedMessage id="custom_fields.field" />
</Form.Label>
<Form.Label column className="custom-fields-value">
<FormattedMessage id="custom_fields.value" />
</Form.Label>
</Row>
{fields.map((field) => (
<CustomFieldInput
key={field}
field={field}
value={values[field]}
onChange={(newField, newValue) =>
fieldChanged(field, newField, newValue)
}
/>
))}
<CustomFieldInput
field={newCustomField.field}
value={newCustomField.value}
error={error}
onChange={(field, value) => onSetNewField({ field, value })}
isNew
/>
</Col>
</Row>
<Button
className="custom-fields-add"
variant="success"
onClick={() => onAdd()}
disabled={newCustomField.field === "" || error !== undefined}
>
<Icon icon={faPlus} />
</Button>
</CollapseButton>
);
}
);