From f9cf77e3ed1ed7d3c41dd1ae6639e0c031a873c9 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sun, 3 Apr 2022 07:05:57 +1000 Subject: [PATCH] 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 --- graphql/documents/data/performer-slim.graphql | 15 + .../components/Changelog/versions/v0140.md | 1 + .../Performers/EditPerformersDialog.tsx | 272 ++++++++++-------- .../components/Shared/BulkUpdateTextInput.tsx | 45 +++ ui/v2.5/src/components/Shared/Icon.tsx | 2 +- .../Shared/IndeterminateCheckbox.tsx | 2 +- ui/v2.5/src/components/Shared/MultiSet.tsx | 1 + ui/v2.5/src/components/Shared/styles.scss | 27 ++ ui/v2.5/src/locales/en-GB.json | 3 + ui/v2.5/src/utils/bulkUpdate.ts | 46 ++- 10 files changed, 287 insertions(+), 127 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 1420d15c4..cafe7614d 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -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 } diff --git a/ui/v2.5/src/components/Changelog/versions/v0140.md b/ui/v2.5/src/components/Changelog/versions/v0140.md index 28571f097..f0206b1b7 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0140.md +++ b/ui/v2.5/src/components/Changelog/versions/v0140.md @@ -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)) diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index 5d8e4ed15..d06d0651a 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -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 = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating, setRating] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); const [existingTagIds, setExistingTagIds] = useState(); - const [favorite, setFavorite] = useState(); - const [ethnicity, setEthnicity] = useState(); - const [country, setCountry] = useState(); - const [eyeColor, setEyeColor] = useState(); - const [fakeTits, setFakeTits] = useState(); - const [careerLength, setCareerLength] = useState(); - const [tattoos, setTattoos] = useState(); - const [piercings, setPiercings] = useState(); - const [hairColor, setHairColor] = useState(); - const [gender, setGender] = useState(); + const [ + aggregateState, + setAggregateState, + ] = useState({}); + // weight needs conversion to/from number + const [weight, setWeight] = useState(); + const [updateInput, setUpdateInput] = useState( + {} + ); const genderOptions = [""].concat(genderStrings); const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); @@ -50,37 +73,36 @@ export const EditPerformersDialog: React.FC = ( // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + function setUpdateField(input: Partial) { + 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 = ( } 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 = ( - setter(event.currentTarget.value)} - placeholder={intl.formatMessage({ id: name })} + setter(newValue)} + unsetDisabled={props.selected.length < 2} /> ); @@ -219,20 +204,18 @@ export const EditPerformersDialog: React.FC = ( })} setRating(value)} + value={updateInput.rating ?? undefined} + onSetRating={(value) => setUpdateField({ rating: value })} disabled={isUpdating} />
- cycleFavorite()} + setUpdateField({ favorite: checked })} + checked={updateInput.favorite ?? undefined} + label={intl.formatMessage({ id: "favourite" })} /> @@ -243,8 +226,11 @@ export const EditPerformersDialog: React.FC = ( - setGender(stringToGender(event.currentTarget.value)) + setUpdateField({ + gender: stringToGender(event.currentTarget.value), + }) } > {genderOptions.map((opt) => ( @@ -255,14 +241,52 @@ export const EditPerformersDialog: React.FC = ( - {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 }) + )} @@ -271,11 +295,11 @@ export const EditPerformersDialog: React.FC = ( 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} /> diff --git a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx b/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx new file mode 100644 index 000000000..ba292d1a7 --- /dev/null +++ b/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx @@ -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 = ({ + valueChanged, + unsetDisabled, + ...props +}) => { + const intl = useIntl(); + + const unsetClassName = props.value === undefined ? "unset" : ""; + + return ( + + ` + : undefined + } + onChange={(event) => valueChanged(event.currentTarget.value)} + /> + {!unsetDisabled ? ( + + ) : undefined} + + ); +}; diff --git a/ui/v2.5/src/components/Shared/Icon.tsx b/ui/v2.5/src/components/Shared/Icon.tsx index ab158fb0b..533477575 100644 --- a/ui/v2.5/src/components/Shared/Icon.tsx +++ b/ui/v2.5/src/components/Shared/Icon.tsx @@ -20,7 +20,7 @@ interface IIcon { const Icon: React.FC = ({ icon, className, color, size }) => ( diff --git a/ui/v2.5/src/components/Shared/IndeterminateCheckbox.tsx b/ui/v2.5/src/components/Shared/IndeterminateCheckbox.tsx index d38bb31f4..c489190d5 100644 --- a/ui/v2.5/src/components/Shared/IndeterminateCheckbox.tsx +++ b/ui/v2.5/src/components/Shared/IndeterminateCheckbox.tsx @@ -48,7 +48,7 @@ export const IndeterminateCheckbox: React.FC = ({ checked === undefined ? indeterminateClassname : "" }`} ref={ref} - checked={checked} + checked={checked ?? false} onChange={() => setChecked(cycleState())} /> ); diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index de69a5324..6f7aa08e4 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -74,6 +74,7 @@ const MultiSet: React.FunctionComponent = ( function renderModeButton(mode: GQL.BulkUpdateIdMode) { return (