From 1ec8d4afe5162339ecf95d55d7f7ca1cb6b34c1d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:12:50 +1100 Subject: [PATCH] Add edit studios dialog (#6238) --- ui/v2.5/graphql/mutations/studio.graphql | 6 + .../components/Shared/BulkUpdateTextInput.tsx | 2 + .../components/Studios/EditStudiosDialog.tsx | 245 ++++++++++++++++++ ui/v2.5/src/components/Studios/StudioList.tsx | 9 + ui/v2.5/src/core/StashService.ts | 10 + 5 files changed, 272 insertions(+) create mode 100644 ui/v2.5/src/components/Studios/EditStudiosDialog.tsx diff --git a/ui/v2.5/graphql/mutations/studio.graphql b/ui/v2.5/graphql/mutations/studio.graphql index 6d1944dc1..679d75f6d 100644 --- a/ui/v2.5/graphql/mutations/studio.graphql +++ b/ui/v2.5/graphql/mutations/studio.graphql @@ -10,6 +10,12 @@ mutation StudioUpdate($input: StudioUpdateInput!) { } } +mutation BulkStudioUpdate($input: BulkStudioUpdateInput!) { + bulkStudioUpdate(input: $input) { + ...StudioData + } +} + mutation StudioDestroy($id: ID!) { studioDestroy(input: { id: $id }) } diff --git a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx b/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx index 542ab5b3b..cf78798e1 100644 --- a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx +++ b/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx @@ -7,6 +7,7 @@ import { Icon } from "./Icon"; interface IBulkUpdateTextInputProps extends FormControlProps { valueChanged: (value: string | undefined) => void; unsetDisabled?: boolean; + as?: React.ElementType; } export const BulkUpdateTextInput: React.FC = ({ @@ -24,6 +25,7 @@ export const BulkUpdateTextInput: React.FC = ({ {...props} className="input-control" type="text" + as={props.as} value={props.value ?? ""} placeholder={ props.value === undefined diff --git a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx new file mode 100644 index 000000000..293a8dfb3 --- /dev/null +++ b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx @@ -0,0 +1,245 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Col, Form, Row } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useBulkStudioUpdate } from "src/core/StashService"; +import * as GQL from "src/core/generated-graphql"; +import { ModalComponent } from "../Shared/Modal"; +import { useToast } from "src/hooks/Toast"; +import { MultiSet } from "../Shared/MultiSet"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; +import { + getAggregateInputValue, + getAggregateState, + getAggregateStateObject, +} from "src/utils/bulkUpdate"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; +import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import * as FormUtils from "src/utils/form"; +import { StudioSelect } from "../Shared/Select"; + +interface IListOperationProps { + selected: GQL.SlimStudioDataFragment[]; + onClose: (applied: boolean) => void; +} + +const studioFields = ["favorite", "rating100", "details", "ignore_auto_tag"]; + +export const EditStudiosDialog: React.FC = ( + props: IListOperationProps +) => { + const intl = useIntl(); + const Toast = useToast(); + + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((studio) => { + return studio.id; + }), + }); + + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const [updateStudios] = useBulkStudioUpdate(); + + // Network state + const [isUpdating, setIsUpdating] = useState(false); + + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + let updateTagIds: string[] = []; + let first = true; + + state.forEach((studio: GQL.SlimStudioDataFragment) => { + getAggregateStateObject(updateState, studio, studioFields, first); + + // studio data fragment doesn't have parent_id, so handle separately + updateState.parent_id = getAggregateState( + updateState.parent_id, + studio.parent_studio?.id, + first + ); + + const studioTagIDs = (studio.tags ?? []).map((p) => p.id).sort(); + + updateTagIds = getAggregateState(updateTagIds, studioTagIDs, first) ?? []; + + first = false; + }); + + return { state: updateState, tagIds: updateTagIds }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } + + function getStudioInput(): GQL.BulkStudioUpdateInput { + const studioInput: GQL.BulkStudioUpdateInput = { + ...updateInput, + tag_ids: tagIds, + }; + + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + studioInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 + ); + + return studioInput; + } + + async function onSave() { + setIsUpdating(true); + try { + await updateStudios({ + variables: { + input: getStudioInput(), + }, + }); + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "studios" }).toLocaleLowerCase(), + } + ) + ); + props.onClose(true); + } catch (e) { + Toast.error(e); + } + setIsUpdating(false); + } + + function renderTextField( + name: string, + value: string | undefined | null, + setter: (newValue: string | undefined) => void, + area: boolean = false + ) { + return ( + + + + + setter(newValue)} + unsetDisabled={props.selected.length < 2} + as={area ? "textarea" : undefined} + /> + + ); + } + + function render() { + return ( + props.onClose(false), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={isUpdating} + > + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "parent_studio" }), + })} + + + setUpdateField({ + parent_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.parent_id ? [updateInput.parent_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "rating" }), + })} + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + + +
+ + setUpdateField({ favorite: checked })} + checked={updateInput.favorite ?? undefined} + label={intl.formatMessage({ id: "favourite" })} + /> + + + + + + + setTagIds((v) => ({ ...v, ids: itemIDs }))} + onSetMode={(newMode) => + setTagIds((v) => ({ ...v, mode: newMode })) + } + existingIds={aggregateState.tagIds ?? []} + ids={tagIds.ids ?? []} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + {renderTextField( + "details", + updateInput.details, + (newValue) => setUpdateField({ details: newValue }), + true + )} + + + + setUpdateField({ ignore_auto_tag: checked }) + } + checked={updateInput.ignore_auto_tag ?? undefined} + /> + +
+
+ ); + } + + return render(); +}; diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index dd67f560b..423cd1587 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -17,6 +17,7 @@ import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { StudioTagger } from "../Tagger/studios/StudioTagger"; import { StudioCardGrid } from "./StudioCardGrid"; import { View } from "../List/views"; +import { EditStudiosDialog } from "./EditStudiosDialog"; function getItems(result: GQL.FindStudiosQueryResult) { return result?.data?.findStudios?.studios ?? []; @@ -161,6 +162,13 @@ export const StudioList: React.FC = ({ ); } + function renderEditDialog( + selectedStudios: GQL.SlimStudioDataFragment[], + onClose: (applied: boolean) => void + ) { + return ; + } + function renderDeleteDialog( selectedStudios: GQL.SlimStudioDataFragment[], onClose: (confirmed: boolean) => void @@ -193,6 +201,7 @@ export const StudioList: React.FC = ({ otherOperations={otherOperations} addKeybinds={addKeybinds} renderContent={renderContent} + renderEditDialog={renderEditDialog} renderDeleteDialog={renderDeleteDialog} /> diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 51cdd3ca5..be5fb4dbe 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -1907,6 +1907,16 @@ export const useStudioUpdate = () => }, }); +export const useBulkStudioUpdate = () => + GQL.useBulkStudioUpdateMutation({ + update(cache, result) { + if (!result.data?.bulkStudioUpdate) return; + + evictTypeFields(cache, studioMutationImpactedTypeFields); + evictQueries(cache, studioMutationImpactedQueries); + }, + }); + export const useStudioDestroy = (input: GQL.StudioDestroyInput) => GQL.useStudioDestroyMutation({ variables: input,