mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
Performer select refactor (#4013)
* Overhaul performer select * Add interface to load performers by id * Add Performer ID select and replace existing
This commit is contained in:
257
ui/v2.5/src/components/Shared/FilterSelect.tsx
Normal file
257
ui/v2.5/src/components/Shared/FilterSelect.tsx
Normal file
@@ -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<T> = { value: string; object: T };
|
||||
|
||||
interface ISelectProps<T, IsMulti extends boolean>
|
||||
extends AsyncCreatableProps<Option<T>, IsMulti, GroupBase<Option<T>>> {
|
||||
selectedOptions?: OnChangeValue<Option<T>, IsMulti>;
|
||||
creatable?: boolean;
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
placeholder?: string;
|
||||
showDropdown?: boolean;
|
||||
groupHeader?: string;
|
||||
noOptionsMessageText?: string | null;
|
||||
}
|
||||
|
||||
interface IFilterSelectProps<T, IsMulti extends boolean>
|
||||
extends Pick<
|
||||
ISelectProps<T, IsMulti>,
|
||||
| "selectedOptions"
|
||||
| "isLoading"
|
||||
| "isMulti"
|
||||
| "components"
|
||||
| "placeholder"
|
||||
| "closeMenuOnSelect"
|
||||
> {}
|
||||
|
||||
const getSelectedItems = <T,>(
|
||||
selectedItems: OnChangeValue<Option<T>, boolean>
|
||||
) => {
|
||||
if (Array.isArray(selectedItems)) {
|
||||
return selectedItems;
|
||||
} else if (selectedItems) {
|
||||
return [selectedItems];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const SelectComponent = <T, IsMulti extends boolean>(
|
||||
props: ISelectProps<T, IsMulti>
|
||||
) => {
|
||||
const {
|
||||
selectedOptions,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
creatable = false,
|
||||
components,
|
||||
placeholder,
|
||||
showDropdown = true,
|
||||
noOptionsMessageText: noOptionsMessage = "None",
|
||||
} = props;
|
||||
|
||||
const styles: StylesConfig<Option<T>, 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 ? (
|
||||
<AsyncCreatableSelect
|
||||
{...componentProps}
|
||||
isDisabled={isLoading || isDisabled}
|
||||
/>
|
||||
) : (
|
||||
<AsyncSelect {...componentProps} />
|
||||
);
|
||||
};
|
||||
|
||||
export interface IFilterValueProps<T> {
|
||||
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<T> extends IFilterProps {
|
||||
loadOptions: (inputValue: string) => Promise<Option<T>[]>;
|
||||
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<T> &
|
||||
IFilterComponentProps<T> &
|
||||
IFilterSelectProps<T, IsMulti>
|
||||
) => {
|
||||
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<T>)
|
||||
) as unknown as OnChangeValue<Option<T>, IsMulti>;
|
||||
}
|
||||
|
||||
if (values?.length) {
|
||||
return {
|
||||
object: values[0],
|
||||
value: values[0].id,
|
||||
} as OnChangeValue<Option<T>, IsMulti>;
|
||||
}
|
||||
}, [values, isMulti]);
|
||||
|
||||
const onChange = (selectedItems: OnChangeValue<Option<T>, 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<T>;
|
||||
if (!isMulti) {
|
||||
onChange(newItemOption);
|
||||
} else {
|
||||
const o = (selectedOptions ?? []) as Option<T>[];
|
||||
onChange([...o, newItemOption]);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
Toast.success({
|
||||
content: (
|
||||
<span>
|
||||
{message}: <b>{name}</b>
|
||||
</span>
|
||||
),
|
||||
});
|
||||
} 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<Option<T>>,
|
||||
options: OptionsOrGroups<Option<T>, GroupBase<Option<T>>>
|
||||
) => {
|
||||
return isValidNewOption(
|
||||
inputValue,
|
||||
(options as Options<Option<T>>).map((o) => o.object)
|
||||
);
|
||||
};
|
||||
|
||||
const debounceDelay = 100;
|
||||
const debounceLoadOptions = useDebounce(
|
||||
(inputValue, callback) => {
|
||||
loadOptions(inputValue).then(callback);
|
||||
},
|
||||
[loadOptions],
|
||||
debounceDelay
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectComponent<T, IsMulti>
|
||||
{...props}
|
||||
loadOptions={debounceLoadOptions}
|
||||
isLoading={props.isLoading || loading}
|
||||
onChange={onChange}
|
||||
selectedOptions={selectedOptions}
|
||||
onCreateOption={props.creatable ? onCreate : undefined}
|
||||
getNewOptionData={getNewOptionData}
|
||||
isValidNewOption={validNewOption}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export interface IFilterIDProps<T> {
|
||||
ids?: string[];
|
||||
onSelect?: (item: T[]) => void;
|
||||
}
|
||||
@@ -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<IMarkerSuggestProps> = (props) => {
|
||||
};
|
||||
|
||||
export const PerformerSelect: React.FC<IFilterProps> = (props) => {
|
||||
const [performerAliases, setPerformerAliases] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
const [performerDisambiguations, setPerformerDisambiguations] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [allAliases, setAllAliases] = useState<string[]>([]);
|
||||
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<string, string[]> = {};
|
||||
const newDisambiguations: Record<string, string> = {};
|
||||
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<Option, boolean>> = (
|
||||
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 <reactSelectComponents.Option {...thisOptionProps} />;
|
||||
};
|
||||
|
||||
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<Option>,
|
||||
options: OptionsOrGroups<Option, GroupBase<Option>>
|
||||
) => {
|
||||
if (!inputValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(options as Options<Option>).some((o: Option) => {
|
||||
return o.label.toLowerCase() === inputValue.toLowerCase();
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allAliases.some((a) => a.toLowerCase() === inputValue.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const onCreate = async (name: string) => {
|
||||
const result = await createPerformer({
|
||||
variables: { input: { name } },
|
||||
});
|
||||
return {
|
||||
item: result.data!.performerCreate!,
|
||||
message: intl.formatMessage(
|
||||
{ id: "toast.created_entity" },
|
||||
{ entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase() }
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterSelectComponent
|
||||
{...props}
|
||||
filterOption={filterOption}
|
||||
isValidNewOption={isValidNewOption}
|
||||
components={{ Option: PerformerOption }}
|
||||
isMulti={props.isMulti ?? false}
|
||||
creatable={props.creatable ?? defaultCreatable}
|
||||
onCreate={onCreate}
|
||||
type="performers"
|
||||
isLoading={loading}
|
||||
items={performers}
|
||||
placeholder={
|
||||
props.noSelectionString ??
|
||||
intl.formatMessage(
|
||||
{ id: "actions.select_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "performer" }) }
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
return <PerformerIDSelect {...props} />;
|
||||
};
|
||||
|
||||
export const StudioSelect: React.FC<
|
||||
|
||||
Reference in New Issue
Block a user