From e40b3d78b2932c7bc8733e0c088584915760a026 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 24 Aug 2023 11:15:49 +1000 Subject: [PATCH] Performer select refactor (#4013) * Overhaul performer select * Add interface to load performers by id * Add Performer ID select and replace existing --- graphql/documents/data/performer-slim.graphql | 7 + graphql/documents/queries/misc.graphql | 9 - graphql/documents/queries/performer.graphql | 24 +- graphql/schema/schema.graphql | 5 +- internal/api/resolver_query_find_performer.go | 15 +- .../GalleryDetails/GalleryEditPanel.tsx | 39 ++- .../Images/ImageDetails/ImageEditPanel.tsx | 33 ++- .../components/Performers/PerformerSelect.tsx | 241 ++++++++++++++++ ui/v2.5/src/components/Performers/styles.scss | 4 + .../Scenes/SceneDetails/SceneEditPanel.tsx | 38 ++- .../src/components/Shared/FilterSelect.tsx | 257 ++++++++++++++++++ ui/v2.5/src/components/Shared/Select.tsx | 150 +--------- ui/v2.5/src/components/Tagger/queries.ts | 25 -- .../Tagger/scenes/PerformerResult.tsx | 26 +- ui/v2.5/src/core/StashService.ts | 20 +- 15 files changed, 667 insertions(+), 226 deletions(-) create mode 100644 ui/v2.5/src/components/Performers/PerformerSelect.tsx create mode 100644 ui/v2.5/src/components/Shared/FilterSelect.tsx diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 65019b98b..5fbd1a2eb 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -34,3 +34,10 @@ fragment SlimPerformerData on Performer { death_date weight } + +fragment SelectPerformerData on Performer { + id + name + disambiguation + alias_list +} diff --git a/graphql/documents/queries/misc.graphql b/graphql/documents/queries/misc.graphql index 791392fb0..61354be53 100644 --- a/graphql/documents/queries/misc.graphql +++ b/graphql/documents/queries/misc.graphql @@ -6,15 +6,6 @@ query MarkerStrings($q: String, $sort: String) { } } -query AllPerformersForFilter { - allPerformers { - id - name - disambiguation - alias_list - } -} - query AllStudiosForFilter { allStudios { id diff --git a/graphql/documents/queries/performer.graphql b/graphql/documents/queries/performer.graphql index cc25752ac..3c3f689c3 100644 --- a/graphql/documents/queries/performer.graphql +++ b/graphql/documents/queries/performer.graphql @@ -1,8 +1,13 @@ query FindPerformers( $filter: FindFilterType $performer_filter: PerformerFilterType + $performer_ids: [Int!] ) { - findPerformers(filter: $filter, performer_filter: $performer_filter) { + findPerformers( + filter: $filter + performer_filter: $performer_filter + performer_ids: $performer_ids + ) { count performers { ...PerformerData @@ -15,3 +20,20 @@ query FindPerformer($id: ID!) { ...PerformerData } } + +query FindPerformersForSelect( + $filter: FindFilterType + $performer_filter: PerformerFilterType + $performer_ids: [Int!] +) { + findPerformers( + filter: $filter + performer_filter: $performer_filter + performer_ids: $performer_ids + ) { + count + performers { + ...SelectPerformerData + } + } +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 52f97adab..4c011ad0d 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -60,6 +60,7 @@ type Query { findPerformers( performer_filter: PerformerFilterType filter: FindFilterType + performer_ids: [Int!] ): FindPerformersResultType! "Find a studio by ID" @@ -223,11 +224,13 @@ type Query { allSceneMarkers: [SceneMarker!]! allImages: [Image!]! allGalleries: [Gallery!]! - allPerformers: [Performer!]! allStudios: [Studio!]! allMovies: [Movie!]! allTags: [Tag!]! + # @deprecated + allPerformers: [Performer!]! + # Get everything with minimal metadata # Version diff --git a/internal/api/resolver_query_find_performer.go b/internal/api/resolver_query_find_performer.go index 437ac8fcf..a47b7a18d 100644 --- a/internal/api/resolver_query_find_performer.go +++ b/internal/api/resolver_query_find_performer.go @@ -23,9 +23,19 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode return ret, nil } -func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType) (ret *FindPerformersResultType, err error) { +func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int) (ret *FindPerformersResultType, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - performers, total, err := r.repository.Performer.Query(ctx, performerFilter, filter) + var performers []*models.Performer + var err error + var total int + + if len(performerIDs) > 0 { + performers, err = r.repository.Performer.FindMany(ctx, performerIDs) + total = len(performers) + } else { + performers, total, err = r.repository.Performer.Query(ctx, performerFilter, filter) + } + if err != nil { return err } @@ -34,6 +44,7 @@ func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *mod Count: total, Performers: performers, } + return nil }); err != nil { return nil, err diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index c0d037661..2ae7f44e5 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -19,7 +19,6 @@ import { mutateReloadScrapers, } from "src/core/StashService"; import { - PerformerSelect, TagSelect, SceneSelect, StudioSelect, @@ -39,6 +38,10 @@ import { ConfigurationContext } from "src/hooks/Config"; import isEqual from "lodash-es/isEqual"; import { DateInput } from "src/components/Shared/DateInput"; import { handleUnsavedChanges } from "src/utils/navigation"; +import { + Performer, + PerformerSelect, +} from "src/components/Performers/PerformerSelect"; interface IProps { gallery: Partial; @@ -62,6 +65,8 @@ export const GalleryEditPanel: React.FC = ({ })) ); + const [performers, setPerformers] = useState([]); + const isNew = gallery.id === undefined; const { configuration: stashConfig } = React.useContext(ConfigurationContext); @@ -139,12 +144,24 @@ export const GalleryEditPanel: React.FC = ({ ); } + function onSetPerformers(items: Performer[]) { + setPerformers(items); + formik.setFieldValue( + "performer_ids", + items.map((item) => item.id) + ); + } + useRatingKeybinds( isVisible, stashConfig?.ui?.ratingSystemOptions?.type, setRating ); + useEffect(() => { + setPerformers(gallery.performers ?? []); + }, [gallery.performers]); + useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { @@ -309,8 +326,15 @@ export const GalleryEditPanel: React.FC = ({ }); if (idPerfs.length > 0) { - const newIds = idPerfs.map((p) => p.stored_id); - formik.setFieldValue("performer_ids", newIds as string[]); + onSetPerformers( + idPerfs.map((p) => { + return { + id: p.stored_id!, + name: p.name ?? "", + alias_list: [], + }; + }) + ); } } @@ -472,13 +496,8 @@ export const GalleryEditPanel: React.FC = ({ - formik.setFieldValue( - "performer_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.performer_ids} + onSelect={onSetPerformers} + values={performers} /> diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 23e1a8996..a684e29da 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -4,11 +4,7 @@ import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; -import { - PerformerSelect, - TagSelect, - StudioSelect, -} from "src/components/Shared/Select"; +import { TagSelect, StudioSelect } from "src/components/Shared/Select"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { URLField } from "src/components/Shared/URLField"; import { useToast } from "src/hooks/Toast"; @@ -20,6 +16,10 @@ import { useRatingKeybinds } from "src/hooks/keybinds"; import { ConfigurationContext } from "src/hooks/Config"; import isEqual from "lodash-es/isEqual"; import { DateInput } from "src/components/Shared/DateInput"; +import { + Performer, + PerformerSelect, +} from "src/components/Performers/PerformerSelect"; interface IProps { image: GQL.ImageDataFragment; @@ -42,6 +42,8 @@ export const ImageEditPanel: React.FC = ({ const { configuration } = React.useContext(ConfigurationContext); + const [performers, setPerformers] = useState([]); + const schema = yup.object({ title: yup.string().ensure(), url: yup.string().ensure(), @@ -87,12 +89,24 @@ export const ImageEditPanel: React.FC = ({ formik.setFieldValue("rating100", v); } + function onSetPerformers(items: Performer[]) { + setPerformers(items); + formik.setFieldValue( + "performer_ids", + items.map((item) => item.id) + ); + } + useRatingKeybinds( true, configuration?.ui?.ratingSystemOptions?.type, setRating ); + useEffect(() => { + setPerformers(image.performers ?? []); + }, [image.performers]); + useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { @@ -249,13 +263,8 @@ export const ImageEditPanel: React.FC = ({ - formik.setFieldValue( - "performer_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.performer_ids} + onSelect={onSetPerformers} + values={performers} /> diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx new file mode 100644 index 000000000..c721d652d --- /dev/null +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -0,0 +1,241 @@ +import React, { useEffect, useState } from "react"; +import { + OptionProps, + components as reactSelectComponents, + MultiValueGenericProps, + SingleValueProps, +} from "react-select"; + +import * as GQL from "src/core/generated-graphql"; +import { + usePerformerCreate, + queryFindPerformersByIDForSelect, + queryFindPerformersForSelect, +} from "src/core/StashService"; +import { ConfigurationContext } from "src/hooks/Config"; +import { useIntl } from "react-intl"; +import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { + FilterSelectComponent, + IFilterIDProps, + IFilterProps, + IFilterValueProps, + Option as SelectOption, +} from "../Shared/FilterSelect"; +import { useCompare } from "src/hooks/state"; + +export type SelectObject = { + id: string; + name?: string | null; + title?: string | null; +}; + +export type Performer = Pick< + GQL.Performer, + "id" | "name" | "alias_list" | "disambiguation" +>; +type Option = SelectOption; + +export const PerformerSelect: React.FC< + IFilterProps & IFilterValueProps +> = (props) => { + const [createPerformer] = usePerformerCreate(); + + const { configuration } = React.useContext(ConfigurationContext); + const intl = useIntl(); + const maxOptionsShown = + (configuration?.ui as IUIConfig).maxOptionsShown ?? defaultMaxOptionsShown; + const defaultCreatable = + !configuration?.interface.disableDropdownCreate.performer ?? true; + + async function loadPerformers(input: string): Promise { + const filter = new ListFilterModel(GQL.FilterMode.Performers); + filter.searchTerm = input; + filter.currentPage = 1; + filter.itemsPerPage = maxOptionsShown; + filter.sortBy = "name"; + filter.sortDirection = GQL.SortDirectionEnum.Asc; + const query = await queryFindPerformersForSelect(filter); + return query.data.findPerformers.performers.map((performer) => ({ + value: performer.id, + object: performer, + })); + } + + const PerformerOption: React.FC> = ( + optionProps + ) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + let { name } = object; + + // if name does not match the input value but an alias does, show the alias + const { inputValue } = optionProps.selectProps; + let alias: string | undefined = ""; + if (!name.toLowerCase().includes(inputValue.toLowerCase())) { + alias = object.alias_list?.find((a) => + a.toLowerCase().includes(inputValue.toLowerCase()) + ); + } + + thisOptionProps = { + ...optionProps, + children: ( + + {name} + {object.disambiguation && ( + {` (${object.disambiguation})`} + )} + {alias && {` (${alias})`}} + + ), + }; + + return ; + }; + + const PerformerMultiValueLabel: React.FC< + MultiValueGenericProps + > = (optionProps) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + thisOptionProps = { + ...optionProps, + children: object.name, + }; + + return ; + }; + + const PerformerValueLabel: React.FC> = ( + optionProps + ) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + thisOptionProps = { + ...optionProps, + children: object.name, + }; + + return ; + }; + + const onCreate = async (name: string) => { + const result = await createPerformer({ + variables: { input: { name } }, + }); + return { + value: result.data!.performerCreate!.id, + item: result.data!.performerCreate!, + message: "Created performer", + }; + }; + + const getNamedObject = (id: string, name: string) => { + return { + id, + name, + alias_list: [], + }; + }; + + const isValidNewOption = (inputValue: string, options: Performer[]) => { + if (!inputValue) { + return false; + } + + if ( + options.some((o) => { + return ( + o.name.toLowerCase() === inputValue.toLowerCase() || + o.alias_list?.some( + (a) => a.toLowerCase() === inputValue.toLowerCase() + ) + ); + }) + ) { + return false; + } + + return true; + }; + + return ( + + {...props} + loadOptions={loadPerformers} + getNamedObject={getNamedObject} + isValidNewOption={isValidNewOption} + components={{ + Option: PerformerOption, + MultiValueLabel: PerformerMultiValueLabel, + SingleValue: PerformerValueLabel, + }} + isMulti={props.isMulti ?? false} + creatable={props.creatable ?? defaultCreatable} + onCreate={onCreate} + placeholder={ + props.noSelectionString ?? + intl.formatMessage( + { id: "actions.select_entity" }, + { entityType: intl.formatMessage({ id: "performer" }) } + ) + } + /> + ); +}; + +export const PerformerIDSelect: React.FC< + IFilterProps & IFilterIDProps +> = (props) => { + const { ids, onSelect: onSelectValues } = props; + + const [values, setValues] = useState([]); + const idsChanged = useCompare(ids); + + function onSelect(items: Performer[]) { + setValues(items); + onSelectValues?.(items); + } + + async function loadObjectsByID(idsToLoad: string[]): Promise { + const performerIDs = idsToLoad.map((id) => parseInt(id)); + const query = await queryFindPerformersByIDForSelect(performerIDs); + const { performers: loadedPerformers } = query.data.findPerformers; + + return loadedPerformers; + } + + useEffect(() => { + if (!idsChanged) { + return; + } + + if (!ids || ids?.length === 0) { + setValues([]); + return; + } + + // load the values if we have ids and they haven't been loaded yet + const filteredValues = values.filter((v) => ids.includes(v.id.toString())); + if (filteredValues.length === ids.length) { + return; + } + + const load = async () => { + const items = await loadObjectsByID(ids); + setValues(items); + }; + + load(); + }, [ids, idsChanged, values]); + + return ; +}; diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 4a0ec524a..445172804 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -223,3 +223,7 @@ content: ""; } } + +.react-select .alias { + font-weight: bold; +} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 15989fa3c..741f22145 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -20,7 +20,6 @@ import { queryScrapeSceneQueryFragment, } from "src/core/StashService"; import { - PerformerSelect, TagSelect, StudioSelect, GallerySelect, @@ -51,6 +50,10 @@ import { useRatingKeybinds } from "src/hooks/keybinds"; import { lazyComponent } from "src/utils/lazyComponent"; import isEqual from "lodash-es/isEqual"; import { DateInput } from "src/components/Shared/DateInput"; +import { + Performer, + PerformerSelect, +} from "src/components/Performers/PerformerSelect"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -78,6 +81,7 @@ export const SceneEditPanel: React.FC = ({ const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( [] ); + const [performers, setPerformers] = useState([]); const Scrapers = useListSceneScrapers(); const [fragmentScrapers, setFragmentScrapers] = useState([]); @@ -98,6 +102,10 @@ export const SceneEditPanel: React.FC = ({ ); }, [scene.galleries]); + useEffect(() => { + setPerformers(scene.performers ?? []); + }, [scene.performers]); + const { configuration: stashConfig } = React.useContext(ConfigurationContext); // Network state @@ -218,6 +226,14 @@ export const SceneEditPanel: React.FC = ({ ); } + function onSetPerformers(items: Performer[]) { + setPerformers(items); + formik.setFieldValue( + "performer_ids", + items.map((item) => item.id) + ); + } + useRatingKeybinds( isVisible, stashConfig?.ui?.ratingSystemOptions?.type, @@ -581,8 +597,15 @@ export const SceneEditPanel: React.FC = ({ }); if (idPerfs.length > 0) { - const newIds = idPerfs.map((p) => p.stored_id); - formik.setFieldValue("performer_ids", newIds as string[]); + onSetPerformers( + idPerfs.map((p) => { + return { + id: p.stored_id!, + name: p.name ?? "", + alias_list: [], + }; + }) + ); } } @@ -852,13 +875,8 @@ export const SceneEditPanel: React.FC = ({ - formik.setFieldValue( - "performer_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.performer_ids} + onSelect={onSetPerformers} + values={performers} /> diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx new file mode 100644 index 000000000..faf14a7fc --- /dev/null +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -0,0 +1,257 @@ +import React, { useMemo, useState } from "react"; +import { + OnChangeValue, + StylesConfig, + GroupBase, + OptionsOrGroups, + Options, +} from "react-select"; +import AsyncSelect from "react-select/async"; +import AsyncCreatableSelect, { + AsyncCreatableProps, +} from "react-select/async-creatable"; + +import { useToast } from "src/hooks/Toast"; +import { useDebounce } from "src/hooks/debounce"; + +interface IHasID { + id: string; +} + +export type Option = { value: string; object: T }; + +interface ISelectProps + extends AsyncCreatableProps, IsMulti, GroupBase>> { + selectedOptions?: OnChangeValue, IsMulti>; + creatable?: boolean; + isLoading?: boolean; + isDisabled?: boolean; + placeholder?: string; + showDropdown?: boolean; + groupHeader?: string; + noOptionsMessageText?: string | null; +} + +interface IFilterSelectProps + extends Pick< + ISelectProps, + | "selectedOptions" + | "isLoading" + | "isMulti" + | "components" + | "placeholder" + | "closeMenuOnSelect" + > {} + +const getSelectedItems = ( + selectedItems: OnChangeValue, boolean> +) => { + if (Array.isArray(selectedItems)) { + return selectedItems; + } else if (selectedItems) { + return [selectedItems]; + } else { + return []; + } +}; + +const SelectComponent = ( + props: ISelectProps +) => { + const { + selectedOptions, + isLoading, + isDisabled = false, + creatable = false, + components, + placeholder, + showDropdown = true, + noOptionsMessageText: noOptionsMessage = "None", + } = props; + + const styles: StylesConfig, IsMulti> = { + option: (base) => ({ + ...base, + color: "#000", + }), + container: (base, state) => ({ + ...base, + zIndex: state.isFocused ? 10 : base.zIndex, + }), + multiValueRemove: (base, state) => ({ + ...base, + color: state.isFocused ? base.color : "#333333", + }), + }; + + const componentProps = { + ...props, + styles, + defaultOptions: true, + value: selectedOptions, + className: "react-select", + classNamePrefix: "react-select", + noOptionsMessage: () => noOptionsMessage, + placeholder: isDisabled ? "" : placeholder, + components: { + ...components, + IndicatorSeparator: () => null, + ...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }), + ...(isDisabled && { MultiValueRemove: () => null }), + }, + }; + + return creatable ? ( + + ) : ( + + ); +}; + +export interface IFilterValueProps { + values?: T[]; + onSelect?: (item: T[]) => void; +} + +export interface IFilterProps { + noSelectionString?: string; + className?: string; + isMulti?: boolean; + isClearable?: boolean; + isDisabled?: boolean; + creatable?: boolean; + menuPortalTarget?: HTMLElement | null; +} + +export interface IFilterComponentProps extends IFilterProps { + loadOptions: (inputValue: string) => Promise[]>; + onCreate?: ( + name: string + ) => Promise<{ value: string; item: T; message: string }>; + getNamedObject: (id: string, name: string) => T; + isValidNewOption: (inputValue: string, options: T[]) => boolean; +} + +export const FilterSelectComponent = < + T extends IHasID, + IsMulti extends boolean +>( + props: IFilterValueProps & + IFilterComponentProps & + IFilterSelectProps +) => { + const { + values, + isMulti, + onSelect, + isValidNewOption, + getNamedObject, + loadOptions, + } = props; + const [loading, setLoading] = useState(false); + const Toast = useToast(); + + const selectedOptions = useMemo(() => { + if (isMulti && values) { + return values.map( + (value) => + ({ + object: value, + value: value.id, + } as Option) + ) as unknown as OnChangeValue, IsMulti>; + } + + if (values?.length) { + return { + object: values[0], + value: values[0].id, + } as OnChangeValue, IsMulti>; + } + }, [values, isMulti]); + + const onChange = (selectedItems: OnChangeValue, boolean>) => { + const selected = getSelectedItems(selectedItems); + + onSelect?.(selected.map((item) => item.object)); + }; + + const onCreate = async (name: string) => { + try { + setLoading(true); + const { value, item: newItem, message } = await props.onCreate!(name); + const newItemOption = { + object: newItem, + value, + } as Option; + if (!isMulti) { + onChange(newItemOption); + } else { + const o = (selectedOptions ?? []) as Option[]; + onChange([...o, newItemOption]); + } + + setLoading(false); + Toast.success({ + content: ( + + {message}: {name} + + ), + }); + } catch (e) { + Toast.error(e); + } + }; + + const getNewOptionData = ( + inputValue: string, + optionLabel: React.ReactNode + ) => { + return { + value: "", + object: getNamedObject("", optionLabel as string), + }; + }; + + const validNewOption = ( + inputValue: string, + value: Options>, + options: OptionsOrGroups, GroupBase>> + ) => { + return isValidNewOption( + inputValue, + (options as Options>).map((o) => o.object) + ); + }; + + const debounceDelay = 100; + const debounceLoadOptions = useDebounce( + (inputValue, callback) => { + loadOptions(inputValue).then(callback); + }, + [loadOptions], + debounceDelay + ); + + return ( + + {...props} + loadOptions={debounceLoadOptions} + isLoading={props.isLoading || loading} + onChange={onChange} + selectedOptions={selectedOptions} + onCreateOption={props.creatable ? onCreate : undefined} + getNewOptionData={getNewOptionData} + isValidNewOption={validNewOption} + /> + ); +}; + +export interface IFilterIDProps { + ids?: string[]; + onSelect?: (item: T[]) => void; +} diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 6ae86b05c..f7c264609 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -16,11 +16,9 @@ import { useAllTagsForFilter, useAllMoviesForFilter, useAllStudiosForFilter, - useAllPerformersForFilter, useMarkerStrings, useTagCreate, useStudioCreate, - usePerformerCreate, useMovieCreate, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; @@ -33,6 +31,7 @@ import { TagPopover } from "../Tags/TagPopover"; import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; import { useDebouncedSetState } from "src/hooks/debounce"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { PerformerIDSelect } from "../Performers/PerformerSelect"; export type SelectObject = { id: string; @@ -533,152 +532,7 @@ export const MarkerTitleSuggest: React.FC = (props) => { }; export const PerformerSelect: React.FC = (props) => { - const [performerAliases, setPerformerAliases] = useState< - Record - >({}); - const [performerDisambiguations, setPerformerDisambiguations] = useState< - Record - >({}); - const [allAliases, setAllAliases] = useState([]); - const { data, loading } = useAllPerformersForFilter(); - const [createPerformer] = usePerformerCreate(); - - const { configuration } = React.useContext(ConfigurationContext); - const intl = useIntl(); - const defaultCreatable = - !configuration?.interface.disableDropdownCreate.performer ?? true; - - const performers = useMemo( - () => data?.allPerformers ?? [], - [data?.allPerformers] - ); - - useEffect(() => { - // build the tag aliases map - const newAliases: Record = {}; - const newDisambiguations: Record = {}; - const newAll: string[] = []; - performers.forEach((t) => { - if (t.alias_list.length) { - newAliases[t.id] = t.alias_list; - } - newAll.push(...t.alias_list); - if (t.disambiguation) { - newDisambiguations[t.id] = t.disambiguation; - } - }); - setPerformerAliases(newAliases); - setAllAliases(newAll); - setPerformerDisambiguations(newDisambiguations); - }, [performers]); - - const PerformerOption: React.FC> = ( - optionProps - ) => { - const { inputValue } = optionProps.selectProps; - - let thisOptionProps = optionProps; - - let { label } = optionProps.data; - const id = Number(optionProps.data.value); - - if (id && performerDisambiguations[id]) { - label += ` (${performerDisambiguations[id]})`; - } - - if ( - inputValue && - !optionProps.label.toLowerCase().includes(inputValue.toLowerCase()) - ) { - // must be alias - label += " (alias)"; - } - - if (label != optionProps.data.label) { - thisOptionProps = { - ...optionProps, - children: label, - }; - } - - return ; - }; - - const filterOption = (option: Option, rawInput: string): boolean => { - if (!rawInput) { - return true; - } - - const input = rawInput.toLowerCase(); - const optionVal = option.label.toLowerCase(); - - if (optionVal.includes(input)) { - return true; - } - - // search for performer aliases - const aliases = performerAliases[option.value]; - return aliases && aliases.some((a) => a.toLowerCase().includes(input)); - }; - - const isValidNewOption = ( - inputValue: string, - value: Options