Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ui/v2.5/graphql/mutations/studio.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ mutation StudioUpdate($input: StudioUpdateInput!) {
}
}

mutation BulkStudioUpdate($input: BulkStudioUpdateInput!) {
bulkStudioUpdate(input: $input) {
...StudioData
}
}

mutation StudioDestroy($id: ID!) {
studioDestroy(input: { id: $id })
}
Expand Down
2 changes: 2 additions & 0 deletions ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IBulkUpdateTextInputProps> = ({
Expand All @@ -24,6 +25,7 @@ export const BulkUpdateTextInput: React.FC<IBulkUpdateTextInputProps> = ({
{...props}
className="input-control"
type="text"
as={props.as}
value={props.value ?? ""}
placeholder={
props.value === undefined
Expand Down
245 changes: 245 additions & 0 deletions ui/v2.5/src/components/Studios/EditStudiosDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();

const [updateInput, setUpdateInput] = useState<GQL.BulkStudioUpdateInput>({
ids: props.selected.map((studio) => {
return studio.id;
}),
});

const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});

const [updateStudios] = useBulkStudioUpdate();

// Network state
const [isUpdating, setIsUpdating] = useState(false);

const aggregateState = useMemo(() => {
const updateState: Partial<GQL.BulkStudioUpdateInput> = {};
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<GQL.BulkStudioUpdateInput>) {
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 (
<Form.Group controlId={name}>
<Form.Label>
<FormattedMessage id={name} />
</Form.Label>
<BulkUpdateTextInput
value={value === null ? "" : value ?? undefined}
valueChanged={(newValue) => setter(newValue)}
unsetDisabled={props.selected.length < 2}
as={area ? "textarea" : undefined}
/>
</Form.Group>
);
}

function render() {
return (
<ModalComponent
dialogClassName="edit-studios-dialog"
show
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "studios" }) }
)}
accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
isRunning={isUpdating}
>
<Form.Group controlId="parent-studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "parent_studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
setUpdateField({
parent_id: items.length > 0 ? items[0]?.id : undefined,
})
}
ids={updateInput.parent_id ? [updateInput.parent_id] : []}
isDisabled={isUpdating}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingSystem
value={updateInput.rating100}
onSetRating={(value) =>
setUpdateField({ rating100: value ?? undefined })
}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form>
<Form.Group controlId="favorite">
<IndeterminateCheckbox
setChecked={(checked) => setUpdateField({ favorite: checked })}
checked={updateInput.favorite ?? undefined}
label={intl.formatMessage({ id: "favourite" })}
/>
</Form.Group>

<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
<MultiSet
type="tags"
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds((v) => ({ ...v, ids: itemIDs }))}
onSetMode={(newMode) =>
setTagIds((v) => ({ ...v, mode: newMode }))
}
existingIds={aggregateState.tagIds ?? []}
ids={tagIds.ids ?? []}
mode={tagIds.mode}
menuPortalTarget={document.body}
/>
</Form.Group>

{renderTextField(
"details",
updateInput.details,
(newValue) => setUpdateField({ details: newValue }),
true
)}

<Form.Group controlId="ignore-auto-tags">
<IndeterminateCheckbox
label={intl.formatMessage({ id: "ignore_auto_tag" })}
setChecked={(checked) =>
setUpdateField({ ignore_auto_tag: checked })
}
checked={updateInput.ignore_auto_tag ?? undefined}
/>
</Form.Group>
</Form>
</ModalComponent>
);
}

return render();
};
9 changes: 9 additions & 0 deletions ui/v2.5/src/components/Studios/StudioList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [];
Expand Down Expand Up @@ -161,6 +162,13 @@ export const StudioList: React.FC<IStudioList> = ({
);
}

function renderEditDialog(
selectedStudios: GQL.SlimStudioDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditStudiosDialog selected={selectedStudios} onClose={onClose} />;
}

function renderDeleteDialog(
selectedStudios: GQL.SlimStudioDataFragment[],
onClose: (confirmed: boolean) => void
Expand Down Expand Up @@ -193,6 +201,7 @@ export const StudioList: React.FC<IStudioList> = ({
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
Expand Down
10 changes: 10 additions & 0 deletions ui/v2.5/src/core/StashService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down