FR: Save & New Button on Objects (#6438)

This commit is contained in:
Gykes 2026-01-13 19:06:21 -08:00 committed by GitHub
parent b4969add27
commit 77d0008c6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 209 additions and 66 deletions

View file

@ -19,12 +19,14 @@ const GalleryCreate: React.FC = () => {
const [createGallery] = useGalleryCreate();
async function onSave(input: GQL.GalleryCreateInput) {
async function onSave(input: GQL.GalleryCreateInput, andNew?: boolean) {
const result = await createGallery({
variables: { input },
});
if (result.data?.galleryCreate) {
history.push(`/galleries/${result.data.galleryCreate.id}`);
if (!andNew) {
history.push(`/galleries/${result.data.galleryCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Prompt } from "react-router-dom";
import { Button, Form, Col, Row } from "react-bootstrap";
import { Button, Dropdown, Form, Col, Row, SplitButton } from "react-bootstrap";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
@ -35,7 +35,7 @@ import { ScraperMenu } from "src/components/Shared/ScraperMenu";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
isVisible: boolean;
onSubmit: (input: GQL.GalleryCreateInput) => Promise<void>;
onSubmit: (input: GQL.GalleryCreateInput, andNew?: boolean) => Promise<void>;
onDelete: () => void;
}
@ -177,10 +177,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
return <div></div>;
}, [gallery?.paths?.cover, intl]);
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -188,6 +188,11 @@ export const GalleryEditPanel: React.FC<IProps> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
if (!gallery || !gallery.id) return;
@ -445,16 +450,31 @@ export const GalleryEditPanel: React.FC<IProps> = ({
<Form noValidate onSubmit={formik.handleSubmit}>
<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"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
{isNew ? (
<SplitButton
id="gallery-save-split-button"
className="edit-button"
variant="primary"
disabled={!isEqual(formik.errors, {})}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : (
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
)}
<Button
className="edit-button"
variant="danger"

View file

@ -25,12 +25,14 @@ const GroupCreate: React.FC = () => {
const [createGroup] = useGroupCreate();
async function onSave(input: GQL.GroupCreateInput) {
async function onSave(input: GQL.GroupCreateInput, andNew?: boolean) {
const result = await createGroup({
variables: { input },
});
if (result.data?.groupCreate?.id) {
history.push(`/groups/${result.data.groupCreate.id}`);
if (!andNew) {
history.push(`/groups/${result.data.groupCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -31,7 +31,7 @@ import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable";
interface IGroupEditPanel {
group: Partial<GQL.GroupDataFragment>;
onSubmit: (group: GQL.GroupCreateInput) => Promise<void>;
onSubmit: (group: GQL.GroupCreateInput, andNew?: boolean) => Promise<void>;
onCancel: () => void;
onDelete: () => void;
setFrontImage: (image?: string | null) => void;
@ -208,10 +208,10 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
}
}
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -219,6 +219,11 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
async function onScrapeGroupURL(url: string) {
if (!url) return;
setIsLoading(true);
@ -462,6 +467,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
onSaveAndNew={isNew ? onSaveAndNewClick : undefined}
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onImageChange={onFrontImageChange}
onImageChangeURL={onFrontImageLoad}

View file

@ -23,12 +23,14 @@ const PerformerCreate: React.FC = () => {
const [createPerformer] = usePerformerCreate();
async function onSave(input: GQL.PerformerCreateInput) {
async function onSave(input: GQL.PerformerCreateInput, andNew?: boolean) {
const result = await createPerformer({
variables: { input },
});
if (result.data?.performerCreate) {
history.push(`/performers/${result.data.performerCreate.id}`);
if (!andNew) {
history.push(`/performers/${result.data.performerCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Button, Form, Dropdown } from "react-bootstrap";
import { Button, Form, Dropdown, SplitButton } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
@ -58,7 +58,10 @@ const isScraper = (
interface IPerformerDetails {
performer: Partial<GQL.PerformerDataFragment>;
isVisible: boolean;
onSubmit: (performer: GQL.PerformerCreateInput) => Promise<void>;
onSubmit: (
performer: GQL.PerformerCreateInput,
andNew?: boolean
) => Promise<void>;
onCancel?: () => void;
setImage: (image?: string | null) => void;
setEncodingImage: (loading: boolean) => void;
@ -345,10 +348,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
ImageUtils.onImageChange(event, onImageLoad);
}
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -356,6 +359,15 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const { values } = formik;
const input = {
...schema.cast(values),
custom_fields: customFieldInput(isNew, values.custom_fields),
};
onSave(input, true);
}
// set up hotkeys
useEffect(() => {
if (isVisible) {
@ -603,17 +615,33 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<FormattedMessage id="actions.clear_image" />
</Button>
</div>
<Button
variant="success"
disabled={
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
{isNew ? (
<SplitButton
id="save-split-button"
variant="success"
disabled={
!isEqual(formik.errors, {}) || customFieldsError !== undefined
}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : (
<Button
variant="success"
disabled={
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
)}
</div>
);
}

View file

@ -57,14 +57,16 @@ const SceneCreate: React.FC = () => {
return <LoadingIndicator />;
}
async function onSave(input: GQL.SceneCreateInput) {
async function onSave(input: GQL.SceneCreateInput, andNew?: boolean) {
const fileID = query.get("file_id") ?? undefined;
const result = await mutateCreateScene({
...input,
file_ids: fileID ? [fileID] : undefined,
});
if (result.data?.sceneCreate?.id) {
history.push(`/scenes/${result.data.sceneCreate.id}`);
if (!andNew) {
history.push(`/scenes/${result.data.sceneCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -1,6 +1,14 @@
import React, { useEffect, useState, useMemo } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form, Col, Row, ButtonGroup } from "react-bootstrap";
import {
Button,
Dropdown,
Form,
Col,
Row,
ButtonGroup,
SplitButton,
} from "react-bootstrap";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
@ -51,7 +59,7 @@ interface IProps {
initialCoverImage?: string;
isNew?: boolean;
isVisible: boolean;
onSubmit: (input: GQL.SceneCreateInput) => Promise<void>;
onSubmit: (input: GQL.SceneCreateInput, andNew?: boolean) => Promise<void>;
onDelete?: () => void;
}
@ -268,10 +276,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
formik.setFieldValue("groups", newGroups);
}
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -279,6 +287,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
const encodingImage = ImageUtils.usePasteImage(onImageLoad);
function onImageLoad(imageData: string) {
@ -737,16 +750,31 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Form noValidate onSubmit={formik.handleSubmit}>
<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"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
{isNew ? (
<SplitButton
id="scene-save-split-button"
className="edit-button"
variant="primary"
disabled={!isEqual(formik.errors, {})}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : (
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
)}
{onDelete && (
<Button
className="edit-button"

View file

@ -1,4 +1,4 @@
import { Button, Modal } from "react-bootstrap";
import { Button, Dropdown, Modal, SplitButton } from "react-bootstrap";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { ImageInput } from "./ImageInput";
@ -10,6 +10,7 @@ interface IProps {
isEditing: boolean;
onToggleEdit: () => void;
onSave: () => void;
onSaveAndNew?: () => void;
saveDisabled?: boolean;
onDelete: () => void;
onAutoTag?: () => void;
@ -48,6 +49,23 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
function renderSaveButton() {
if (!props.isEditing) return;
if (props.isNew && props.onSaveAndNew) {
return (
<SplitButton
id="save-split-button"
variant="success"
className="save"
disabled={props.saveDisabled}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => props.onSave()}
>
<Dropdown.Item onClick={() => props.onSaveAndNew!()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
);
}
return (
<Button
variant="success"

View file

@ -62,10 +62,23 @@
padding: 0;
row-gap: 0.5rem;
.btn {
> .btn {
margin-right: 0.5rem;
white-space: nowrap;
}
> .btn-group {
margin-right: 0.5rem;
.btn {
margin-right: 0;
}
// Show caret on split button dropdown toggle
.dropdown-toggle-split::after {
content: "";
}
}
}
.col-md-8 .details-edit div:nth-last-child(2),

View file

@ -26,12 +26,14 @@ const StudioCreate: React.FC = () => {
const [createStudio] = useStudioCreate();
async function onSave(input: GQL.StudioCreateInput) {
async function onSave(input: GQL.StudioCreateInput, andNew?: boolean) {
const result = await createStudio({
variables: { input },
});
if (result.data?.studioCreate?.id) {
history.push(`/studios/${result.data.studioCreate.id}`);
if (!andNew) {
history.push(`/studios/${result.data.studioCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -24,7 +24,7 @@ import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal";
interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>;
onSubmit: (studio: GQL.StudioCreateInput) => Promise<void>;
onSubmit: (studio: GQL.StudioCreateInput, andNew?: boolean) => Promise<void>;
onCancel: () => void;
onDelete: () => void;
setImage: (image?: string | null) => void;
@ -132,10 +132,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
};
});
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -143,6 +143,11 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
function onImageLoad(imageData: string | null) {
formik.setFieldValue("image", imageData);
}
@ -248,6 +253,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
onSaveAndNew={isNew ? onSaveAndNewClick : undefined}
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onImageChange={onImageChange}
onImageChangeURL={onImageLoad}

View file

@ -25,7 +25,7 @@ const TagCreate: React.FC = () => {
const [createTag] = useTagCreate();
async function onSave(input: GQL.TagCreateInput) {
async function onSave(input: GQL.TagCreateInput, andNew?: boolean) {
const oldRelations = {
parents: [],
children: [],
@ -39,7 +39,9 @@ const TagCreate: React.FC = () => {
parents: created.parents,
children: created.children,
});
history.push(`/tags/${created.id}`);
if (!andNew) {
history.push(`/tags/${created.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -23,7 +23,7 @@ import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal";
interface ITagEditPanel {
tag: Partial<GQL.TagDataFragment>;
onSubmit: (tag: GQL.TagCreateInput) => Promise<void>;
onSubmit: (tag: GQL.TagCreateInput, andNew?: boolean) => Promise<void>;
onCancel: () => void;
onDelete: () => void;
setImage: (image?: string | null) => void;
@ -122,10 +122,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
};
});
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -133,6 +133,11 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
const encodingImage = ImageUtils.usePasteImage(onImageLoad);
useEffect(() => {
@ -272,6 +277,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
onSaveAndNew={isNew ? onSaveAndNewClick : undefined}
saveDisabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}

View file

@ -687,6 +687,11 @@ div.dropdown-menu {
.edit-button {
margin-right: 10px;
// Show caret on split button dropdown toggle
&.btn-group .dropdown-toggle-split::after {
content: "";
}
}
.wrap-tags {

View file

@ -105,6 +105,7 @@
"reshuffle": "Reshuffle",
"running": "running",
"save": "Save",
"save_and_new": "Save & New",
"save_delete_settings": "Use these options by default when deleting",
"save_filter": "Save filter",
"scan": "Scan",