Improve bulk performer editing (#2467)

* Cleanup Edit Performers dialog
* Add bulk text inputs to edit performers dialog
* Make bulk update code more generic
* Add remaining performer fields
This commit is contained in:
WithoutPants 2022-04-03 07:05:57 +10:00 committed by GitHub
parent 9e2261a813
commit f9cf77e3ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 287 additions and 127 deletions

View File

@ -2,8 +2,21 @@ fragment SlimPerformerData on Performer {
id
name
gender
url
twitter
instagram
image_path
favorite
country
birthdate
ethnicity
hair_color
eye_color
height
fake_tits
career_length
tattoos
piercings
tags {
id
name
@ -13,4 +26,6 @@ fragment SlimPerformerData on Performer {
stash_id
}
rating
death_date
weight
}

View File

@ -2,6 +2,7 @@
* Add python location in System Settings for script scrapers and plugins. ([#2409](https://github.com/stashapp/stash/pull/2409))
### 🎨 Improvements
* Added support for bulk editing most performer fields. ([#2467](https://github.com/stashapp/stash/pull/2467))
* Changed video player to videojs. ([#2100](https://github.com/stashapp/stash/pull/2100))
* Maintain lightbox settings and add lightbox settings to Interface settings page. ([#2406](https://github.com/stashapp/stash/pull/2406))
* Image lightbox now transitions to next/previous image when scrolling in pan-Y mode. ([#2403](https://github.com/stashapp/stash/pull/2403))

View File

@ -1,7 +1,6 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import _ from "lodash";
import { useBulkPerformerUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { Modal } from "src/components/Shared";
@ -10,39 +9,63 @@ import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregateRating,
getAggregateTagIds,
getAggregateState,
getAggregateStateObject,
} from "src/utils/bulkUpdate";
import { genderStrings, stringToGender } from "src/utils/gender";
import {
genderStrings,
genderToString,
stringToGender,
} from "src/utils/gender";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
interface IListOperationProps {
selected: GQL.SlimPerformerDataFragment[];
onClose: (applied: boolean) => void;
}
const performerFields = [
"favorite",
"url",
"instagram",
"twitter",
"rating",
"gender",
"birthdate",
"death_date",
"career_length",
"country",
"ethnicity",
"eye_color",
"height",
// "weight",
"measurements",
"fake_tits",
"hair_color",
"tattoos",
"piercings",
];
export const EditPerformersDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
const [rating, setRating] = useState<number>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({
mode: GQL.BulkUpdateIdMode.Add,
});
const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [favorite, setFavorite] = useState<boolean | undefined>();
const [ethnicity, setEthnicity] = useState<string | undefined>();
const [country, setCountry] = useState<string | undefined>();
const [eyeColor, setEyeColor] = useState<string | undefined>();
const [fakeTits, setFakeTits] = useState<string | undefined>();
const [careerLength, setCareerLength] = useState<string | undefined>();
const [tattoos, setTattoos] = useState<string | undefined>();
const [piercings, setPiercings] = useState<string | undefined>();
const [hairColor, setHairColor] = useState<string | undefined>();
const [gender, setGender] = useState<GQL.GenderEnum | undefined>();
const [
aggregateState,
setAggregateState,
] = useState<GQL.BulkPerformerUpdateInput>({});
// weight needs conversion to/from number
const [weight, setWeight] = useState<string | undefined>();
const [updateInput, setUpdateInput] = useState<GQL.BulkPerformerUpdateInput>(
{}
);
const genderOptions = [""].concat(genderStrings);
const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput());
@ -50,37 +73,36 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
// Network state
const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
function setUpdateField(input: Partial<GQL.BulkPerformerUpdateInput>) {
setUpdateInput({ ...updateInput, ...input });
}
function getPerformerInput(): GQL.BulkPerformerUpdateInput {
// need to determine what we are actually setting on each performer
const aggregateTagIds = getAggregateTagIds(props.selected);
const aggregateRating = getAggregateRating(props.selected);
const performerInput: GQL.BulkPerformerUpdateInput = {
ids: props.selected.map((performer) => {
return performer.id;
}),
...updateInput,
tag_ids: tagIds,
};
performerInput.rating = getAggregateInputValue(rating, aggregateRating);
performerInput.tag_ids = getAggregateInputIDs(
tagMode,
tagIds,
aggregateTagIds
// we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not
performerInput.rating = getAggregateInputValue(
updateInput.rating,
aggregateState.rating
);
performerInput.favorite = favorite;
performerInput.ethnicity = ethnicity;
performerInput.country = country;
performerInput.eye_color = eyeColor;
performerInput.fake_tits = fakeTits;
performerInput.career_length = careerLength;
performerInput.tattoos = tattoos;
performerInput.piercings = piercings;
performerInput.hair_color = hairColor;
performerInput.gender = gender;
// gender dropdown doesn't have unset functionality
// so need to determine what we are setting
performerInput.gender = getAggregateInputValue(
updateInput.gender,
aggregateState.gender
);
if (weight !== undefined) {
performerInput.weight = parseFloat(weight);
}
return performerInput;
}
@ -107,74 +129,39 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
}
useEffect(() => {
const updateState: GQL.BulkPerformerUpdateInput = {};
const state = props.selected;
let updateTagIds: string[] = [];
let updateFavorite: boolean | undefined;
let updateRating: number | undefined;
let updateGender: GQL.GenderEnum | undefined;
let updateWeight: string | undefined | null = undefined;
let first = true;
state.forEach((performer: GQL.SlimPerformerDataFragment) => {
const performerTagIDs = (performer.tags ?? []).map((p) => p.id).sort();
const performerRating = performer.rating;
getAggregateStateObject(updateState, performer, performerFields, first);
if (first) {
updateTagIds = performerTagIDs;
first = false;
updateFavorite = performer.favorite;
updateRating = performerRating ?? undefined;
updateGender = performer.gender ?? undefined;
} else {
if (!_.isEqual(performerTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (performer.favorite !== updateFavorite) {
updateFavorite = undefined;
}
if (performerRating !== updateRating) {
updateRating = undefined;
}
if (performer.gender !== updateGender) {
updateGender = undefined;
}
}
const performerTagIDs = (performer.tags ?? []).map((p) => p.id).sort();
updateTagIds =
getAggregateState(updateTagIds, performerTagIDs, first) ?? [];
const thisWeight =
performer.weight !== undefined && performer.weight !== null
? performer.weight.toString()
: performer.weight;
updateWeight = getAggregateState(updateWeight, thisWeight, first);
first = false;
});
setExistingTagIds(updateTagIds);
setFavorite(updateFavorite);
setRating(updateRating);
setGender(updateGender);
// these fields are not part of SlimPerformerDataFragment
setEthnicity(undefined);
setCountry(undefined);
setEyeColor(undefined);
setFakeTits(undefined);
setCareerLength(undefined);
setTattoos(undefined);
setPiercings(undefined);
setHairColor(undefined);
}, [props.selected, tagMode]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = favorite === undefined;
}
}, [favorite, checkboxRef]);
function cycleFavorite() {
if (favorite) {
setFavorite(undefined);
} else if (favorite === undefined) {
setFavorite(false);
} else {
setFavorite(true);
}
}
setWeight(updateWeight);
setAggregateState(updateState);
setUpdateInput(updateState);
}, [props.selected]);
function renderTextField(
name: string,
value: string | undefined,
value: string | undefined | null,
setter: (newValue: string | undefined) => void
) {
return (
@ -182,12 +169,10 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
<Form.Label>
<FormattedMessage id={name} />
</Form.Label>
<Form.Control
className="input-control"
type="text"
value={value}
onChange={(event) => setter(event.currentTarget.value)}
placeholder={intl.formatMessage({ id: name })}
<BulkUpdateTextInput
value={value === null ? "" : value ?? undefined}
valueChanged={(newValue) => setter(newValue)}
unsetDisabled={props.selected.length < 2}
/>
</Form.Group>
);
@ -219,20 +204,18 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
})}
<Col xs={9}>
<RatingStars
value={rating}
onSetRating={(value) => setRating(value)}
value={updateInput.rating ?? undefined}
onSetRating={(value) => setUpdateField({ rating: value })}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form>
<Form.Group controlId="favorite">
<Form.Check
type="checkbox"
label="Favorite"
checked={favorite}
ref={checkboxRef}
onChange={() => cycleFavorite()}
<IndeterminateCheckbox
setChecked={(checked) => setUpdateField({ favorite: checked })}
checked={updateInput.favorite ?? undefined}
label={intl.formatMessage({ id: "favourite" })}
/>
</Form.Group>
@ -243,8 +226,11 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
<Form.Control
as="select"
className="input-control"
value={genderToString(updateInput.gender ?? undefined)}
onChange={(event) =>
setGender(stringToGender(event.currentTarget.value))
setUpdateField({
gender: stringToGender(event.currentTarget.value),
})
}
>
{genderOptions.map((opt) => (
@ -255,14 +241,52 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
</Form.Control>
</Form.Group>
{renderTextField("country", country, setCountry)}
{renderTextField("ethnicity", ethnicity, setEthnicity)}
{renderTextField("hair_color", hairColor, setHairColor)}
{renderTextField("eye_color", eyeColor, setEyeColor)}
{renderTextField("fake_tits", fakeTits, setFakeTits)}
{renderTextField("tattoos", tattoos, setTattoos)}
{renderTextField("piercings", piercings, setPiercings)}
{renderTextField("career_length", careerLength, setCareerLength)}
{renderTextField("birthdate", updateInput.birthdate, (v) =>
setUpdateField({ birthdate: v })
)}
{renderTextField("death_date", updateInput.death_date, (v) =>
setUpdateField({ death_date: v })
)}
{renderTextField("country", updateInput.country, (v) =>
setUpdateField({ country: v })
)}
{renderTextField("ethnicity", updateInput.ethnicity, (v) =>
setUpdateField({ ethnicity: v })
)}
{renderTextField("hair_color", updateInput.hair_color, (v) =>
setUpdateField({ hair_color: v })
)}
{renderTextField("eye_color", updateInput.eye_color, (v) =>
setUpdateField({ eye_color: v })
)}
{renderTextField("height", updateInput.height, (v) =>
setUpdateField({ height: v })
)}
{renderTextField("weight", weight, (v) => setWeight(v))}
{renderTextField("measurements", updateInput.measurements, (v) =>
setUpdateField({ measurements: v })
)}
{renderTextField("fake_tits", updateInput.fake_tits, (v) =>
setUpdateField({ fake_tits: v })
)}
{renderTextField("tattoos", updateInput.tattoos, (v) =>
setUpdateField({ tattoos: v })
)}
{renderTextField("piercings", updateInput.piercings, (v) =>
setUpdateField({ piercings: v })
)}
{renderTextField("career_length", updateInput.career_length, (v) =>
setUpdateField({ career_length: v })
)}
{renderTextField("url", updateInput.url, (v) =>
setUpdateField({ url: v })
)}
{renderTextField("twitter", updateInput.twitter, (v) =>
setUpdateField({ twitter: v })
)}
{renderTextField("instagram", updateInput.instagram, (v) =>
setUpdateField({ instagram: v })
)}
<Form.Group controlId="tags">
<Form.Label>
@ -271,11 +295,11 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
<MultiSet
type="tags"
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds(itemIDs)}
onSetMode={(newMode) => setTagMode(newMode)}
onUpdate={(itemIDs) => setTagIds({ ...tagIds, ids: itemIDs })}
onSetMode={(newMode) => setTagIds({ ...tagIds, mode: newMode })}
existingIds={existingTagIds ?? []}
ids={tagIds ?? []}
mode={tagMode}
ids={tagIds.ids ?? []}
mode={tagIds.mode}
/>
</Form.Group>
</Form>

View File

@ -0,0 +1,45 @@
import React from "react";
import { Button, Form, FormControlProps, InputGroup } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Icon } from ".";
interface IBulkUpdateTextInputProps extends FormControlProps {
valueChanged: (value: string | undefined) => void;
unsetDisabled?: boolean;
}
export const BulkUpdateTextInput: React.FC<IBulkUpdateTextInputProps> = ({
valueChanged,
unsetDisabled,
...props
}) => {
const intl = useIntl();
const unsetClassName = props.value === undefined ? "unset" : "";
return (
<InputGroup className={`bulk-update-text-input ${unsetClassName}`}>
<Form.Control
{...props}
className="input-control"
type="text"
value={props.value ?? ""}
placeholder={
props.value === undefined
? `<${intl.formatMessage({ id: "existing_value" })}>`
: undefined
}
onChange={(event) => valueChanged(event.currentTarget.value)}
/>
{!unsetDisabled ? (
<Button
variant="secondary"
onClick={() => valueChanged(undefined)}
title={intl.formatMessage({ id: "actions.unset" })}
>
<Icon icon="ban" />
</Button>
) : undefined}
</InputGroup>
);
};

View File

@ -20,7 +20,7 @@ interface IIcon {
const Icon: React.FC<IIcon> = ({ icon, className, color, size }) => (
<FontAwesomeIcon
icon={icon}
className={`fa-icon ${className}`}
className={`fa-icon ${className ?? ""}`}
color={color}
size={size}
/>

View File

@ -48,7 +48,7 @@ export const IndeterminateCheckbox: React.FC<IIndeterminateCheckbox> = ({
checked === undefined ? indeterminateClassname : ""
}`}
ref={ref}
checked={checked}
checked={checked ?? false}
onChange={() => setChecked(cycleState())}
/>
);

View File

@ -74,6 +74,7 @@ const MultiSet: React.FunctionComponent<IMultiSetProps> = (
function renderModeButton(mode: GQL.BulkUpdateIdMode) {
return (
<Button
key={mode}
variant="primary"
active={props.mode === mode}
size="sm"

View File

@ -271,3 +271,30 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active {
.string-list-input .input-group {
margin-bottom: 0.25rem;
}
.bulk-update-text-input {
button {
background-color: $secondary;
color: $text-muted;
font-size: $btn-font-size-sm;
margin: $btn-padding-y $btn-padding-x;
padding: 0;
position: absolute;
right: 0;
z-index: 4;
&:hover,
&:focus,
&:active,
&:not(:disabled):not(.disabled):active,
&:not(:disabled):not(.disabled):active:focus {
background-color: $secondary;
border-color: transparent;
box-shadow: none;
}
}
&.unset button {
visibility: hidden;
}
}

View File

@ -101,6 +101,7 @@
},
"temp_disable": "Disable temporarily…",
"temp_enable": "Enable temporarily…",
"unset": "Unset",
"use_default": "Use default",
"view_random": "View Random",
"continue": "Continue",
@ -584,6 +585,7 @@
"delete_object_overflow": "…and {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.",
"delete_object_title": "Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"edit_entity_title": "Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"existing_value": "existing value",
"export_include_related_objects": "Include related objects in export",
"export_title": "Export",
"lightbox": {
@ -701,6 +703,7 @@
"warmth": "Warmth"
},
"ethnicity": "Ethnicity",
"existing_value": "existing value",
"eye_color": "Eye Colour",
"fake_tits": "Fake Tits",
"false": "False",

View File

@ -124,7 +124,7 @@ export function getAggregateMovieIds(state: IHasMovies[]) {
return ret;
}
function makeBulkUpdateIds(
export function makeBulkUpdateIds(
ids: string[],
mode: GQL.BulkUpdateIdMode
): GQL.BulkUpdateIds {
@ -152,6 +152,7 @@ export function getAggregateInputValue<V>(
}
}
// TODO - remove - this is incorrect
export function getAggregateInputIDs(
mode: GQL.BulkUpdateIdMode,
inputIds: string[] | undefined,
@ -173,3 +174,46 @@ export function getAggregateInputIDs(
return undefined;
}
export function getAggregateState<T>(
currentValue: T,
newValue: T,
first: boolean
) {
if (!first && !_.isEqual(currentValue, newValue)) {
return undefined;
}
return newValue;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setProperty<T, K extends keyof T>(obj: T, key: K, value: any) {
obj[key] = value;
}
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
export function getAggregateStateObject<O, I>(
output: O,
input: I,
fields: string[],
first: boolean
) {
fields.forEach((key) => {
const outputKey = key as keyof O;
const inputKey = key as keyof I;
const currentValue = getProperty(output, outputKey);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const performerValue = getProperty(input, inputKey) as any;
setProperty(
output,
outputKey,
getAggregateState(currentValue, performerValue, first)
);
});
}