mirror of https://github.com/stashapp/stash.git
Rebuild gallery edit using formik (#1654)
* Rebuild gallery edit using formik * Prompt on page leave when changes are not saved * Fixes missing required validation * Only enables save when changes are made * Wrap in <Form onSubmit> (not sure if this does anything)
This commit is contained in:
parent
ac72d4db2b
commit
392fa3535c
|
@ -9,6 +9,7 @@
|
|||
* Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568))
|
||||
|
||||
### 🎨 Improvements
|
||||
* Prompt when leaving gallery edit page with unsaved changes. ([#1654](https://github.com/stashapp/stash/pull/1654))
|
||||
* Show largest duplicates first in scene duplicate checker. ([#1639](https://github.com/stashapp/stash/pull/1639))
|
||||
* Added checkboxes to scene list view. ([#1642](https://github.com/stashapp/stash/pull/1642))
|
||||
* Added keyboard shortcuts for scene queue navigation. ([#1635](https://github.com/stashapp/stash/pull/1635))
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "react-bootstrap";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import * as yup from "yup";
|
||||
import {
|
||||
queryScrapeGallery,
|
||||
queryScrapeGalleryURL,
|
||||
|
@ -28,7 +29,9 @@ import {
|
|||
LoadingIndicator,
|
||||
} from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import { FormUtils, EditableTextUtils, TextUtils } from "src/utils";
|
||||
import { useFormik } from "formik";
|
||||
import { Prompt } from "react-router";
|
||||
import { FormUtils, TextUtils } from "src/utils";
|
||||
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
||||
import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
|
||||
|
||||
|
@ -53,25 +56,11 @@ export const GalleryEditPanel: React.FC<
|
|||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const history = useHistory();
|
||||
const [title, setTitle] = useState<string>(gallery?.title ?? "");
|
||||
const [details, setDetails] = useState<string>(gallery?.details ?? "");
|
||||
const [url, setUrl] = useState<string>(gallery?.url ?? "");
|
||||
const [date, setDate] = useState<string>(gallery?.date ?? "");
|
||||
const [rating, setRating] = useState<number>(gallery?.rating ?? NaN);
|
||||
const [studioId, setStudioId] = useState<string | undefined>(
|
||||
gallery?.studio?.id ?? undefined
|
||||
);
|
||||
const [performerIds, setPerformerIds] = useState<string[]>(
|
||||
gallery?.performers.map((p) => p.id) ?? []
|
||||
);
|
||||
const [tagIds, setTagIds] = useState<string[]>(
|
||||
gallery?.tags.map((t) => t.id) ?? []
|
||||
);
|
||||
const [scenes, setScenes] = useState<{ id: string; title: string }[]>(
|
||||
gallery?.scenes.map((s) => ({
|
||||
(gallery?.scenes ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title ?? TextUtils.fileNameFromPath(s.path ?? ""),
|
||||
})) ?? []
|
||||
}))
|
||||
);
|
||||
|
||||
const Scrapers = useListGalleryScrapers();
|
||||
|
@ -88,10 +77,59 @@ export const GalleryEditPanel: React.FC<
|
|||
const [createGallery] = useGalleryCreate();
|
||||
const [updateGallery] = useGalleryUpdate();
|
||||
|
||||
const schema = yup.object({
|
||||
title: yup.string().required(),
|
||||
details: yup.string().optional().nullable(),
|
||||
url: yup.string().optional().nullable(),
|
||||
date: yup.string().optional().nullable(),
|
||||
rating: yup.number().optional().nullable(),
|
||||
studio_id: yup.string().optional().nullable(),
|
||||
performer_ids: yup.array(yup.string().required()).optional().nullable(),
|
||||
tag_ids: yup.array(yup.string().required()).optional().nullable(),
|
||||
scene_ids: yup.array(yup.string().required()).optional().nullable(),
|
||||
});
|
||||
|
||||
const initialValues = {
|
||||
title: gallery?.title ?? "",
|
||||
details: gallery?.details ?? "",
|
||||
url: gallery?.url ?? "",
|
||||
date: gallery?.date ?? "",
|
||||
rating: gallery?.rating ?? null,
|
||||
studio_id: gallery?.studio?.id,
|
||||
performer_ids: (gallery?.performers ?? []).map((p) => p.id),
|
||||
tag_ids: (gallery?.tags ?? []).map((t) => t.id),
|
||||
scene_ids: (gallery?.scenes ?? []).map((s) => s.id),
|
||||
};
|
||||
|
||||
type InputValues = typeof initialValues;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues,
|
||||
validationSchema: schema,
|
||||
onSubmit: (values) => onSave(getGalleryInput(values)),
|
||||
});
|
||||
|
||||
function setRating(v: number) {
|
||||
formik.setFieldValue("rating", v);
|
||||
}
|
||||
|
||||
interface ISceneSelectValue {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function onSetScenes(items: ISceneSelectValue[]) {
|
||||
setScenes(items);
|
||||
formik.setFieldValue(
|
||||
"scene_ids",
|
||||
items.map((i) => i.id)
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
Mousetrap.bind("s s", () => {
|
||||
onSave();
|
||||
formik.handleSubmit();
|
||||
});
|
||||
Mousetrap.bind("d d", () => {
|
||||
onDelete();
|
||||
|
@ -140,28 +178,24 @@ export const GalleryEditPanel: React.FC<
|
|||
setQueryableScrapers(newQueryableScrapers);
|
||||
}, [Scrapers]);
|
||||
|
||||
function getGalleryInput() {
|
||||
function getGalleryInput(
|
||||
input: InputValues
|
||||
): GQL.GalleryCreateInput | GQL.GalleryUpdateInput {
|
||||
return {
|
||||
id: isNew ? undefined : gallery?.id ?? "",
|
||||
title: title ?? "",
|
||||
details,
|
||||
url,
|
||||
date,
|
||||
rating: rating ?? null,
|
||||
studio_id: studioId ?? null,
|
||||
performer_ids: performerIds,
|
||||
tag_ids: tagIds,
|
||||
scene_ids: scenes.map((s) => s.id),
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
async function onSave(
|
||||
input: GQL.GalleryCreateInput | GQL.GalleryUpdateInput
|
||||
) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (isNew) {
|
||||
const result = await createGallery({
|
||||
variables: {
|
||||
input: getGalleryInput(),
|
||||
input: input as GQL.GalleryCreateInput,
|
||||
},
|
||||
});
|
||||
if (result.data?.galleryCreate) {
|
||||
|
@ -171,7 +205,7 @@ export const GalleryEditPanel: React.FC<
|
|||
} else {
|
||||
const result = await updateGallery({
|
||||
variables: {
|
||||
input: getGalleryInput() as GQL.GalleryUpdateInput,
|
||||
input: input as GQL.GalleryUpdateInput,
|
||||
},
|
||||
});
|
||||
if (result.data?.galleryUpdate) {
|
||||
|
@ -185,6 +219,7 @@ export const GalleryEditPanel: React.FC<
|
|||
}
|
||||
),
|
||||
});
|
||||
formik.resetForm({ values: formik.values });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -196,7 +231,9 @@ export const GalleryEditPanel: React.FC<
|
|||
async function onScrapeClicked(scraper: GQL.Scraper) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const galleryInput = getGalleryInput() as GQL.GalleryUpdateInput;
|
||||
const galleryInput = getGalleryInput(
|
||||
formik.values
|
||||
) as GQL.GalleryUpdateInput;
|
||||
const result = await queryScrapeGallery(scraper.id, galleryInput);
|
||||
if (!result.data || !result.data.scrapeGallery) {
|
||||
Toast.success({
|
||||
|
@ -238,7 +275,7 @@ export const GalleryEditPanel: React.FC<
|
|||
return;
|
||||
}
|
||||
|
||||
const currentGallery = getGalleryInput();
|
||||
const currentGallery = getGalleryInput(formik.values);
|
||||
|
||||
return (
|
||||
<GalleryScrapeDialog
|
||||
|
@ -289,23 +326,23 @@ export const GalleryEditPanel: React.FC<
|
|||
galleryData: GQL.ScrapedGalleryDataFragment
|
||||
) {
|
||||
if (galleryData.title) {
|
||||
setTitle(galleryData.title);
|
||||
formik.setFieldValue("title", galleryData.title);
|
||||
}
|
||||
|
||||
if (galleryData.details) {
|
||||
setDetails(galleryData.details);
|
||||
formik.setFieldValue("details", galleryData.details);
|
||||
}
|
||||
|
||||
if (galleryData.date) {
|
||||
setDate(galleryData.date);
|
||||
formik.setFieldValue("date", galleryData.date);
|
||||
}
|
||||
|
||||
if (galleryData.url) {
|
||||
setUrl(galleryData.url);
|
||||
formik.setFieldValue("url", galleryData.url);
|
||||
}
|
||||
|
||||
if (galleryData.studio?.stored_id) {
|
||||
setStudioId(galleryData.studio.stored_id);
|
||||
formik.setFieldValue("studio_id", galleryData.studio.stored_id);
|
||||
}
|
||||
|
||||
if (galleryData.performers?.length) {
|
||||
|
@ -315,7 +352,7 @@ export const GalleryEditPanel: React.FC<
|
|||
|
||||
if (idPerfs.length > 0) {
|
||||
const newIds = idPerfs.map((p) => p.stored_id);
|
||||
setPerformerIds(newIds as string[]);
|
||||
formik.setFieldValue("performer_ids", newIds as string[]);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -326,18 +363,18 @@ export const GalleryEditPanel: React.FC<
|
|||
|
||||
if (idTags.length > 0) {
|
||||
const newIds = idTags.map((t) => t.stored_id);
|
||||
setTagIds(newIds as string[]);
|
||||
formik.setFieldValue("tag_ids", newIds as string[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onScrapeGalleryURL() {
|
||||
if (!url) {
|
||||
if (!formik.values.url) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await queryScrapeGalleryURL(url);
|
||||
const result = await queryScrapeGalleryURL(formik.values.url);
|
||||
if (!result || !result.data || !result.data.scrapeGalleryURL) {
|
||||
return;
|
||||
}
|
||||
|
@ -350,7 +387,7 @@ export const GalleryEditPanel: React.FC<
|
|||
}
|
||||
|
||||
function maybeRenderScrapeButton() {
|
||||
if (!url || !urlScrapable(url)) {
|
||||
if (!formik.values.url || !urlScrapable(formik.values.url)) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
|
@ -364,158 +401,197 @@ export const GalleryEditPanel: React.FC<
|
|||
);
|
||||
}
|
||||
|
||||
function renderTextField(field: string, title: string, placeholder?: string) {
|
||||
return (
|
||||
<Form.Group controlId={title} 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 />;
|
||||
|
||||
return (
|
||||
<div id="gallery-edit-details">
|
||||
<Prompt
|
||||
when={formik.dirty}
|
||||
message={intl.formatMessage({ id: "dialogs.unsaved_changes" })}
|
||||
/>
|
||||
|
||||
{maybeRenderScrapeDialog()}
|
||||
<div className="form-container row px-3 pt-3">
|
||||
<div className="col edit-buttons mb-3 pl-0">
|
||||
<Button className="edit-button" variant="primary" onClick={onSave}>
|
||||
<FormattedMessage id="actions.save" />
|
||||
</Button>
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
<Form noValidate onSubmit={formik.handleSubmit}>
|
||||
<div className="form-container row px-3 pt-3">
|
||||
<div className="col edit-buttons mb-3 pl-0">
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="primary"
|
||||
disabled={!formik.dirty}
|
||||
onClick={() => formik.submitForm()}
|
||||
>
|
||||
<FormattedMessage id="actions.save" />
|
||||
</Button>
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
</div>
|
||||
<Col xs={6} className="text-right">
|
||||
{renderScraperMenu()}
|
||||
</Col>
|
||||
</div>
|
||||
<Col xs={6} className="text-right">
|
||||
{renderScraperMenu()}
|
||||
</Col>
|
||||
</div>
|
||||
<div className="form-container row px-3">
|
||||
<div className="col-12 col-lg-6 col-xl-12">
|
||||
{FormUtils.renderInputGroup({
|
||||
title: intl.formatMessage({ id: "title" }),
|
||||
value: title,
|
||||
onChange: setTitle,
|
||||
isEditing: true,
|
||||
})}
|
||||
<Form.Group controlId="url" as={Row}>
|
||||
<Col xs={3} className="pr-0 url-label">
|
||||
<Form.Label className="col-form-label">
|
||||
{intl.formatMessage({ id: "url" })}
|
||||
</Form.Label>
|
||||
<div className="float-right scrape-button-container">
|
||||
{maybeRenderScrapeButton()}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={9}>
|
||||
{EditableTextUtils.renderInputGroup({
|
||||
title: intl.formatMessage({ id: "url" }),
|
||||
value: url,
|
||||
onChange: setUrl,
|
||||
isEditing: true,
|
||||
<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="url" as={Row}>
|
||||
<Col xs={3} className="pr-0 url-label">
|
||||
<Form.Label className="col-form-label">
|
||||
<FormattedMessage id="url" />
|
||||
</Form.Label>
|
||||
<div className="float-right scrape-button-container">
|
||||
{maybeRenderScrapeButton()}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={9}>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
placeholder={intl.formatMessage({ id: "url" })}
|
||||
{...formik.getFieldProps("url")}
|
||||
isInvalid={!!formik.getFieldMeta("url").error}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
{renderTextField(
|
||||
"date",
|
||||
intl.formatMessage({ id: "date" }),
|
||||
"YYYY-MM-DD"
|
||||
)}
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "rating" }),
|
||||
})}
|
||||
</Col>
|
||||
</Form.Group>
|
||||
{FormUtils.renderInputGroup({
|
||||
title: intl.formatMessage({ id: "date" }),
|
||||
value: date,
|
||||
isEditing: true,
|
||||
onChange: setDate,
|
||||
placeholder: "YYYY-MM-DD",
|
||||
})}
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "rating" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<RatingStars
|
||||
value={rating}
|
||||
onSetRating={(value) => setRating(value ?? NaN)}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Col xs={9}>
|
||||
<RatingStars
|
||||
value={formik.values.rating ?? undefined}
|
||||
onSetRating={(value) =>
|
||||
formik.setFieldValue("rating", value ?? null)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="studio" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "studio" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
||||
<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={(items) =>
|
||||
formik.setFieldValue(
|
||||
"performer_ids",
|
||||
items.map((item) => item.id)
|
||||
)
|
||||
}
|
||||
ids={formik.values.performer_ids}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</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
|
||||
scenes={scenes}
|
||||
onSelect={(items) => onSetScenes(items)}
|
||||
/>
|
||||
</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={(newValue: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
formik.setFieldValue("details", newValue.currentTarget.value)
|
||||
}
|
||||
ids={studioId ? [studioId] : []}
|
||||
value={formik.values.details}
|
||||
/>
|
||||
</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={(items) =>
|
||||
setPerformerIds(items.map((item) => item.id))
|
||||
}
|
||||
ids={performerIds}
|
||||
/>
|
||||
</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) => setTagIds(items.map((item) => item.id))}
|
||||
ids={tagIds}
|
||||
/>
|
||||
</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
|
||||
scenes={scenes}
|
||||
onSelect={(items) => setScenes(items)}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</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={(newValue: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setDetails(newValue.currentTarget.value)
|
||||
}
|
||||
value={details}
|
||||
/>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue