Form-related fixes, improvements and refactoring (#4283)

* Fix another validateDOMNesting error
* Fix React.forwardRef error
* Fix encoding_image intl message
* Return null instead of undefined from RatingSystem
* DurationInput tweaks
* DateInput tweaks, remove unused utils functions
* Refactor and deduplicate edit form rendering
* Improve/fix yup validation
This commit is contained in:
DingDongSoLong4
2023-11-20 03:42:26 +02:00
committed by GitHub
parent 65b416a2d9
commit 959f2531fd
55 changed files with 1419 additions and 2217 deletions

View File

@@ -37,7 +37,7 @@
"classnames": "^2.3.2",
"flag-icons": "^6.6.6",
"flexbin": "^0.2.0",
"formik": "^2.2.9",
"formik": "^2.4.5",
"graphql": "^16.8.1",
"graphql-tag": "^2.12.6",
"graphql-ws": "^5.11.3",
@@ -74,7 +74,7 @@
"videojs-seek-buttons": "^3.0.1",
"videojs-vr": "^2.0.0",
"videojs-vtt.js": "^0.15.4",
"yup": "^1.0.0"
"yup": "^1.3.2"
},
"devDependencies": {
"@babel/core": "^7.20.12",

View File

@@ -7,7 +7,7 @@ import * as GQL from "src/core/generated-graphql";
import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
import { useToast } from "src/hooks/Toast";
import FormUtils from "src/utils/form";
import * as FormUtils from "src/utils/form";
import { MultiSet } from "../Shared/MultiSet";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
@@ -257,7 +257,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value)}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>

View File

@@ -11,6 +11,8 @@ import {
} from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
import isEqual from "lodash-es/isEqual";
import { formikUtils } from "src/utils/form";
import { yupFormikValidate, yupInputNumber } from "src/utils/yup";
interface IGalleryChapterForm {
galleryID: string;
@@ -34,11 +36,10 @@ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
const schema = yup.object({
title: yup.string().ensure(),
image_index: yup
.number()
image_index: yupInputNumber()
.integer()
.required()
.moreThan(0)
.required()
.label(intl.formatMessage({ id: "image_index" })),
});
@@ -51,9 +52,9 @@ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
const formik = useFormik<InputValues>({
initialValues,
validationSchema: schema,
enableReinitialize: true,
onSubmit: (values) => onSave(values),
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
});
async function onSave(input: InputValues) {
@@ -93,43 +94,25 @@ export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
}
}
const splitProps = {
labelProps: {
column: true,
sm: 3,
},
fieldProps: {
sm: 9,
},
};
const { renderInputField } = formikUtils(intl, formik, splitProps);
return (
<Form noValidate onSubmit={formik.handleSubmit}>
<div>
<Form.Group>
<Form.Label>
<FormattedMessage id="title" />
</Form.Label>
<Form.Control
className="text-input"
placeholder={intl.formatMessage({ id: "title" })}
isInvalid={!!formik.errors.title}
{...formik.getFieldProps("title")}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.title}
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Label>
<FormattedMessage id="image_index" />
</Form.Label>
<Form.Control
className="text-input"
placeholder={intl.formatMessage({ id: "image_index" })}
isInvalid={!!formik.errors.image_index}
{...formik.getFieldProps("image_index")}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.image_index}
</Form.Control.Feedback>
</Form.Group>
<div className="form-container px-3">
{renderInputField("title")}
{renderInputField("image_index", "number")}
</div>
<div className="buttons-container row">
<div className="col d-flex">
<div className="buttons-container px-3">
<div className="d-flex">
<Button
variant="primary"
disabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}

View File

@@ -25,24 +25,25 @@ import {
} from "src/components/Shared/Select";
import { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { URLListInput } from "src/components/Shared/URLField";
import { useToast } from "src/hooks/Toast";
import { useFormik } from "formik";
import FormUtils from "src/utils/form";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput";
import { handleUnsavedChanges } from "src/utils/navigation";
import {
Performer,
PerformerSelect,
} from "src/components/Performers/PerformerSelect";
import { yupDateString, yupUniqueStringList } from "src/utils/yup";
import {
yupDateString,
yupFormikValidate,
yupUniqueStringList,
} from "src/utils/yup";
import { formikUtils } from "src/utils/form";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
@@ -87,7 +88,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
title: titleRequired ? yup.string().required() : yup.string().ensure(),
urls: yupUniqueStringList("urls"),
date: yupDateString(intl),
rating100: yup.number().nullable().defined(),
rating100: yup.number().integer().nullable().defined(),
studio_id: yup.string().required().nullable(),
performer_ids: yup.array(yup.string().required()).defined(),
tag_ids: yup.array(yup.string().required()).defined(),
@@ -112,8 +113,8 @@ export const GalleryEditPanel: React.FC<IProps> = ({
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validationSchema: schema,
onSubmit: (values) => onSave(values),
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
});
function setRating(v: number) {
@@ -356,36 +357,109 @@ export const GalleryEditPanel: React.FC<IProps> = ({
}
}
function renderTextField(field: string, title: string, placeholder?: string) {
return (
<Form.Group controlId={field} as={Row}>
{FormUtils.renderLabel({
title,
})}
<Col xs={9}>
<Form.Control
className="text-input"
placeholder={placeholder ?? title}
{...formik.getFieldProps(field)}
isInvalid={!!formik.getFieldMeta(field).error}
/>
<Form.Control.Feedback type="invalid">
{formik.getFieldMeta(field).error}
</Form.Control.Feedback>
</Col>
</Form.Group>
);
}
if (isLoading) return <LoadingIndicator />;
const urlsErrors = Array.isArray(formik.errors.urls)
? formik.errors.urls[0]
: formik.errors.urls;
const urlsErrorMsg = urlsErrors
? intl.formatMessage({ id: "validation.urls_must_be_unique" })
: undefined;
const urlsErrorIdx = urlsErrors?.split(" ").map((e) => parseInt(e));
const splitProps = {
labelProps: {
column: true,
sm: 3,
},
fieldProps: {
sm: 9,
},
};
const fullWidthProps = {
labelProps: {
column: true,
sm: 3,
xl: 12,
},
fieldProps: {
sm: 9,
xl: 12,
},
};
const {
renderField,
renderInputField,
renderDateField,
renderRatingField,
renderURLListField,
} = formikUtils(intl, formik, splitProps);
function renderScenesField() {
const title = intl.formatMessage({ id: "scenes" });
const control = (
<SceneSelect
selected={scenes}
onSelect={(items) => onSetScenes(items)}
isMulti
/>
);
return renderField("scene_ids", title, control);
}
function renderStudioField() {
const title = intl.formatMessage({ id: "studio" });
const control = (
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
/>
);
return renderField("studio_id", title, control);
}
function renderPerformersField() {
const title = intl.formatMessage({ id: "performers" });
const control = (
<PerformerSelect isMulti onSelect={onSetPerformers} values={performers} />
);
return renderField("performer_ids", title, control, fullWidthProps);
}
function renderTagsField() {
const title = intl.formatMessage({ id: "tags" });
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
hoverPlacement="right"
/>
);
return renderField("tag_ids", title, control, fullWidthProps);
}
function renderDetailsField() {
const props = {
labelProps: {
column: true,
sm: 3,
lg: 12,
},
fieldProps: {
sm: 9,
lg: 12,
},
};
return renderInputField("details", "textarea", "details", props);
}
return (
<div id="gallery-edit-details">
@@ -396,8 +470,8 @@ export const GalleryEditPanel: React.FC<IProps> = ({
{maybeRenderScrapeDialog()}
<Form noValidate onSubmit={formik.handleSubmit}>
<div className="form-container row px-3 pt-3">
<div className="col edit-buttons mb-3 pl-0">
<Row className="form-container edit-buttons-container px-3 pt-3">
<div className="edit-buttons mb-3 pl-0">
<Button
className="edit-button"
variant="primary"
@@ -416,148 +490,31 @@ export const GalleryEditPanel: React.FC<IProps> = ({
<FormattedMessage id="actions.delete" />
</Button>
</div>
<Col xs={6} className="text-right">
{renderScraperMenu()}
<div className="ml-auto text-right d-flex">{renderScraperMenu()}</div>
</Row>
<Row className="form-container px-3">
<Col lg={7} xl={12}>
{renderInputField("title")}
{renderURLListField(
"urls",
"validation.urls_must_be_unique",
onScrapeGalleryURL,
urlScrapable
)}
{renderDateField("date")}
{renderRatingField("rating100", "rating")}
{renderScenesField()}
{renderStudioField()}
{renderPerformersField()}
{renderTagsField()}
</Col>
</div>
<div className="form-container row px-3">
<div className="col-12 col-lg-6 col-xl-12">
{renderTextField("title", intl.formatMessage({ id: "title" }))}
<Form.Group controlId="urls" as={Row}>
<Col xs={3} className="pr-0 url-label">
<Form.Label className="col-form-label">
<FormattedMessage id="urls" />
</Form.Label>
</Col>
<Col xs={9}>
<URLListInput
value={formik.values.urls ?? []}
setValue={(value) => formik.setFieldValue("urls", value)}
errors={urlsErrorMsg}
errorIdx={urlsErrorIdx}
onScrapeClick={(url) => onScrapeGalleryURL(url)}
urlScrapable={urlScrapable}
/>
</Col>
</Form.Group>
<Form.Group controlId="date" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "date" }),
})}
<Col xs={9}>
<DateInput
value={formik.values.date}
onValueChange={(value) => formik.setFieldValue("date", value)}
error={formik.errors.date}
/>
</Col>
</Form.Group>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={formik.values.rating100 ?? undefined}
onSetRating={(value) =>
formik.setFieldValue("rating100", value ?? null)
}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
/>
</Col>
</Form.Group>
<Form.Group controlId="performers" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "performers" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<PerformerSelect
isMulti
onSelect={onSetPerformers}
values={performers}
/>
</Col>
</Form.Group>
<Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "tags" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
hoverPlacement="right"
/>
</Col>
</Form.Group>
<Form.Group controlId="scenes" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "scenes" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<SceneSelect
selected={scenes}
onSelect={(items) => onSetScenes(items)}
isMulti
/>
</Col>
</Form.Group>
</div>
<div className="col-12 col-lg-6 col-xl-12">
<Form.Group controlId="details">
<Form.Label>
<FormattedMessage id="details" />
</Form.Label>
<Form.Control
as="textarea"
className="gallery-description text-input"
onChange={(e) =>
formik.setFieldValue("details", e.currentTarget.value)
}
value={formik.values.details}
/>
</Form.Group>
</div>
</div>
<Col lg={5} xl={12}>
{renderDetailsField()}
</Col>
</Row>
</Form>
</div>
);

View File

@@ -50,7 +50,7 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
role="button"
tabIndex={0}
>
<RatingSystem value={gallery.rating100 ?? undefined} disabled />
<RatingSystem value={gallery.rating100} disabled />
<img loading="lazy" src={cover} alt="" className={CLASSNAME_IMG} />
<footer className={CLASSNAME_FOOTER}>
<Link

View File

@@ -15,10 +15,6 @@
.tab-content {
min-height: 15rem;
}
.gallery-description {
width: 100%;
}
}
.gallery-card {

View File

@@ -7,7 +7,7 @@ import * as GQL from "src/core/generated-graphql";
import { StudioSelect } from "src/components/Shared/Select";
import { ModalComponent } from "src/components/Shared/Modal";
import { useToast } from "src/hooks/Toast";
import FormUtils from "src/utils/form";
import * as FormUtils from "src/utils/form";
import { MultiSet } from "../Shared/MultiSet";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
@@ -247,7 +247,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value)}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>

View File

@@ -6,21 +6,22 @@ import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import { TagSelect, StudioSelect } from "src/components/Shared/Select";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { URLListInput } from "src/components/Shared/URLField";
import { useToast } from "src/hooks/Toast";
import FormUtils from "src/utils/form";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput";
import { yupDateString, yupUniqueStringList } from "src/utils/yup";
import {
yupDateString,
yupFormikValidate,
yupUniqueStringList,
} from "src/utils/yup";
import {
Performer,
PerformerSelect,
} from "src/components/Performers/PerformerSelect";
import { formikUtils } from "src/utils/form";
interface IProps {
image: GQL.ImageDataFragment;
@@ -49,7 +50,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
title: yup.string().ensure(),
urls: yupUniqueStringList("urls"),
date: yupDateString(intl),
rating100: yup.number().nullable().defined(),
rating100: yup.number().integer().nullable().defined(),
studio_id: yup.string().required().nullable(),
performer_ids: yup.array(yup.string().required()).defined(),
tag_ids: yup.array(yup.string().required()).defined(),
@@ -70,8 +71,8 @@ export const ImageEditPanel: React.FC<IProps> = ({
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validationSchema: schema,
onSubmit: (values) => onSave(values),
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
});
function setRating(v: number) {
@@ -128,36 +129,80 @@ export const ImageEditPanel: React.FC<IProps> = ({
setIsLoading(false);
}
function renderTextField(field: string, title: string, placeholder?: string) {
return (
<Form.Group controlId={field} as={Row}>
{FormUtils.renderLabel({
title,
})}
<Col xs={9}>
<Form.Control
className="text-input"
placeholder={placeholder ?? title}
{...formik.getFieldProps(field)}
isInvalid={!!formik.getFieldMeta(field).error}
/>
<Form.Control.Feedback type="invalid">
{formik.getFieldMeta(field).error}
</Form.Control.Feedback>
</Col>
</Form.Group>
);
}
if (isLoading) return <LoadingIndicator />;
const urlsErrors = Array.isArray(formik.errors.urls)
? formik.errors.urls[0]
: formik.errors.urls;
const urlsErrorMsg = urlsErrors
? intl.formatMessage({ id: "validation.urls_must_be_unique" })
: undefined;
const urlsErrorIdx = urlsErrors?.split(" ").map((e) => parseInt(e));
const splitProps = {
labelProps: {
column: true,
sm: 3,
},
fieldProps: {
sm: 9,
},
};
const fullWidthProps = {
labelProps: {
column: true,
sm: 3,
xl: 12,
},
fieldProps: {
sm: 9,
xl: 12,
},
};
const {
renderField,
renderInputField,
renderDateField,
renderRatingField,
renderURLListField,
} = formikUtils(intl, formik, splitProps);
function renderStudioField() {
const title = intl.formatMessage({ id: "studio" });
const control = (
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
/>
);
return renderField("studio_id", title, control);
}
function renderPerformersField() {
const title = intl.formatMessage({ id: "performers" });
const control = (
<PerformerSelect isMulti onSelect={onSetPerformers} values={performers} />
);
return renderField("performer_ids", title, control, fullWidthProps);
}
function renderTagsField() {
const title = intl.formatMessage({ id: "tags" });
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
hoverPlacement="right"
/>
);
return renderField("tag_ids", title, control, fullWidthProps);
}
return (
<div id="image-edit-details">
@@ -167,8 +212,8 @@ export const ImageEditPanel: React.FC<IProps> = ({
/>
<Form noValidate onSubmit={formik.handleSubmit}>
<div className="form-container row px-3 pt-3">
<div className="col edit-buttons mb-3 pl-0">
<Row className="form-container edit-buttons-container px-3 pt-3">
<div className="edit-buttons mb-3 pl-0">
<Button
className="edit-button"
variant="primary"
@@ -185,110 +230,21 @@ export const ImageEditPanel: React.FC<IProps> = ({
<FormattedMessage id="actions.delete" />
</Button>
</div>
</div>
<div className="form-container row px-3">
<div className="col-12 col-lg-6 col-xl-12">
{renderTextField("title", intl.formatMessage({ id: "title" }))}
<Form.Group controlId="urls" as={Row}>
<Col xs={3} className="pr-0 url-label">
<Form.Label className="col-form-label">
<FormattedMessage id="urls" />
</Form.Label>
</Col>
<Col xs={9}>
<URLListInput
value={formik.values.urls ?? []}
setValue={(value) => formik.setFieldValue("urls", value)}
errors={urlsErrorMsg}
errorIdx={urlsErrorIdx}
/>
</Col>
</Form.Group>
<Form.Group controlId="date" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "date" }),
})}
<Col xs={9}>
<DateInput
value={formik.values.date}
onValueChange={(value) => formik.setFieldValue("date", value)}
error={formik.errors.date}
/>
</Col>
</Form.Group>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={formik.values.rating100 ?? undefined}
onSetRating={(value) =>
formik.setFieldValue("rating100", value ?? null)
}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
/>
</Col>
</Form.Group>
</Row>
<Row className="form-container px-3">
<Col lg={7} xl={12}>
{renderInputField("title")}
<Form.Group controlId="performers" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "performers" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<PerformerSelect
isMulti
onSelect={onSetPerformers}
values={performers}
/>
</Col>
</Form.Group>
{renderURLListField("urls", "validation.urls_must_be_unique")}
<Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "tags" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
hoverPlacement="right"
/>
</Col>
</Form.Group>
</div>
</div>
{renderDateField("date")}
{renderRatingField("rating100", "rating")}
{renderStudioField()}
{renderPerformersField()}
{renderTagsField()}
</Col>
</Row>
</Form>
</div>
);

View File

@@ -17,9 +17,9 @@ export const DurationFilter: React.FC<IDurationFilterProps> = ({
}) => {
const intl = useIntl();
function onChanged(v: number | undefined, property: "value" | "value2") {
function onChanged(v: number | null, property: "value" | "value2") {
const { value } = criterion;
value[property] = v;
value[property] = v ?? undefined;
onValueChanged(value);
}

View File

@@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "../Shared/Modal";
import { StudioSelect } from "../Shared/Select";
import { useToast } from "src/hooks/Toast";
import FormUtils from "src/utils/form";
import * as FormUtils from "src/utils/form";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputValue,
@@ -127,7 +127,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value)}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>

View File

@@ -404,7 +404,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
<div className="logo w-100">
{encodingImage ? (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
message={intl.formatMessage({ id: "actions.encoding_image" })}
/>
) : (
<div className="movie-images">
@@ -423,8 +423,8 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
</h2>
{maybeRenderAliases()}
<RatingSystem
value={movie.rating100 ?? undefined}
onSetRating={(value) => setRating(value ?? null)}
value={movie.rating100}
onSetRating={(value) => setRating(value)}
/>
{maybeRenderDetails()}
{maybeRenderEditPanel()}

View File

@@ -67,7 +67,7 @@ const MovieCreate: React.FC = () => {
<div className="logo w-100">
{encodingImage ? (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
message={intl.formatMessage({ id: "actions.encoding_image" })}
/>
) : (
<div className="movie-images">

View File

@@ -1,7 +1,6 @@
import React from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import DurationUtils from "src/utils/duration";
import TextUtils from "src/utils/text";
import { DetailItem } from "src/components/Shared/DetailItem";
@@ -22,7 +21,7 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({
<DetailItem
id="duration"
value={
movie.duration ? DurationUtils.secondsToString(movie.duration) : ""
movie.duration ? TextUtils.secondsToTimestamp(movie.duration) : ""
}
fullWidth={fullWidth}
/>

View File

@@ -10,18 +10,18 @@ import {
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { StudioSelect } from "src/components/Shared/Select";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { DurationInput } from "src/components/Shared/DurationInput";
import { URLField } from "src/components/Shared/URLField";
import { useToast } from "src/hooks/Toast";
import { Modal as BSModal, Form, Button, Col, Row } from "react-bootstrap";
import DurationUtils from "src/utils/duration";
import { Modal as BSModal, Form, Button } from "react-bootstrap";
import TextUtils from "src/utils/text";
import ImageUtils from "src/utils/image";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
import { MovieScrapeDialog } from "./MovieScrapeDialog";
import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput";
import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form";
import { yupDateString, yupFormikValidate } from "src/utils/yup";
interface IMovieEditPanel {
movie: Partial<GQL.MovieDataFragment>;
@@ -55,28 +55,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
const Scrapers = useListMovieScrapers();
const [scrapedMovie, setScrapedMovie] = useState<GQL.ScrapedMovie>();
const labelXS = 3;
const labelXL = 2;
const fieldXS = 9;
const fieldXL = 7;
const schema = yup.object({
name: yup.string().required(),
aliases: yup.string().ensure(),
duration: yup.number().nullable().defined(),
date: yup
.string()
.ensure()
.test({
name: "date",
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;
return true;
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
}),
duration: yup.number().integer().min(0).nullable().defined(),
date: yupDateString(intl),
studio_id: yup.string().required().nullable(),
director: yup.string().ensure(),
url: yup.string().ensure(),
@@ -101,8 +84,8 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validationSchema: schema,
onSubmit: (values) => onSave(values),
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
});
// set up hotkeys
@@ -135,8 +118,8 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
}
if (state.duration) {
const seconds = DurationUtils.stringToSeconds(state.duration);
if (seconds !== undefined) {
const seconds = TextUtils.timestampToSeconds(state.duration);
if (seconds) {
formik.setFieldValue("duration", seconds);
}
}
@@ -330,27 +313,41 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
if (isLoading) return <LoadingIndicator />;
const isEditing = true;
const {
renderField,
renderInputField,
renderDateField,
renderDurationField,
} = formikUtils(intl, formik);
function renderTextField(field: string, title: string, placeholder?: string) {
return (
<Form.Group controlId={field} as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id={title} />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
className="text-input"
placeholder={placeholder ?? title}
{...formik.getFieldProps(field)}
isInvalid={!!formik.getFieldMeta(field).error}
/>
<Form.Control.Feedback type="invalid">
{formik.getFieldMeta(field).error}
</Form.Control.Feedback>
</Col>
</Form.Group>
function renderStudioField() {
const title = intl.formatMessage({ id: "studio" });
const control = (
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
/>
);
return renderField("studio_id", title, control);
}
function renderUrlField() {
const title = intl.formatMessage({ id: "url" });
const control = (
<URLField
{...formik.getFieldProps("url")}
onScrapeClick={onScrapeMovieURL}
urlScrapable={urlScrapable}
/>
);
return renderField("url", title, control);
}
// TODO: CSS class
@@ -377,102 +374,21 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
/>
<Form noValidate onSubmit={formik.handleSubmit} id="movie-edit">
<Form.Group controlId="name" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="name" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
className="text-input"
placeholder={intl.formatMessage({ id: "name" })}
{...formik.getFieldProps("name")}
isInvalid={!!formik.errors.name}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.name}
</Form.Control.Feedback>
</Col>
</Form.Group>
{renderTextField("aliases", intl.formatMessage({ id: "aliases" }))}
<Form.Group controlId="duration" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="duration" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<DurationInput
value={formik.values.duration ?? undefined}
setValue={(v) => formik.setFieldValue("duration", v ?? null)}
/>
</Col>
</Form.Group>
<Form.Group controlId="date" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="date" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<DateInput
value={formik.values.date}
onValueChange={(value) => formik.setFieldValue("date", value)}
error={formik.errors.date}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="studio" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
/>
</Col>
</Form.Group>
{renderTextField("director", intl.formatMessage({ id: "director" }))}
<Form.Group controlId="url" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="url" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<URLField
{...formik.getFieldProps("url")}
onScrapeClick={onScrapeMovieURL}
urlScrapable={urlScrapable}
/>
</Col>
</Form.Group>
<Form.Group controlId="synopsis" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="synopsis" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
as="textarea"
className="text-input"
placeholder={intl.formatMessage({ id: "synopsis" })}
{...formik.getFieldProps("synopsis")}
/>
</Col>
</Form.Group>
{renderInputField("name")}
{renderInputField("aliases")}
{renderDurationField("duration")}
{renderDateField("date")}
{renderStudioField()}
{renderInputField("director")}
{renderUrlField()}
{renderInputField("synopsis", "textarea")}
</Form>
<DetailsEditNavbar
objectName={movie?.name ?? intl.formatMessage({ id: "movie" })}
isNew={isNew}
classNames="col-xl-9 mt-3"
isEditing={isEditing}
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}

View File

@@ -9,7 +9,7 @@ import {
ScrapedTextAreaRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import { StudioSelect } from "src/components/Shared/Select";
import DurationUtils from "src/utils/duration";
import TextUtils from "src/utils/text";
import { useStudioCreate } from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult";
@@ -83,10 +83,10 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
);
const [duration, setDuration] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
DurationUtils.secondsToString(props.movie.duration || 0),
TextUtils.secondsToTimestamp(props.movie.duration || 0),
// convert seconds to string if it's a number
props.scraped.duration && !isNaN(+props.scraped.duration)
? DurationUtils.secondsToString(parseInt(props.scraped.duration, 10))
? TextUtils.secondsToTimestamp(parseInt(props.scraped.duration, 10))
: props.scraped.duration
)
);

View File

@@ -25,7 +25,7 @@ import {
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import FormUtils from "src/utils/form";
import * as FormUtils from "src/utils/form";
interface IListOperationProps {
selected: GQL.SlimPerformerDataFragment[];
@@ -245,8 +245,10 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
})}
<Col xs={9}>
<RatingSystem
value={updateInput.rating100 ?? undefined}
onSetRating={(value) => setUpdateField({ rating100: value })}
value={updateInput.rating100}
onSetRating={(value) =>
setUpdateField({ rating100: value ?? undefined })
}
disabled={isUpdating}
/>
</Col>

View File

@@ -573,7 +573,7 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
<div className="detail-header-image">
{encodingImage ? (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
message={intl.formatMessage({ id: "actions.encoding_image" })}
/>
) : (
renderImage()
@@ -593,8 +593,8 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
</h2>
{maybeRenderAliases()}
<RatingSystem
value={performer.rating100 ?? undefined}
onSetRating={(value) => setRating(value ?? null)}
value={performer.rating100}
onSetRating={(value) => setRating(value)}
/>
{maybeRenderDetails()}
{maybeRenderEditPanel()}

View File

@@ -44,7 +44,7 @@ const PerformerCreate: React.FC = () => {
if (encodingImage) {
return (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
message={intl.formatMessage({ id: "actions.encoding_image" })}
/>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Button, Form, Col, Row, Badge, Dropdown } from "react-bootstrap";
import { Button, Form, Badge, Dropdown } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
@@ -39,15 +39,16 @@ import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
import PerformerScrapeModal from "./PerformerScrapeModal";
import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal";
import cx from "classnames";
import {
faPlus,
faSyncAlt,
faTrashAlt,
} from "@fortawesome/free-solid-svg-icons";
import { StringListInput } from "src/components/Shared/StringListInput";
import { faPlus, faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput";
import { StashIDPill } from "src/components/Shared/StashID";
import { formikUtils } from "src/utils/form";
import {
yupFormikValidate,
yupInputNumber,
yupInputEnum,
yupDateString,
yupUniqueAliases,
} from "src/utils/yup";
const isScraper = (
scraper: GQL.Scraper | GQL.StashBox
@@ -92,71 +93,23 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const [createTag] = useTagCreate();
const intl = useIntl();
const labelXS = 3;
const labelXL = 2;
const fieldXS = 9;
const fieldXL = 7;
const schema = yup.object({
name: yup.string().required(),
disambiguation: yup.string().ensure(),
alias_list: yup
.array(yup.string().required())
.defined()
.test({
name: "unique",
test: (value, context) => {
const aliases = [context.parent.name.toLowerCase()];
const dupes: number[] = [];
for (let i = 0; i < value.length; i++) {
const a = value[i].toLowerCase();
if (aliases.includes(a)) {
dupes.push(i);
} else {
aliases.push(a);
}
}
if (dupes.length === 0) return true;
return new yup.ValidationError(dupes.join(" "), value, "alias_list");
},
}),
gender: yup.string<GQL.GenderEnum | "">().ensure(),
birthdate: yup
.string()
.ensure()
.test({
name: "date",
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;
return true;
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
}),
death_date: yup
.string()
.ensure()
.test({
name: "date",
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;
return true;
},
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
}),
alias_list: yupUniqueAliases("alias_list", "name"),
gender: yupInputEnum(GQL.GenderEnum).nullable().defined(),
birthdate: yupDateString(intl),
death_date: yupDateString(intl),
country: yup.string().ensure(),
ethnicity: yup.string().ensure(),
hair_color: yup.string().ensure(),
eye_color: yup.string().ensure(),
height_cm: yup.number().nullable().defined().default(null),
weight: yup.number().nullable().defined().default(null),
height_cm: yupInputNumber().positive().truncate().nullable().defined(),
weight: yupInputNumber().positive().truncate().nullable().defined(),
measurements: yup.string().ensure(),
fake_tits: yup.string().ensure(),
penis_length: yup.number().nullable().defined().default(null),
circumcised: yup.string<GQL.CircumisedEnum | "">().ensure(),
penis_length: yupInputNumber().positive().truncate().nullable().defined(),
circumcised: yupInputEnum(GQL.CircumisedEnum).nullable().defined(),
tattoos: yup.string().ensure(),
piercings: yup.string().ensure(),
career_length: yup.string().ensure(),
@@ -174,7 +127,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
name: performer.name ?? "",
disambiguation: performer.disambiguation ?? "",
alias_list: performer.alias_list ?? [],
gender: (performer.gender as GQL.GenderEnum) ?? "",
gender: performer.gender ?? null,
birthdate: performer.birthdate ?? "",
death_date: performer.death_date ?? "",
country: performer.country ?? "",
@@ -186,7 +139,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
measurements: performer.measurements ?? "",
fake_tits: performer.fake_tits ?? "",
penis_length: performer.penis_length ?? null,
circumcised: (performer.circumcised as GQL.CircumisedEnum) ?? "",
circumcised: performer.circumcised ?? null,
tattoos: performer.tattoos ?? "",
piercings: performer.piercings ?? "",
career_length: performer.career_length ?? "",
@@ -204,8 +157,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validationSchema: schema,
onSubmit: (values) => onSave(values),
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
});
function translateScrapedGender(scrapedGender?: string) {
@@ -240,42 +193,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
}
}
function renderNewTags() {
if (!newTags || newTags.length === 0) {
return;
}
const ret = (
<>
{newTags.map((t) => (
<Badge
className="tag-item"
variant="secondary"
key={t.name}
onClick={() => createNewTag(t)}
>
{t.name}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon={faPlus} />
</Button>
</Badge>
))}
</>
);
const minCollapseLength = 10;
if (newTags.length >= minCollapseLength) {
return (
<CollapseButton text={`Missing (${newTags.length})`}>
{ret}
</CollapseButton>
);
}
return ret;
}
async function createNewTag(toCreate: GQL.ScrapedTag) {
const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" };
try {
@@ -451,21 +368,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
ImageUtils.onImageChange(event, onImageLoad);
}
function valuesToInput(input: InputValues): GQL.PerformerCreateInput {
return {
...input,
gender: input.gender || null,
height_cm: input.height_cm || null,
weight: input.weight || null,
penis_length: input.penis_length || null,
circumcised: input.circumcised || null,
};
}
async function onSave(input: InputValues) {
setIsLoading(true);
try {
await onSubmit(valuesToInput(input));
await onSubmit(input);
formik.resetForm();
} catch (e) {
Toast.error(e);
@@ -668,7 +574,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
}
const currentPerformer = {
...valuesToInput(formik.values),
...formik.values,
image: formik.values.image ?? performer.image_path,
};
@@ -746,111 +652,98 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
) : undefined;
};
function renderTagsField() {
return (
<Form.Group controlId="tags" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}>
<FormattedMessage id="tags" defaultMessage="Tags" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<TagSelect
menuPortalTarget={document.body}
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
/>
{renderNewTags()}
</Col>
</Form.Group>
const {
renderField,
renderInputField,
renderSelectField,
renderDateField,
renderStringListField,
renderStashIDsField,
} = formikUtils(intl, formik);
function renderCountryField() {
const title = intl.formatMessage({ id: "country" });
const control = (
<CountrySelect
value={formik.values.country}
onChange={(v) => formik.setFieldValue("country", v)}
/>
);
return renderField("country", title, control);
}
const removeStashID = (stashID: GQL.StashIdInput) => {
formik.setFieldValue(
"stash_ids",
(formik.values.stash_ids ?? []).filter(
(s) =>
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
)
function renderUrlField() {
const title = intl.formatMessage({ id: "url" });
const control = (
<URLField
{...formik.getFieldProps("url")}
onScrapeClick={onScrapePerformerURL}
urlScrapable={urlScrapable}
/>
);
};
function renderStashIDs() {
if (!formik.values.stash_ids?.length) {
return renderField("url", title, control);
}
function renderNewTags() {
if (!newTags || newTags.length === 0) {
return;
}
return (
<Row>
<Form.Label column sm={labelXS} xl={labelXL}>
{intl.formatMessage({ id: "stash_ids" })}
</Form.Label>
<Col sm={fieldXS} xl={fieldXL}>
<ul className="pl-0">
{formik.values.stash_ids.map((stashID) => {
return (
<li key={stashID.stash_id} className="row no-gutters mb-1">
<Button
variant="danger"
className="mr-2 py-0"
title={intl.formatMessage({ id: "actions.delete_stashid" })}
onClick={() => removeStashID(stashID)}
>
<Icon icon={faTrashAlt} />
</Button>
<StashIDPill stashID={stashID} linkType="performers" />
</li>
);
})}
</ul>
</Col>
</Row>
const ret = (
<>
{newTags.map((t) => (
<Badge
className="tag-item"
variant="secondary"
key={t.name}
onClick={() => createNewTag(t)}
>
{t.name}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon={faPlus} />
</Button>
</Badge>
))}
</>
);
}
function renderField(
field: string,
props?: {
messageID?: string;
placeholder?: string;
type?: string;
const minCollapseLength = 10;
if (newTags.length >= minCollapseLength) {
return (
<CollapseButton text={`Missing (${newTags.length})`}>
{ret}
</CollapseButton>
);
}
) {
const title = intl.formatMessage({ id: props?.messageID ?? field });
return (
<Form.Group controlId={field} as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
{title}
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
type={props?.type ?? "text"}
className="text-input"
placeholder={props?.placeholder ?? title}
{...formik.getFieldProps(field)}
isInvalid={!!formik.getFieldMeta(field).error}
/>
<Form.Control.Feedback type="invalid">
{formik.getFieldMeta(field).error}
</Form.Control.Feedback>
</Col>
</Form.Group>
);
return ret;
}
const aliasErrors = Array.isArray(formik.errors.alias_list)
? formik.errors.alias_list[0]
: formik.errors.alias_list;
const aliasErrorMsg = aliasErrors
? intl.formatMessage({ id: "validation.aliases_must_be_unique" })
: undefined;
const aliasErrorIdx = aliasErrors?.split(" ").map((e) => parseInt(e));
function renderTagsField() {
const title = intl.formatMessage({ id: "tags" });
const control = (
<>
<TagSelect
menuPortalTarget={document.body}
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
/>
{renderNewTags()}
</>
);
return renderField("tag_ids", title, control);
}
return (
<>
@@ -864,231 +757,51 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{renderButtons("mb-3")}
<Form noValidate onSubmit={formik.handleSubmit} id="performer-edit">
<Form.Group controlId="name" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="name" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
className="text-input"
placeholder={intl.formatMessage({ id: "name" })}
{...formik.getFieldProps("name")}
isInvalid={!!formik.errors.name}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.name}
</Form.Control.Feedback>
</Col>
</Form.Group>
{renderInputField("name")}
{renderInputField("disambiguation")}
<Form.Group controlId="disambiguation" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="disambiguation" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
className="text-input"
placeholder={intl.formatMessage({ id: "disambiguation" })}
{...formik.getFieldProps("disambiguation")}
isInvalid={!!formik.errors.disambiguation}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.disambiguation}
</Form.Control.Feedback>
</Col>
</Form.Group>
{renderStringListField(
"alias_list",
"validation.aliases_must_be_unique",
"aliases"
)}
<Form.Group controlId="aliases" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="aliases" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<StringListInput
value={formik.values.alias_list ?? []}
setValue={(value) => formik.setFieldValue("alias_list", value)}
errors={aliasErrorMsg}
errorIdx={aliasErrorIdx}
/>
</Col>
</Form.Group>
{renderSelectField("gender", stringGenderMap)}
<Form.Group as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="gender" />
</Form.Label>
<Col xs="auto">
<Form.Control
as="select"
className="input-control"
{...formik.getFieldProps("gender")}
>
<option value="" key=""></option>
{Array.from(stringGenderMap.entries()).map(([name, value]) => (
<option value={value} key={value}>
{name}
</option>
))}
</Form.Control>
</Col>
</Form.Group>
{renderDateField("birthdate")}
{renderDateField("death_date")}
<Form.Group controlId="birthdate" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="birthdate" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<DateInput
value={formik.values.birthdate}
onValueChange={(value) =>
formik.setFieldValue("birthdate", value)
}
error={formik.errors.birthdate}
/>
</Col>
</Form.Group>
{renderCountryField()}
<Form.Group controlId="death_date" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="death_date" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<DateInput
value={formik.values.death_date}
onValueChange={(value) =>
formik.setFieldValue("death_date", value)
}
error={formik.errors.death_date}
/>
</Col>
</Form.Group>
{renderInputField("ethnicity")}
{renderInputField("hair_color")}
{renderInputField("eye_color")}
{renderInputField("height_cm", "number")}
{renderInputField("weight", "number", "weight_kg")}
{renderInputField("penis_length", "number", "penis_length_cm")}
<Form.Group as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="country" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<CountrySelect
value={formik.getFieldProps("country").value}
onChange={(value) => formik.setFieldValue("country", value)}
/>
</Col>
</Form.Group>
{renderSelectField("circumcised", stringCircumMap)}
{renderField("ethnicity")}
{renderField("hair_color")}
{renderField("eye_color")}
{renderField("height_cm", {
type: "number",
})}
{renderField("weight", {
type: "number",
messageID: "weight_kg",
})}
{renderField("penis_length", {
type: "number",
messageID: "penis_length_cm",
})}
{renderInputField("measurements")}
{renderInputField("fake_tits")}
<Form.Group as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="circumcised" />
</Form.Label>
<Col xs="auto">
<Form.Control
as="select"
className="input-control"
{...formik.getFieldProps("circumcised")}
>
<option value="" key=""></option>
{Array.from(stringCircumMap.entries()).map(([name, value]) => (
<option value={value} key={value}>
{name}
</option>
))}
</Form.Control>
</Col>
</Form.Group>
{renderInputField("tattoos", "textarea")}
{renderInputField("piercings", "textarea")}
{renderField("measurements")}
{renderField("fake_tits")}
{renderInputField("career_length")}
<Form.Group controlId="tattoos" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}>
<FormattedMessage id="tattoos" />
</Form.Label>
<Col sm={fieldXS} xl={fieldXL}>
<Form.Control
as="textarea"
className="text-input"
placeholder={intl.formatMessage({ id: "tattoos" })}
{...formik.getFieldProps("tattoos")}
/>
</Col>
</Form.Group>
{renderUrlField()}
<Form.Group controlId="piercings" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}>
<FormattedMessage id="piercings" />
</Form.Label>
<Col sm={fieldXS} xl={fieldXL}>
<Form.Control
as="textarea"
className="text-input"
placeholder={intl.formatMessage({ id: "piercings" })}
{...formik.getFieldProps("piercings")}
/>
</Col>
</Form.Group>
{renderField("career_length")}
<Form.Group controlId="url" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="url" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<URLField
{...formik.getFieldProps("url")}
onScrapeClick={onScrapePerformerURL}
urlScrapable={urlScrapable}
/>
</Col>
</Form.Group>
{renderField("twitter")}
{renderField("instagram")}
<Form.Group controlId="details" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}>
<FormattedMessage id="details" />
</Form.Label>
<Col sm={fieldXS} xl={fieldXL}>
<Form.Control
as="textarea"
className="text-input"
placeholder={intl.formatMessage({ id: "details" })}
{...formik.getFieldProps("details")}
/>
</Col>
</Form.Group>
{renderInputField("twitter")}
{renderInputField("instagram")}
{renderInputField("details", "textarea")}
{renderTagsField()}
{renderStashIDs()}
{renderStashIDsField("stash_ids", "performers")}
<hr />
<Form.Group controlId="ignore-auto-tag" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}>
<FormattedMessage id="ignore_auto_tag" />
</Form.Label>
<Col sm={fieldXS} xl={fieldXL}>
<Form.Check
{...formik.getFieldProps({
name: "ignore_auto_tag",
type: "checkbox",
})}
/>
</Col>
</Form.Group>
{renderInputField("ignore_auto_tag", "checkbox")}
{renderButtons("mt-3")}
</Form>

View File

@@ -212,7 +212,11 @@ export const PerformerSelect: React.FC<
props.noSelectionString ??
intl.formatMessage(
{ id: "actions.select_entity" },
{ entityType: intl.formatMessage({ id: "performer" }) }
{
entityType: intl.formatMessage({
id: props.isMulti ? "performers" : "performer",
}),
}
)
}
/>

View File

@@ -8,7 +8,7 @@ import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
import { MultiSet } from "../Shared/MultiSet";
import { useToast } from "src/hooks/Toast";
import FormUtils from "src/utils/form";
import * as FormUtils from "src/utils/form";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputIDs,
@@ -272,7 +272,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
<Col xs={9}>
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value)}
onSetRating={(value) => setRating(value ?? undefined)}
disabled={isUpdating}
/>
</Col>

View File

@@ -28,34 +28,30 @@ import {
import { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { ImageInput } from "src/components/Shared/ImageInput";
import { URLListInput } from "src/components/Shared/URLField";
import { useToast } from "src/hooks/Toast";
import ImageUtils from "src/utils/image";
import FormUtils from "src/utils/form";
import { getStashIDs } from "src/utils/stashIds";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
import { ConfigurationContext } from "src/hooks/Config";
import { stashboxDisplayName } from "src/utils/stashbox";
import { SceneMovieTable } from "./SceneMovieTable";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import {
faSearch,
faSyncAlt,
faTrashAlt,
} from "@fortawesome/free-solid-svg-icons";
import { faSearch, faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { lazyComponent } from "src/utils/lazyComponent";
import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput";
import { yupDateString, yupUniqueStringList } from "src/utils/yup";
import {
yupDateString,
yupFormikValidate,
yupUniqueStringList,
} from "src/utils/yup";
import {
Performer,
PerformerSelect,
} from "src/components/Performers/PerformerSelect";
import { StashIDPill } from "src/components/Shared/StashID";
import { formikUtils } from "src/utils/form";
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
@@ -119,7 +115,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
urls: yupUniqueStringList("urls"),
date: yupDateString(intl),
director: yup.string().ensure(),
rating100: yup.number().nullable().defined(),
rating100: yup.number().integer().nullable().defined(),
gallery_ids: yup.array(yup.string().required()).defined(),
studio_id: yup.string().required().nullable(),
performer_ids: yup.array(yup.string().required()).defined(),
@@ -127,7 +123,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
.array(
yup.object({
movie_id: yup.string().required(),
scene_index: yup.number().nullable().defined(),
scene_index: yup.number().integer().nullable().defined(),
})
)
.defined(),
@@ -164,8 +160,8 @@ export const SceneEditPanel: React.FC<IProps> = ({
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validationSchema: schema,
onSubmit: (values) => onSave(values),
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
});
const coverImagePreview = useMemo(() => {
@@ -275,16 +271,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
setIsLoading(false);
}
const removeStashID = (stashID: GQL.StashIdInput) => {
formik.setFieldValue(
"stash_ids",
formik.values.stash_ids.filter(
(s) =>
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
)
);
};
function renderTableMovies() {
return (
<SceneMovieTable
@@ -656,32 +642,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
}
}
function renderTextField(field: string, title: string, placeholder?: string) {
return (
<Form.Group controlId={field} as={Row}>
{FormUtils.renderLabel({
title,
})}
<Col xs={9}>
<Form.Control
className="text-input"
placeholder={placeholder ?? title}
{...formik.getFieldProps(field)}
isInvalid={!!formik.getFieldMeta(field).error}
/>
<Form.Control.Feedback type="invalid">
{formik.getFieldMeta(field).error}
</Form.Control.Feedback>
</Col>
</Form.Group>
);
}
const image = useMemo(() => {
if (encodingImage) {
return (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
message={intl.formatMessage({ id: "actions.encoding_image" })}
/>
);
}
@@ -701,13 +666,124 @@ export const SceneEditPanel: React.FC<IProps> = ({
if (isLoading) return <LoadingIndicator />;
const urlsErrors = Array.isArray(formik.errors.urls)
? formik.errors.urls[0]
: formik.errors.urls;
const urlsErrorMsg = urlsErrors
? intl.formatMessage({ id: "validation.urls_must_be_unique" })
: undefined;
const urlsErrorIdx = urlsErrors?.split(" ").map((e) => parseInt(e));
const splitProps = {
labelProps: {
column: true,
sm: 3,
},
fieldProps: {
sm: 9,
},
};
const fullWidthProps = {
labelProps: {
column: true,
sm: 3,
xl: 12,
},
fieldProps: {
sm: 9,
xl: 12,
},
};
const {
renderField,
renderInputField,
renderDateField,
renderRatingField,
renderURLListField,
renderStashIDsField,
} = formikUtils(intl, formik, splitProps);
function renderGalleriesField() {
const title = intl.formatMessage({ id: "galleries" });
const control = (
<GallerySelect
selected={galleries}
onSelect={(items) => onSetGalleries(items)}
isMulti
/>
);
return renderField("gallery_ids", title, control);
}
function renderStudioField() {
const title = intl.formatMessage({ id: "studio" });
const control = (
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
/>
);
return renderField("studio_id", title, control);
}
function renderPerformersField() {
const title = intl.formatMessage({ id: "performers" });
const control = (
<PerformerSelect isMulti onSelect={onSetPerformers} values={performers} />
);
return renderField("performer_ids", title, control, fullWidthProps);
}
function renderMoviesField() {
const title = intl.formatMessage({ id: "movies" });
const control = (
<>
<MovieSelect
isMulti
onSelect={(items) => setMovieIds(items.map((item) => item.id))}
ids={formik.values.movies.map((m) => m.movie_id)}
/>
{renderTableMovies()}
</>
);
return renderField("movies", title, control, fullWidthProps);
}
function renderTagsField() {
const title = intl.formatMessage({ id: "tags" });
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
hoverPlacement="right"
/>
);
return renderField("tag_ids", title, control, fullWidthProps);
}
function renderDetailsField() {
const props = {
labelProps: {
column: true,
sm: 3,
lg: 12,
},
fieldProps: {
sm: 9,
lg: 12,
},
};
return renderInputField("details", "textarea", "details", props);
}
return (
<div id="scene-edit-details">
@@ -719,7 +795,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
{renderScrapeQueryModal()}
{maybeRenderScrapeDialog()}
<Form noValidate onSubmit={formik.handleSubmit}>
<div className="form-container edit-buttons-container row px-3 pt-3">
<Row className="form-container edit-buttons-container px-3 pt-3">
<div className="edit-buttons mb-3 pl-0">
<Button
className="edit-button"
@@ -742,223 +818,58 @@ export const SceneEditPanel: React.FC<IProps> = ({
)}
</div>
{!isNew && (
<div className="ml-auto pr-3 text-right d-flex">
<div className="ml-auto text-right d-flex">
<ButtonGroup className="scraper-group">
{renderScraperMenu()}
{renderScrapeQueryMenu()}
</ButtonGroup>
</div>
)}
</div>
<div className="form-container row px-3">
<div className="col-12 col-lg-7 col-xl-12">
{renderTextField("title", intl.formatMessage({ id: "title" }))}
{renderTextField("code", intl.formatMessage({ id: "scene_code" }))}
<Form.Group controlId="urls" as={Row}>
<Col xs={3} className="pr-0 url-label">
<Form.Label className="col-form-label">
<FormattedMessage id="urls" />
</Form.Label>
</Col>
<Col xs={9}>
<URLListInput
value={formik.values.urls ?? []}
setValue={(value) => formik.setFieldValue("urls", value)}
errors={urlsErrorMsg}
errorIdx={urlsErrorIdx}
onScrapeClick={(url) => onScrapeSceneURL(url)}
urlScrapable={urlScrapable}
/>
</Col>
</Form.Group>
<Form.Group controlId="date" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "date" }),
})}
<Col xs={9}>
<DateInput
value={formik.values.date}
onValueChange={(value) => formik.setFieldValue("date", value)}
error={formik.errors.date}
/>
</Col>
</Form.Group>
{renderTextField(
"director",
intl.formatMessage({ id: "director" })
</Row>
<Row className="form-container px-3">
<Col lg={7} xl={12}>
{renderInputField("title")}
{renderInputField("code", "text", "scene_code")}
{renderURLListField(
"urls",
"validation.urls_must_be_unique",
onScrapeSceneURL,
urlScrapable
)}
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={formik.values.rating100 ?? undefined}
onSetRating={(value) =>
formik.setFieldValue("rating100", value ?? null)
}
/>
</Col>
</Form.Group>
<Form.Group controlId="galleries" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "galleries" }),
labelProps: {
column: true,
sm: 3,
},
})}
<Col sm={9}>
<GallerySelect
selected={galleries}
onSelect={(items) => onSetGalleries(items)}
isMulti
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
labelProps: {
column: true,
sm: 3,
},
})}
<Col sm={9}>
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
/>
</Col>
</Form.Group>
<Form.Group controlId="performers" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "performers" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<PerformerSelect
isMulti
onSelect={onSetPerformers}
values={performers}
/>
</Col>
</Form.Group>
<Form.Group controlId="moviesScenes" as={Row}>
{FormUtils.renderLabel({
title: `${intl.formatMessage({
id: "movies",
})}/${intl.formatMessage({ id: "scenes" })}`,
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<MovieSelect
isMulti
onSelect={(items) =>
setMovieIds(items.map((item) => item.id))
}
ids={formik.values.movies.map((m) => m.movie_id)}
/>
{renderTableMovies()}
</Col>
</Form.Group>
<Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "tags" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
hoverPlacement="right"
/>
</Col>
</Form.Group>
{formik.values.stash_ids.length ? (
<Form.Group controlId="stashIDs">
<Form.Label>
<FormattedMessage id="stash_ids" />
</Form.Label>
<ul className="pl-0">
{formik.values.stash_ids.map((stashID) => {
return (
<li key={stashID.stash_id} className="row no-gutters">
<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="scenes" />
</li>
);
})}
</ul>
</Form.Group>
) : undefined}
</div>
<div className="col-12 col-lg-5 col-xl-12">
<Form.Group controlId="details">
{renderDateField("date")}
{renderInputField("director")}
{renderRatingField("rating100", "rating")}
{renderGalleriesField()}
{renderStudioField()}
{renderPerformersField()}
{renderMoviesField()}
{renderTagsField()}
{renderStashIDsField(
"stash_ids",
"scenes",
"stash_ids",
fullWidthProps
)}
</Col>
<Col lg={5} xl={12}>
{renderDetailsField()}
<Form.Group controlId="cover_image">
<Form.Label>
<FormattedMessage id="details" />
<FormattedMessage id="cover_image" />
</Form.Label>
<Form.Control
as="textarea"
className="scene-description text-input"
onChange={(e) =>
formik.setFieldValue("details", e.currentTarget.value)
}
value={formik.values.details ?? ""}
{image}
<ImageInput
isEditing
onImageChange={onCoverImageChange}
onImageURL={onImageLoad}
/>
</Form.Group>
<div>
<Form.Group controlId="cover">
<Form.Label>
<FormattedMessage id="cover_image" />
</Form.Label>
{image}
<ImageInput
isEditing
onImageChange={onCoverImageChange}
onImageURL={onImageLoad}
/>
</Form.Group>
</div>
</div>
</div>
</Col>
</Row>
</Form>
</div>
);

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from "react";
import { Button, Form } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl";
import { useFormik } from "formik";
import * as yup from "yup";
import * as GQL from "src/core/generated-graphql";
@@ -17,6 +17,9 @@ import {
} from "src/components/Shared/Select";
import { getPlayerPosition } from "src/components/ScenePlayer/util";
import { useToast } from "src/hooks/Toast";
import isEqual from "lodash-es/isEqual";
import { formikUtils } from "src/utils/form";
import { yupFormikValidate } from "src/utils/yup";
interface ISceneMarkerForm {
sceneID: string;
@@ -29,6 +32,8 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
marker,
onClose,
}) => {
const intl = useIntl();
const [sceneMarkerCreate] = useSceneMarkerCreate();
const [sceneMarkerUpdate] = useSceneMarkerUpdate();
const [sceneMarkerDestroy] = useSceneMarkerDestroy();
@@ -38,7 +43,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
const schema = yup.object({
title: yup.string().ensure(),
seconds: yup.number().required(),
seconds: yup.number().min(0).required(),
primary_tag_id: yup.string().required(),
tag_ids: yup.array(yup.string().required()).defined(),
});
@@ -58,9 +63,9 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
const formik = useFormik<InputValues>({
initialValues,
validationSchema: schema,
enableReinitialize: true,
onSubmit: (values) => onSave(values),
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
});
async function onSave(input: InputValues) {
@@ -105,84 +110,112 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
await formik.setFieldTouched("primary_tag_id", true);
}
const primaryTagId = formik.values.primary_tag_id;
const splitProps = {
labelProps: {
column: true,
sm: 3,
},
fieldProps: {
sm: 9,
},
};
const fullWidthProps = {
labelProps: {
column: true,
sm: 3,
xl: 12,
},
fieldProps: {
sm: 9,
xl: 12,
},
};
const { renderField } = formikUtils(intl, formik, splitProps);
function renderTitleField() {
const title = intl.formatMessage({ id: "title" });
const control = (
<MarkerTitleSuggest
initialMarkerTitle={formik.values.title}
onChange={(v) => formik.setFieldValue("title", v)}
/>
);
return renderField("title", title, control);
}
function renderPrimaryTagField() {
const primaryTagId = formik.values.primary_tag_id;
const title = intl.formatMessage({ id: "primary_tag" });
const control = (
<>
<TagSelect
onSelect={onSetPrimaryTagID}
ids={primaryTagId ? [primaryTagId] : []}
hoverPlacement="right"
/>
{formik.touched.primary_tag_id && (
<Form.Control.Feedback type="invalid">
{formik.errors.primary_tag_id}
</Form.Control.Feedback>
)}
</>
);
return renderField("primary_tag_id", title, control);
}
function renderTimeField() {
const { error } = formik.getFieldMeta("seconds");
const title = intl.formatMessage({ id: "time" });
const control = (
<DurationInput
value={formik.values.seconds}
setValue={(v) => formik.setFieldValue("seconds", v)}
onReset={() =>
formik.setFieldValue("seconds", Math.round(getPlayerPosition() ?? 0))
}
error={error}
/>
);
return renderField("seconds", title, control);
}
function renderTagsField() {
const title = intl.formatMessage({ id: "tags" });
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
hoverPlacement="right"
/>
);
return renderField("tag_ids", title, control, fullWidthProps);
}
return (
<Form noValidate onSubmit={formik.handleSubmit}>
<div>
<Form.Group className="row">
<Form.Label className="col-sm-3 col-md-2 col-xl-12 col-form-label">
Marker Title
</Form.Label>
<div className="col-sm-9 col-md-10 col-xl-12">
<MarkerTitleSuggest
initialMarkerTitle={formik.values.title}
onChange={(query: string) => formik.setFieldValue("title", query)}
/>
</div>
</Form.Group>
<Form.Group className="row">
<Form.Label className="col-sm-3 col-md-2 col-xl-12 col-form-label">
Primary Tag
</Form.Label>
<div className="col-sm-4 col-md-6 col-xl-12 mb-3 mb-sm-0 mb-xl-3">
<TagSelect
onSelect={onSetPrimaryTagID}
ids={primaryTagId ? [primaryTagId] : []}
noSelectionString="Select/create tag..."
hoverPlacement="right"
/>
{formik.touched.primary_tag_id && (
<Form.Control.Feedback type="invalid">
{formik.errors.primary_tag_id}
</Form.Control.Feedback>
)}
</div>
<div className="col-sm-5 col-md-4 col-xl-12">
<div className="row">
<Form.Label className="col-sm-4 col-md-4 col-xl-12 col-form-label text-sm-right text-xl-left">
Time
</Form.Label>
<div className="col-sm-8 col-xl-12">
<DurationInput
value={formik.values.seconds ?? 0}
setValue={(v) => formik.setFieldValue("seconds", v ?? null)}
onReset={() =>
formik.setFieldValue(
"seconds",
Math.round(getPlayerPosition() ?? 0)
)
}
/>
</div>
</div>
</div>
</Form.Group>
<Form.Group className="row">
<Form.Label className="col-sm-3 col-md-2 col-xl-12 col-form-label">
Tags
</Form.Label>
<div className="col-sm-9 col-md-10 col-xl-12">
<TagSelect
isMulti
onSelect={(tags) =>
formik.setFieldValue(
"tag_ids",
tags.map((tag) => tag.id)
)
}
ids={formik.values.tag_ids}
noSelectionString="Select/create tags..."
hoverPlacement="right"
/>
</div>
</Form.Group>
<div className="form-container px-3">
{renderTitleField()}
{renderPrimaryTagField()}
{renderTimeField()}
{renderTagsField()}
</div>
<div className="buttons-container row">
<div className="col d-flex">
<div className="buttons-container px-3">
<div className="d-flex">
<Button
variant="primary"
disabled={!isNew && !formik.dirty}
disabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />

View File

@@ -4,7 +4,7 @@ import * as GQL from "src/core/generated-graphql";
import { Icon } from "../Shared/Icon";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { StringListSelect, GallerySelect, SceneSelect } from "../Shared/Select";
import FormUtils from "src/utils/form";
import * as FormUtils from "src/utils/form";
import ImageUtils from "src/utils/image";
import TextUtils from "src/utils/text";
import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService";

View File

@@ -67,10 +67,6 @@
.tab-content {
min-height: 15rem;
}
.scene-description {
width: 100%;
}
}
textarea.scene-description {

View File

@@ -14,7 +14,7 @@ import {
StringSetting,
} from "../Inputs";
import { useSettings } from "../context";
import DurationUtils from "src/utils/duration";
import TextUtils from "src/utils/text";
import * as GQL from "src/core/generated-graphql";
import {
imageLightboxDisplayModeIntlMap,
@@ -361,7 +361,7 @@ export const SettingsInterfacePanel: React.FC = () => {
/>
)}
renderValue={(v) => {
return <span>{DurationUtils.secondsToString(v ?? 0)}</span>;
return <span>{TextUtils.secondsToTimestamp(v ?? 0)}</span>;
}}
/>

View File

@@ -1,5 +1,5 @@
import { faCalendar } from "@fortawesome/free-regular-svg-icons";
import React, { useMemo } from "react";
import React, { forwardRef, useMemo } from "react";
import { Button, InputGroup, Form } from "react-bootstrap";
import ReactDatePicker from "react-datepicker";
import TextUtils from "src/utils/text";
@@ -10,13 +10,24 @@ import { useIntl } from "react-intl";
interface IProps {
disabled?: boolean;
value: string | undefined;
value: string;
isTime?: boolean;
onValueChange(value: string): void;
placeholder?: string;
error?: string;
}
const ShowPickerButton = forwardRef<
HTMLButtonElement,
{
onClick: (event: React.MouseEvent) => void;
}
>(({ onClick }, ref) => (
<Button variant="secondary" onClick={onClick} ref={ref}>
<Icon icon={faCalendar} />
</Button>
));
export const DateInput: React.FC<IProps> = (props: IProps) => {
const intl = useIntl();
@@ -26,28 +37,14 @@ export const DateInput: React.FC<IProps> = (props: IProps) => {
: TextUtils.stringToFuzzyDate;
if (props.value) {
const ret = toDate(props.value);
if (!ret || isNaN(ret.getTime())) {
return undefined;
if (ret && !Number.isNaN(ret.getTime())) {
return ret;
}
return ret;
}
}, [props.value, props.isTime]);
function maybeRenderButton() {
if (!props.disabled) {
const ShowPickerButton = ({
onClick,
}: {
onClick: (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
}) => (
<Button variant="secondary" onClick={onClick}>
<Icon icon={faCalendar} />
</Button>
);
const dateToString = props.isTime
? TextUtils.dateTimeToString
: TextUtils.dateToString;
@@ -83,9 +80,7 @@ export const DateInput: React.FC<IProps> = (props: IProps) => {
className="date-input text-input"
disabled={props.disabled}
value={props.value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
props.onValueChange(e.currentTarget.value)
}
onChange={(e) => props.onValueChange(e.currentTarget.value)}
placeholder={
!props.disabled
? props.placeholder

View File

@@ -6,15 +6,17 @@ import {
import React, { useState } from "react";
import { Button, ButtonGroup, InputGroup, Form } from "react-bootstrap";
import { Icon } from "./Icon";
import DurationUtils from "src/utils/duration";
import TextUtils from "src/utils/text";
interface IProps {
disabled?: boolean;
value: number | undefined;
setValue(value: number | undefined): void;
value: number | null | undefined;
setValue(value: number | null): void;
onReset?(): void;
className?: string;
placeholder?: string;
error?: string;
allowNegative?: boolean;
}
export const DurationInput: React.FC<IProps> = ({
@@ -24,6 +26,8 @@ export const DurationInput: React.FC<IProps> = ({
onReset,
className,
placeholder,
error,
allowNegative = false,
}) => {
const [tmpValue, setTmpValue] = useState<string>();
@@ -33,19 +37,30 @@ export const DurationInput: React.FC<IProps> = ({
function onBlur() {
if (tmpValue !== undefined) {
setValue(DurationUtils.stringToSeconds(tmpValue));
updateValue(TextUtils.timestampToSeconds(tmpValue));
setTmpValue(undefined);
}
}
function updateValue(v: number | null) {
if (v !== null && !allowNegative && v < 0) {
v = null;
}
setValue(v);
}
function increment() {
setTmpValue(undefined);
setValue((value ?? 0) + 1);
updateValue((value ?? 0) + 1);
}
function decrement() {
setTmpValue(undefined);
setValue((value ?? 0) - 1);
if (allowNegative) {
updateValue((value ?? 0) - 1);
} else {
updateValue(value ? value - 1 : 0);
}
}
function renderButtons() {
@@ -84,8 +99,14 @@ export const DurationInput: React.FC<IProps> = ({
let inputValue = "";
if (tmpValue !== undefined) {
inputValue = tmpValue;
} else if (value !== undefined) {
inputValue = DurationUtils.secondsToString(value);
} else if (value !== null && value !== undefined) {
inputValue = TextUtils.secondsToTimestamp(value);
}
if (placeholder) {
placeholder = `${placeholder} (hh:mm:ss)`;
} else {
placeholder = "hh:mm:ss";
}
return (
@@ -97,12 +118,13 @@ export const DurationInput: React.FC<IProps> = ({
value={inputValue}
onChange={onChange}
onBlur={onBlur}
placeholder={placeholder ? `${placeholder} (hh:mm:ss)` : "hh:mm:ss"}
placeholder={placeholder}
/>
<InputGroup.Append>
{maybeRenderReset()}
{renderButtons()}
</InputGroup.Append>
<Form.Control.Feedback type="invalid">{error}</Form.Control.Feedback>
</InputGroup>
</div>
);

View File

@@ -1,8 +1,8 @@
import React, { useRef } from "react";
export interface IRatingNumberProps {
value?: number;
onSetRating?: (value?: number) => void;
value: number | null;
onSetRating?: (value: number | null) => void;
disabled?: boolean;
}
@@ -42,7 +42,7 @@ export const RatingNumber: React.FC<IRatingNumberProps> = (
if (!useValidation.current) {
e.target.value = Number(val).toFixed(1);
const tempVal = Number(val) * 10;
props.onSetRating(tempVal != 0 ? tempVal : undefined);
props.onSetRating(tempVal || null);
useValidation.current = true;
return;
}
@@ -70,7 +70,7 @@ export const RatingNumber: React.FC<IRatingNumberProps> = (
}
e.target.value = Number(value).toFixed(1);
let tempVal = Number(value) * 10;
props.onSetRating(tempVal != 0 ? tempVal : undefined);
props.onSetRating(tempVal || null);
let cursorPosition = 0;
if (match[2] && !match[4]) {

View File

@@ -13,8 +13,8 @@ import {
import { useIntl } from "react-intl";
export interface IRatingStarsProps {
value?: number;
onSetRating?: (value?: number) => void;
value: number | null;
onSetRating?: (value: number | null) => void;
disabled?: boolean;
precision: RatingStarPrecision;
valueRequired?: boolean;
@@ -87,7 +87,7 @@ export const RatingStars: React.FC<IRatingStarsProps> = (
setHoverRating(undefined);
if (!newRating) {
props.onSetRating(undefined);
props.onSetRating(null);
return;
}

View File

@@ -10,8 +10,8 @@ import { RatingNumber } from "./RatingNumber";
import { RatingStars } from "./RatingStars";
export interface IRatingSystemProps {
value?: number;
onSetRating?: (value?: number) => void;
value: number | null | undefined;
onSetRating?: (value: number | null) => void;
disabled?: boolean;
valueRequired?: boolean;
}
@@ -24,10 +24,10 @@ export const RatingSystem: React.FC<IRatingSystemProps> = (
(config?.ui as IUIConfig)?.ratingSystemOptions ??
defaultRatingSystemOptions;
function getRatingStars() {
if (ratingSystemOptions.type === RatingSystemType.Stars) {
return (
<RatingStars
value={props.value}
value={props.value ?? null}
onSetRating={props.onSetRating}
disabled={props.disabled}
precision={
@@ -36,14 +36,10 @@ export const RatingSystem: React.FC<IRatingSystemProps> = (
valueRequired={props.valueRequired}
/>
);
}
if (ratingSystemOptions.type === RatingSystemType.Stars) {
return getRatingStars();
} else {
return (
<RatingNumber
value={props.value}
value={props.value ?? null}
onSetRating={props.onSetRating}
disabled={props.disabled}
/>

View File

@@ -5,7 +5,7 @@ import { useToast } from "src/hooks/Toast";
import { useIntl } from "react-intl";
import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons";
import { Col, Form, Row } from "react-bootstrap";
import FormUtils from "src/utils/form";
import * as FormUtils from "src/utils/form";
import { mutateSceneAssignFile } from "src/core/StashService";
interface IFile {

View File

@@ -662,7 +662,11 @@ export const StudioSelect: React.FC<
props.noSelectionString ??
intl.formatMessage(
{ id: "actions.select_entity" },
{ entityType: intl.formatMessage({ id: "studio" }) }
{
entityType: intl.formatMessage({
id: props.isMulti ? "studios" : "studio",
}),
}
)
}
creatable={props.creatable ?? defaultCreatable}
@@ -705,7 +709,11 @@ export const MovieSelect: React.FC<IFilterProps> = (props) => {
props.noSelectionString ??
intl.formatMessage(
{ id: "actions.select_entity" },
{ entityType: intl.formatMessage({ id: "movie" }) }
{
entityType: intl.formatMessage({
id: props.isMulti ? "movies" : "movie",
}),
}
)
}
creatable={props.creatable ?? defaultCreatable}
@@ -726,7 +734,7 @@ export const TagSelect: React.FC<
props.noSelectionString ??
intl.formatMessage(
{ id: "actions.select_entity" },
{ entityType: intl.formatMessage({ id: "tags" }) }
{ entityType: intl.formatMessage({ id: props.isMulti ? "tags" : "tag" }) }
);
const { configuration } = React.useContext(ConfigurationContext);

View File

@@ -3,7 +3,7 @@ import { StashId } from "src/core/generated-graphql";
import { ConfigurationContext } from "src/hooks/Config";
import { getStashboxBase } from "src/utils/stashbox";
type LinkType = "performers" | "scenes" | "studios";
export type LinkType = "performers" | "scenes" | "studios";
export const StashIDPill: React.FC<{
stashID: StashId;

View File

@@ -235,17 +235,15 @@ export const TagLink: React.FC<ITagLinkProps> = ({
return (
<CommonLinkComponent link={link} className={className}>
<TagPopover id={tag.id ?? ""} placement={hoverPlacement}>
<Link to={link}>
{title}
{showHierarchyIcon && (
<OverlayTrigger placement="top" overlay={tooltip}>
<span className="icon-wrapper">
<span className="vertical-line">|</span>
<Icon icon={faFolderTree} className="tag-icon" />
</span>
</OverlayTrigger>
)}
</Link>
{title}
{showHierarchyIcon && (
<OverlayTrigger placement="top" overlay={tooltip}>
<span className="icon-wrapper">
<span className="vertical-line">|</span>
<Icon icon={faFolderTree} className="tag-icon" />
</span>
</OverlayTrigger>
)}
</TagPopover>
</CommonLinkComponent>
);

View File

@@ -526,7 +526,7 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
<div className="detail-header-image">
{encodingImage ? (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
message={intl.formatMessage({ id: "actions.encoding_image" })}
/>
) : (
renderImage()
@@ -541,8 +541,8 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
</h2>
{maybeRenderAliases()}
<RatingSystem
value={studio.rating100 ?? undefined}
onSetRating={(value) => setRating(value ?? null)}
value={studio.rating100}
onSetRating={(value) => setRating(value)}
/>
{maybeRenderDetails()}
{maybeRenderEditPanel()}

View File

@@ -59,7 +59,7 @@ const StudioCreate: React.FC = () => {
<div className="text-center">
{encodingImage ? (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
message={intl.formatMessage({ id: "actions.encoding_image" })}
/>
) : (
renderImage()

View File

@@ -1,23 +1,21 @@
import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import Mousetrap from "mousetrap";
import { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { StudioSelect } from "src/components/Shared/Select";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { Button, Form, Col, Row } from "react-bootstrap";
import { Form } from "react-bootstrap";
import ImageUtils from "src/utils/image";
import { getStashIDs } from "src/utils/stashIds";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
import { StringListInput } from "../../Shared/StringListInput";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
import isEqual from "lodash-es/isEqual";
import { useToast } from "src/hooks/Toast";
import { handleUnsavedChanges } from "src/utils/navigation";
import { StashIDPill } from "src/components/Shared/StashID";
import { formikUtils } from "src/utils/form";
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>;
@@ -41,11 +39,6 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
const isNew = studio.id === undefined;
const labelXS = 3;
const labelXL = 2;
const fieldXS = 9;
const fieldXL = 7;
// Network state
const [isLoading, setIsLoading] = useState(false);
@@ -54,26 +47,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
url: yup.string().ensure(),
details: yup.string().ensure(),
parent_id: yup.string().required().nullable(),
aliases: yup
.array(yup.string().required())
.defined()
.test({
name: "unique",
test: (value, context) => {
const aliases = [context.parent.name, ...value];
const dupes = aliases
.map((e, i, a) => {
if (a.indexOf(e) !== i) {
return String(i - 1);
} else {
return null;
}
})
.filter((e) => e !== null) as string[];
if (dupes.length === 0) return true;
return new yup.ValidationError(dupes.join(" "), value, "aliases");
},
}),
aliases: yupUniqueAliases("aliases", "name"),
ignore_auto_tag: yup.boolean().defined(),
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
image: yup.string().nullable().optional(),
@@ -95,8 +69,8 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
const formik = useFormik<InputValues>({
initialValues,
enableReinitialize: true,
validationSchema: schema,
onSubmit: (values) => onSave(values),
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
});
const encodingImage = ImageUtils.usePasteImage((imageData) =>
@@ -143,60 +117,30 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
ImageUtils.onImageChange(event, onImageLoad);
}
const removeStashID = (stashID: GQL.StashIdInput) => {
formik.setFieldValue(
"stash_ids",
(formik.values.stash_ids ?? []).filter(
(s) =>
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
)
);
};
const {
renderField,
renderInputField,
renderStringListField,
renderStashIDsField,
} = formikUtils(intl, formik);
function renderStashIDs() {
if (!formik.values.stash_ids?.length) {
return;
}
return (
<Row>
<Form.Label column xs={labelXS} xl={labelXL}>
{intl.formatMessage({ id: "stash_ids" })}
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<ul className="pl-0">
{formik.values.stash_ids.map((stashID) => {
return (
<li key={stashID.stash_id} className="row no-gutters">
<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="studios" />
</li>
);
})}
</ul>
</Col>
</Row>
function renderParentStudioField() {
const title = intl.formatMessage({ id: "parent_studio" });
const control = (
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"parent_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.parent_id ? [formik.values.parent_id] : []}
/>
);
return renderField("parent_id", title, control);
}
const aliasErrors = Array.isArray(formik.errors.aliases)
? formik.errors.aliases[0]
: formik.errors.aliases;
const aliasErrorMsg = aliasErrors
? intl.formatMessage({ id: "validation.aliases_must_be_unique" })
: undefined;
const aliasErrorIdx = aliasErrors?.split(" ").map((e) => parseInt(e));
if (isLoading) return <LoadingIndicator />;
return (
@@ -213,106 +157,16 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
/>
<Form noValidate onSubmit={formik.handleSubmit} id="studio-edit">
<Form.Group controlId="name" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="name" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
className="text-input"
{...formik.getFieldProps("name")}
isInvalid={!!formik.errors.name}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.name}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="aliases" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="aliases" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<StringListInput
value={formik.values.aliases ?? []}
setValue={(value) => formik.setFieldValue("aliases", value)}
errors={aliasErrorMsg}
errorIdx={aliasErrorIdx}
/>
</Col>
</Form.Group>
<Form.Group controlId="url" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="url" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
className="text-input"
{...formik.getFieldProps("url")}
isInvalid={!!formik.errors.url}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.url}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="details" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="details" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
as="textarea"
className="text-input"
{...formik.getFieldProps("details")}
isInvalid={!!formik.errors.details}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.details}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="parent_studio" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="parent_studios" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"parent_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.parent_id ? [formik.values.parent_id] : []}
excludeIds={studio.id ? [studio.id] : []}
/>
</Col>
</Form.Group>
{renderStashIDs()}
{renderInputField("name")}
{renderStringListField("aliases", "validation.aliases_must_be_unique")}
{renderInputField("url")}
{renderInputField("details", "textarea")}
{renderParentStudioField()}
{renderStashIDsField("stash_ids", "studios")}
<hr />
{renderInputField("ignore_auto_tag", "checkbox")}
</Form>
<hr />
<Form.Group controlId="ignore-auto-tag" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="ignore_auto_tag" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Check
{...formik.getFieldProps({
name: "ignore_auto_tag",
type: "checkbox",
})}
/>
</Col>
</Form.Group>
<DetailsEditNavbar
objectName={studio?.name ?? intl.formatMessage({ id: "studio" })}
classNames="col-xl-9 mt-3"

View File

@@ -14,7 +14,7 @@ import { SuccessIcon } from "src/components/Shared/SuccessIcon";
import { TagSelect } from "src/components/Shared/Select";
import { TruncatedText } from "src/components/Shared/TruncatedText";
import { OperationButton } from "src/components/Shared/OperationButton";
import FormUtils from "src/utils/form";
import * as FormUtils from "src/utils/form";
import { stringToGender } from "src/utils/gender";
import { IScrapedScene, TaggerStateContext } from "../context";
import { OptionalField } from "../IncludeButton";

View File

@@ -518,7 +518,7 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
<div className="detail-header-image">
{encodingImage ? (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
message={intl.formatMessage({ id: "actions.encoding_image" })}
/>
) : (
renderImage()

View File

@@ -61,7 +61,7 @@ const TagCreate: React.FC = () => {
<div className="text-center logo-container">
{encodingImage ? (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
message={intl.formatMessage({ id: "actions.encoding_image" })}
/>
) : (
renderImage()

View File

@@ -4,16 +4,17 @@ import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { TagSelect } from "src/components/Shared/Select";
import { Form, Col, Row } from "react-bootstrap";
import { Form } from "react-bootstrap";
import ImageUtils from "src/utils/image";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
import Mousetrap from "mousetrap";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { StringListInput } from "src/components/Shared/StringListInput";
import isEqual from "lodash-es/isEqual";
import { useToast } from "src/hooks/Toast";
import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form";
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
interface ITagEditPanel {
tag: Partial<GQL.TagDataFragment>;
@@ -40,33 +41,9 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
// Network state
const [isLoading, setIsLoading] = useState(false);
const labelXS = 3;
const labelXL = 2;
const fieldXS = 9;
const fieldXL = 7;
const schema = yup.object({
name: yup.string().required(),
aliases: yup
.array(yup.string().required())
.defined()
.test({
name: "unique",
test: (value, context) => {
const aliases = [context.parent.name, ...value];
const dupes = aliases
.map((e, i, a) => {
if (a.indexOf(e) !== i) {
return String(i - 1);
} else {
return null;
}
})
.filter((e) => e !== null) as string[];
if (dupes.length === 0) return true;
return new yup.ValidationError(dupes.join(" "), value, "aliases");
},
}),
aliases: yupUniqueAliases("aliases", "name"),
description: yup.string().ensure(),
parent_ids: yup.array(yup.string().required()).defined(),
child_ids: yup.array(yup.string().required()).defined(),
@@ -87,9 +64,9 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
const formik = useFormik<InputValues>({
initialValues,
validationSchema: schema,
enableReinitialize: true,
onSubmit: (values) => onSave(values),
validate: yupFormikValidate(schema),
onSubmit: (values) => onSave(schema.cast(values)),
});
// set up hotkeys
@@ -134,18 +111,55 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
ImageUtils.onImageChange(event, onImageLoad);
}
const aliasErrors = Array.isArray(formik.errors.aliases)
? formik.errors.aliases[0]
: formik.errors.aliases;
const aliasErrorMsg = aliasErrors
? intl.formatMessage({ id: "validation.aliases_must_be_unique" })
: undefined;
const aliasErrorIdx = aliasErrors?.split(" ").map((e) => parseInt(e));
const { renderField, renderInputField, renderStringListField } = formikUtils(
intl,
formik
);
function renderParentTagsField() {
const title = intl.formatMessage({ id: "parent_tags" });
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"parent_ids",
items.map((item) => item.id)
)
}
ids={formik.values.parent_ids}
excludeIds={[...(tag?.id ? [tag.id] : []), ...formik.values.child_ids]}
creatable={false}
hoverPlacement="right"
/>
);
return renderField("parent_ids", title, control);
}
function renderSubTagsField() {
const title = intl.formatMessage({ id: "sub_tags" });
const control = (
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"child_ids",
items.map((item) => item.id)
)
}
ids={formik.values.child_ids}
excludeIds={[...(tag?.id ? [tag.id] : []), ...formik.values.parent_ids]}
creatable={false}
hoverPlacement="right"
/>
);
return renderField("child_ids", title, control);
}
if (isLoading) return <LoadingIndicator />;
const isEditing = true;
// TODO: CSS class
return (
<div>
@@ -171,121 +185,20 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
/>
<Form noValidate onSubmit={formik.handleSubmit} id="tag-edit">
<Form.Group controlId="name" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="name" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
className="text-input"
placeholder={intl.formatMessage({ id: "name" })}
{...formik.getFieldProps("name")}
isInvalid={!!formik.errors.name}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.name}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="aliases" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="aliases" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<StringListInput
value={formik.values.aliases}
setValue={(value) => formik.setFieldValue("aliases", value)}
errors={aliasErrorMsg}
errorIdx={aliasErrorIdx}
/>
</Col>
</Form.Group>
<Form.Group controlId="description" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="description" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
as="textarea"
className="text-input"
placeholder={intl.formatMessage({ id: "description" })}
{...formik.getFieldProps("description")}
/>
</Col>
</Form.Group>
<Form.Group controlId="parent_tags" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="parent_tags" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"parent_ids",
items.map((item) => item.id)
)
}
ids={formik.values.parent_ids}
excludeIds={[
...(tag?.id ? [tag.id] : []),
...formik.values.child_ids,
]}
creatable={false}
hoverPlacement="right"
/>
</Col>
</Form.Group>
<Form.Group controlId="sub_tags" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="sub_tags" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<TagSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"child_ids",
items.map((item) => item.id)
)
}
ids={formik.values.child_ids}
excludeIds={[
...(tag?.id ? [tag.id] : []),
...formik.values.parent_ids,
]}
creatable={false}
hoverPlacement="right"
/>
</Col>
</Form.Group>
{renderInputField("name")}
{renderStringListField("aliases", "validation.aliases_must_be_unique")}
{renderInputField("description", "textarea")}
{renderParentTagsField()}
{renderSubTagsField()}
<hr />
<Form.Group controlId="ignore-auto-tag" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="ignore_auto_tag" />
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Check
{...formik.getFieldProps({
name: "ignore_auto_tag",
type: "checkbox",
})}
/>
</Col>
</Form.Group>
{renderInputField("ignore_auto_tag", "checkbox")}
</Form>
<DetailsEditNavbar
objectName={tag?.name ?? intl.formatMessage({ id: "tag" })}
classNames="col-xl-9 mt-3"
isNew={isNew}
isEditing={isEditing}
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}

View File

@@ -3,7 +3,7 @@ import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "src/components/Shared/Modal";
import { TagSelect } from "src/components/Shared/Select";
import FormUtils from "src/utils/form";
import * as FormUtils from "src/utils/form";
import { useTagsMerge } from "src/core/StashService";
import { useIntl } from "react-intl";
import { useToast } from "src/hooks/Toast";

View File

@@ -1,5 +1,5 @@
import * as GQL from "src/core/generated-graphql";
import DurationUtils from "src/utils/duration";
import TextUtils from "src/utils/text";
export const scrapedMovieToCreateInput = (toCreate: GQL.ScrapedMovie) => {
const input: GQL.MovieCreateInput = {
@@ -11,9 +11,7 @@ export const scrapedMovieToCreateInput = (toCreate: GQL.ScrapedMovie) => {
synopsis: toCreate.synopsis,
date: toCreate.date,
// #788 - convert duration and rating to the correct type
duration: toCreate.duration
? DurationUtils.stringToSeconds(toCreate.duration)
: undefined,
duration: TextUtils.timestampToSeconds(toCreate.duration),
studio_id: toCreate.studio?.stored_id,
rating100: parseInt(toCreate.rating ?? "0", 10) * 20,
};

View File

@@ -921,10 +921,8 @@ export const LightboxComponent: React.FC<IProps> = ({
/>
</div>
<RatingSystem
value={currentImage?.rating100 ?? undefined}
onSetRating={(v) => {
setRating(v ?? null);
}}
value={currentImage?.rating100}
onSetRating={(v) => setRating(v)}
/>
</>
)}

View File

@@ -33,7 +33,6 @@
"delete_file": "Delete file",
"delete_file_and_funscript": "Delete file (and funscript)",
"delete_generated_supporting_files": "Delete generated supporting files",
"delete_stashid": "Delete StashID",
"disable": "Disable",
"disallow": "Disallow",
"download": "Download",
@@ -42,7 +41,7 @@
"edit": "Edit",
"edit_entity": "Edit {entityType}",
"enable": "Enable",
"encoding_image": "Encoding image",
"encoding_image": "Encoding image",
"export": "Export",
"export_all": "Export all…",
"find": "Find",
@@ -1130,6 +1129,7 @@
"play_count": "Play Count",
"play_duration": "Play Duration",
"primary_file": "Primary file",
"primary_tag": "Primary Tag",
"queue": "Queue",
"random": "Random",
"rating": "Rating",
@@ -1325,6 +1325,7 @@
"tag_sub_tag_tooltip": "Has sub-tags",
"tags": "Tags",
"tattoos": "Tattoos",
"time": "Time",
"title": "Title",
"toast": {
"added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}",

View File

@@ -9,7 +9,7 @@ import {
TimestampCriterionInput,
ConfigDataFragment,
} from "src/core/generated-graphql";
import DurationUtils from "src/utils/duration";
import TextUtils from "src/utils/text";
import {
CriterionType,
IHierarchicalLabelValue,
@@ -770,8 +770,8 @@ export class DurationCriterion extends Criterion<INumberValue> {
}
protected getLabelValue(_intl: IntlShape) {
const value = DurationUtils.secondsToString(this.value.value ?? 0);
const value2 = DurationUtils.secondsToString(this.value.value2 ?? 0);
const value = TextUtils.secondsToTimestamp(this.value.value ?? 0);
const value2 = TextUtils.secondsToTimestamp(this.value.value2 ?? 0);
if (
this.modifier === CriterionModifier.Between ||
this.modifier === CriterionModifier.NotBetween

View File

@@ -1,53 +0,0 @@
import TextUtils from "./text";
const secondsToString = (seconds: number) => {
let ret = TextUtils.secondsToTimestamp(seconds);
if (ret.startsWith("00:")) {
ret = ret.substring(3);
if (ret.startsWith("0")) {
ret = ret.substring(1);
}
}
return ret;
};
const stringToSeconds = (v?: string) => {
if (!v) {
return undefined;
}
const splits = v.split(":");
if (splits.length > 3) {
return undefined;
}
let seconds = 0;
let factor = 1;
while (splits.length > 0) {
const thisSplit = splits.pop();
if (thisSplit === undefined) {
return undefined;
}
const thisInt = parseInt(thisSplit, 10);
if (Number.isNaN(thisInt)) {
return undefined;
}
seconds += factor * thisInt;
factor *= 60;
}
return seconds;
};
const DurationUtils = {
secondsToString,
stringToSeconds,
};
export default DurationUtils;

View File

@@ -1,183 +0,0 @@
import React from "react";
import { Form } from "react-bootstrap";
import { DurationInput } from "src/components/Shared/DurationInput";
import { FilterSelect } from "src/components/Shared/Select";
import DurationUtils from "./duration";
const renderTextArea = (options: {
value: string | undefined;
isEditing: boolean;
onChange: (value: string) => void;
}) => {
return (
<Form.Control
className="text-input"
as="textarea"
readOnly={!options.isEditing}
plaintext={!options.isEditing}
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
options.onChange(event.currentTarget.value)
}
value={options.value}
/>
);
};
const renderEditableText = (options: {
title?: string;
value?: string | number;
isEditing: boolean;
onChange: (value: string) => void;
}) => {
return (
<Form.Control
readOnly={!options.isEditing}
plaintext={!options.isEditing}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
options.onChange(event.currentTarget.value)
}
value={
typeof options.value === "number"
? options.value.toString()
: options.value
}
placeholder={options.title}
/>
);
};
const renderInputGroup = (options: {
title?: string;
placeholder?: string;
value: string | undefined;
isEditing: boolean;
url?: string;
onChange?: (value: string) => void;
}) => {
if (options.url && !options.isEditing) {
return (
<a href={options.url} target="_blank" rel="noopener noreferrer">
{options.value}
</a>
);
}
return (
<Form.Control
className="text-input"
readOnly={!options.isEditing}
plaintext={!options.isEditing}
value={options.value ?? ""}
placeholder={options.placeholder ?? options.title}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
if (options.onChange) {
options.onChange(event.currentTarget.value);
}
}}
/>
);
};
const renderDurationInput = (options: {
value: number | undefined;
isEditing: boolean;
onChange: (value: number | undefined) => void;
}) => {
if (!options.isEditing) {
let durationString;
if (options.value !== undefined) {
durationString = DurationUtils.secondsToString(options.value);
}
return (
<Form.Control
className="text-input"
readOnly
plaintext
defaultValue={durationString}
/>
);
}
return (
<DurationInput
value={options.value}
setValue={(v) => options.onChange(v)}
/>
);
};
const renderHtmlSelect = (options: {
value?: string | number;
isEditing: boolean;
onChange: (value: string) => void;
selectOptions: Array<string | number>;
}) => {
if (!options.isEditing) {
return (
<Form.Control
className="text-input"
readOnly
plaintext
defaultValue={options.value}
/>
);
}
return (
<Form.Control
as="select"
className="input-control"
disabled={!options.isEditing}
plaintext={!options.isEditing}
value={options.value?.toString()}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) =>
options.onChange(event.currentTarget.value)
}
>
{options.selectOptions.map((opt) => (
<option value={opt} key={opt}>
{opt}
</option>
))}
</Form.Control>
);
};
// TODO: isediting
const renderFilterSelect = (options: {
type: "performers" | "studios" | "tags";
initialId: string | undefined;
onChange: (id: string | undefined) => void;
}) => (
<FilterSelect
type={options.type}
onSelect={(items) => options.onChange(items[0]?.id)}
initialIds={options.initialId ? [options.initialId] : []}
/>
);
// TODO: isediting
const renderMultiSelect = (options: {
type: "performers" | "studios" | "tags";
initialIds: string[] | undefined;
onChange: (ids: string[]) => void;
}) => (
<FilterSelect
type={options.type}
isMulti
onSelect={(items) => options.onChange(items.map((i) => i.id))}
initialIds={options.initialIds ?? []}
/>
);
const EditableTextUtils = {
renderTextArea,
renderEditableText,
renderInputGroup,
renderDurationInput,
renderHtmlSelect,
renderFilterSelect,
renderMultiSelect,
};
export default EditableTextUtils;

View File

@@ -1,5 +1,22 @@
import { Form, Col, Row, ColProps, FormLabelProps } from "react-bootstrap";
import EditableTextUtils from "./editabletext";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
import { FormikValues, useFormik } from "formik";
import {
Button,
Col,
ColProps,
Form,
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;
@@ -13,155 +30,321 @@ function getLabelProps(labelProps?: FormLabelProps) {
return ret;
}
function getInputProps(inputProps?: ColProps) {
let ret = inputProps;
if (!ret) {
ret = {
xs: 9,
};
}
return ret;
export function renderLabel(options: {
title: string;
labelProps?: FormLabelProps;
}) {
return (
<Form.Label column {...getLabelProps(options.labelProps)}>
{options.title}
</Form.Label>
);
}
const renderLabel = (options: {
title: string;
type Formik<V extends FormikValues> = ReturnType<typeof useFormik<V>>;
interface IProps {
labelProps?: FormLabelProps;
}) => (
<Form.Label column {...getLabelProps(options.labelProps)}>
{options.title}
</Form.Label>
);
fieldProps?: ColProps;
}
const renderEditableText = (options: {
title: string;
value?: string | number;
isEditing: boolean;
onChange: (value: string) => void;
labelProps?: FormLabelProps;
inputProps?: ColProps;
}) => (
<Form.Group controlId={options.title} as={Row}>
{renderLabel(options)}
<Col {...getInputProps(options.inputProps)}>
{EditableTextUtils.renderEditableText(options)}
</Col>
</Form.Group>
);
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;
const renderTextArea = (options: {
title: string;
value: string | undefined;
isEditing: boolean;
onChange: (value: string) => void;
labelProps?: FormLabelProps;
inputProps?: ColProps;
}) => (
<Form.Group controlId={options.title} as={Row}>
{renderLabel(options)}
<Col {...getInputProps(options.inputProps)}>
{EditableTextUtils.renderTextArea(options)}
</Col>
</Form.Group>
);
function renderFormControl(field: Field, type: string, placeholder: string) {
const formikProps = formik.getFieldProps({ name: field, type: type });
const { error } = formik.getFieldMeta(field);
const renderInputGroup = (options: {
title: string;
placeholder?: string;
value: string | undefined;
isEditing: boolean;
url?: string;
onChange: (value: string) => void;
labelProps?: FormLabelProps;
inputProps?: ColProps;
}) => (
<Form.Group controlId={options.title} as={Row}>
{renderLabel(options)}
<Col {...getInputProps(options.inputProps)}>
{EditableTextUtils.renderInputGroup(options)}
</Col>
</Form.Group>
);
let { value } = formikProps;
if (value === null) {
value = "";
}
const renderDurationInput = (options: {
title: string;
placeholder?: string;
value: number | undefined;
isEditing: boolean;
onChange: (value: number | undefined) => void;
labelProps?: FormLabelProps;
inputProps?: ColProps;
}) => {
return (
<Form.Group controlId={options.title} as={Row}>
{renderLabel(options)}
<Col {...getInputProps(options.inputProps)}>
{EditableTextUtils.renderDurationInput(options)}
</Col>
</Form.Group>
);
};
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 = (
<Form.Control
type={type}
className="text-input"
placeholder={placeholder}
{...formikProps}
value={value}
isInvalid={!!error}
/>
);
}
const renderHtmlSelect = (options: {
title: string;
value?: string | number;
isEditing: boolean;
onChange: (value: string) => void;
selectOptions: Array<string | number>;
labelProps?: FormLabelProps;
inputProps?: ColProps;
}) => (
<Form.Group controlId={options.title} as={Row}>
{renderLabel(options)}
<Col {...getInputProps(options.inputProps)}>
{EditableTextUtils.renderHtmlSelect(options)}
</Col>
</Form.Group>
);
return (
<>
{control}
<Form.Control.Feedback type="invalid">{error}</Form.Control.Feedback>
</>
);
}
// TODO: isediting
const renderFilterSelect = (options: {
title: string;
type: "performers" | "studios" | "tags";
initialId: string | undefined;
onChange: (id: string | undefined) => void;
labelProps?: FormLabelProps;
inputProps?: ColProps;
}) => (
<Form.Group controlId={options.title} as={Row}>
{renderLabel(options)}
<Col {...getInputProps(options.inputProps)}>
{EditableTextUtils.renderFilterSelect(options)}
</Col>
</Form.Group>
);
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>
);
}
// TODO: isediting
const renderMultiSelect = (options: {
title: string;
type: "performers" | "studios" | "tags";
initialIds: string[] | undefined;
onChange: (ids: string[]) => void;
labelProps?: FormLabelProps;
inputProps?: ColProps;
}) => (
<Form.Group controlId={options.title} as={Row}>
{renderLabel(options)}
<Col {...getInputProps(options.inputProps)}>
{EditableTextUtils.renderMultiSelect(options)}
</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);
const FormUtils = {
renderLabel,
renderEditableText,
renderTextArea,
renderInputGroup,
renderDurationInput,
renderHtmlSelect,
renderFilterSelect,
renderMultiSelect,
};
return renderField(field, title, control, props);
}
export default FormUtils;
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.getFieldMeta(field);
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.getFieldMeta(field);
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);
}
function renderStringListField(
field: Field,
errorMessageID: string,
messageID: string = field,
props?: IProps
) {
const formikProps = formik.getFieldProps(field);
const { error } = formik.getFieldMeta(field);
const errorMsg = error
? intl.formatMessage({ id: errorMessageID })
: undefined;
const errorIdx = error?.split(" ").map((e) => parseInt(e));
const title = intl.formatMessage({ id: messageID });
const control = (
<StringListInput
value={formikProps.value ?? []}
setValue={(v) => formik.setFieldValue(field, v)}
errors={errorMsg}
errorIdx={errorIdx}
/>
);
return renderField(field, title, control, props);
}
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 errorMsg = error
? intl.formatMessage({ id: errorMessageID })
: undefined;
const errorIdx = error?.split(" ").map((e) => parseInt(e));
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,
};
}

View File

@@ -79,7 +79,7 @@ export function getRatingPrecision(precision: RatingStarPrecision) {
}
export function convertToRatingFormat(
rating: number | undefined,
rating: number | null | undefined,
ratingSystemOptions: RatingSystemOptions
) {
if (!rating) {

View File

@@ -1,105 +0,0 @@
import EditableTextUtils from "./editabletext";
const renderEditableTextTableRow = (options: {
title: string;
value?: string | number;
isEditing: boolean;
onChange: (value: string) => void;
}) => (
<tr>
<td>{options.title}</td>
<td>{EditableTextUtils.renderEditableText(options)}</td>
</tr>
);
const renderTextArea = (options: {
title: string;
value: string | undefined;
isEditing: boolean;
onChange: (value: string) => void;
}) => (
<tr>
<td>{options.title}</td>
<td>{EditableTextUtils.renderTextArea(options)}</td>
</tr>
);
const renderInputGroup = (options: {
title: string;
placeholder?: string;
value: string | undefined;
isEditing: boolean;
url?: string;
onChange: (value: string) => void;
}) => (
<tr>
<td>{options.title}</td>
<td>{EditableTextUtils.renderInputGroup(options)}</td>
</tr>
);
const renderDurationInput = (options: {
title: string;
placeholder?: string;
value: number | undefined;
isEditing: boolean;
onChange: (value: number | undefined) => void;
}) => {
return (
<tr>
<td>{options.title}</td>
<td>{EditableTextUtils.renderDurationInput(options)}</td>
</tr>
);
};
const renderHtmlSelect = (options: {
title: string;
value?: string | number;
isEditing: boolean;
onChange: (value: string) => void;
selectOptions: Array<string | number>;
}) => (
<tr>
<td>{options.title}</td>
<td>{EditableTextUtils.renderHtmlSelect(options)}</td>
</tr>
);
// TODO: isediting
const renderFilterSelect = (options: {
title: string;
type: "performers" | "studios" | "tags";
initialId: string | undefined;
onChange: (id: string | undefined) => void;
}) => (
<tr>
<td>{options.title}</td>
<td>{EditableTextUtils.renderFilterSelect(options)}</td>
</tr>
);
// TODO: isediting
const renderMultiSelect = (options: {
title: string;
type: "performers" | "studios" | "tags";
initialIds: string[] | undefined;
onChange: (ids: string[]) => void;
}) => (
<tr>
<td>{options.title}</td>
<td>{EditableTextUtils.renderMultiSelect(options)}</td>
</tr>
);
const TableUtils = {
renderEditableTextTableRow,
renderTextArea,
renderInputGroup,
renderDurationInput,
renderHtmlSelect,
renderFilterSelect,
renderMultiSelect,
};
export default TableUtils;

View File

@@ -129,16 +129,11 @@ const secondsAsTime = (seconds: number = 0): DurationCount[] => {
return result;
};
const timeAsString = (time: DurationCount[]): string => {
return time.join(" ");
};
const secondsAsTimeString = (
seconds: number = 0,
maxUnitCount: number = 2
): string => {
const timeArray = secondsAsTime(seconds).slice(0, maxUnitCount);
return timeAsString(timeArray);
return secondsAsTime(seconds).slice(0, maxUnitCount).join(" ");
};
const formatFileSizeUnit = (u: Unit) => {
@@ -156,18 +151,68 @@ const fileSizeFractionalDigits = (unit: Unit) => {
return 0;
};
// Converts seconds to a hh:mm:ss or mm:ss timestamp.
// A negative input will result in a -hh:mm:ss or -mm:ss output.
// Fractional inputs are truncated.
const secondsToTimestamp = (seconds: number) => {
let ret = new Date(seconds * 1000).toISOString().substring(11, 19);
let neg = false;
if (seconds < 0) {
neg = true;
seconds = -seconds;
}
seconds = Math.trunc(seconds);
if (ret.startsWith("00")) {
// strip hours if under one hour
ret = ret.substring(3);
const s = seconds % 60;
seconds = (seconds - s) / 60;
const m = seconds % 60;
seconds = (seconds - m) / 60;
const h = seconds;
let ret = String(s).padStart(2, "0");
if (h === 0) {
ret = String(m) + ":" + ret;
} else {
ret = String(m).padStart(2, "0") + ":" + ret;
ret = String(h) + ":" + ret;
}
if (ret.startsWith("0")) {
// for duration under a minute, leave one leading zero
ret = ret.substring(1);
if (neg) {
return "-" + ret;
} else {
return ret;
}
return ret;
};
const timestampToSeconds = (v: string | null | undefined) => {
if (!v) {
return null;
}
const splits = v.split(":");
if (splits.length > 3) {
return null;
}
let seconds = 0;
let factor = 1;
while (splits.length > 0) {
const thisSplit = splits.pop();
if (thisSplit === undefined) {
return null;
}
const thisInt = parseInt(thisSplit, 10);
if (Number.isNaN(thisInt)) {
return null;
}
seconds += factor * thisInt;
factor *= 60;
}
return seconds;
};
const fileNameFromPath = (path: string) => {
@@ -415,6 +460,7 @@ const TextUtils = {
formatFileSizeUnit,
fileSizeFractionalDigits,
secondsToTimestamp,
timestampToSeconds,
fileNameFromPath,
stringToDate,
stringToFuzzyDate,

View File

@@ -1,3 +1,4 @@
import { FormikErrors, yupToFormErrors } from "formik";
import { IntlShape } from "react-intl";
import * as yup from "yup";
@@ -8,15 +9,39 @@ export function yupUniqueStringList(fieldName: string) {
.test({
name: "unique",
test: (value) => {
const dupes = value
.map((e, i, a) => {
if (a.indexOf(e) !== i) {
return String(i - 1);
} else {
return null;
}
})
.filter((e) => e !== null) as string[];
const values: string[] = [];
const dupes: number[] = [];
for (let i = 0; i < value.length; i++) {
const a = value[i];
if (values.includes(a)) {
dupes.push(i);
} else {
values.push(a);
}
}
if (dupes.length === 0) return true;
return new yup.ValidationError(dupes.join(" "), value, fieldName);
},
});
}
export function yupUniqueAliases(fieldName: string, nameField: string) {
return yup
.array(yup.string().required())
.defined()
.test({
name: "unique",
test: (value, context) => {
const aliases = [context.parent[nameField].toLowerCase()];
const dupes: number[] = [];
for (let i = 0; i < value.length; i++) {
const a = value[i].toLowerCase();
if (aliases.includes(a)) {
dupes.push(i);
} else {
aliases.push(a);
}
}
if (dupes.length === 0) return true;
return new yup.ValidationError(dupes.join(" "), value, fieldName);
},
@@ -38,3 +63,42 @@ export function yupDateString(intl: IntlShape) {
message: intl.formatMessage({ id: "validation.date_invalid_form" }),
});
}
type StringEnum<T extends string> = {
[k: string]: T;
};
// Use yupInputEnum to validate a string enum from a <select>.
// If "" is not a value in the enum, a "" input will be transformed to null.
export function yupInputEnum<T extends string>(e: StringEnum<T>) {
const enumValues = Object.values(e);
const schema = yup.string<T>().oneOf(enumValues);
if (enumValues.includes("" as T)) {
return schema;
} else {
return schema.transform((v, o) => (o === "" ? null : v));
}
}
// Use yupInputNumber to validate a number from an <input type="number">.
// A "" input will be transformed to null.
export function yupInputNumber() {
return yup.number().transform((v, o) => (o === "" ? null : v));
}
// Formik converts "" into undefined when validating with a yup schema,
// which prevents transformations from running.
// Interfacing with yup ourselves avoids this.
// https://github.com/jaredpalmer/formik/pull/2902#issuecomment-922492137
export function yupFormikValidate<T>(
schema: yup.AnySchema
): (values: T) => Promise<FormikErrors<T>> {
return async function (values) {
try {
await schema.validate(values, { abortEarly: false });
} catch (err) {
return yupToFormErrors(err);
}
return {};
};
}

View File

@@ -4489,18 +4489,19 @@ form-data@^3.0.0:
combined-stream "^1.0.8"
mime-types "^2.1.12"
formik@^2.2.9:
version "2.2.9"
resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0"
integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==
formik@^2.4.5:
version "2.4.5"
resolved "https://registry.yarnpkg.com/formik/-/formik-2.4.5.tgz#f899b5b7a6f103a8fabb679823e8fafc7e0ee1b4"
integrity sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==
dependencies:
"@types/hoist-non-react-statics" "^3.3.1"
deepmerge "^2.1.1"
hoist-non-react-statics "^3.3.0"
lodash "^4.17.21"
lodash-es "^4.17.21"
react-fast-compare "^2.0.1"
tiny-warning "^1.0.2"
tslib "^1.10.0"
tslib "^2.0.0"
fs-extra@^10.0.0:
version "10.1.0"
@@ -7792,7 +7793,7 @@ tsconfig-paths@^3.14.1:
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@^1.10.0, tslib@^1.8.1:
tslib@^1.8.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
@@ -8503,10 +8504,10 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
yup@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/yup/-/yup-1.0.0.tgz#de4e32f9d2e45b1ab428076fc916c84db861b8ce"
integrity sha512-bRZIyMkoe212ahGJTE32cr2dLkJw53Va+Uw5mzsBKpcef9zCGQ23k/xtpQUfGwdWPKvCIlR8CzFwchs2rm2XpQ==
yup@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/yup/-/yup-1.3.2.tgz#afffc458f1513ed386e6aaf4bcaa4e67a9e270dc"
integrity sha512-6KCM971iQtJ+/KUaHdrhVr2LDkfhBtFPRnsG1P8F4q3uUVQ2RfEM9xekpha9aA4GXWJevjM10eDcPQ1FfWlmaQ==
dependencies:
property-expr "^2.0.5"
tiny-case "^1.0.3"