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:
gitgiggety 2021-08-24 04:01:05 +02:00 committed by GitHub
parent ac72d4db2b
commit 392fa3535c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 261 additions and 184 deletions

View File

@ -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))

View File

@ -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>
);
};