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:
WithoutPants 2023-08-24 11:15:49 +10:00 committed by GitHub
parent 3dc01a9362
commit e40b3d78b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 667 additions and 226 deletions

View file

@ -34,3 +34,10 @@ fragment SlimPerformerData on Performer {
death_date
weight
}
fragment SelectPerformerData on Performer {
id
name
disambiguation
alias_list
}

View file

@ -6,15 +6,6 @@ query MarkerStrings($q: String, $sort: String) {
}
}
query AllPerformersForFilter {
allPerformers {
id
name
disambiguation
alias_list
}
}
query AllStudiosForFilter {
allStudios {
id

View file

@ -1,8 +1,13 @@
query FindPerformers(
$filter: FindFilterType
$performer_filter: PerformerFilterType
$performer_ids: [Int!]
) {
findPerformers(
filter: $filter
performer_filter: $performer_filter
performer_ids: $performer_ids
) {
findPerformers(filter: $filter, performer_filter: $performer_filter) {
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
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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<GQL.GalleryDataFragment>;
@ -62,6 +65,8 @@ export const GalleryEditPanel: React.FC<IProps> = ({
}))
);
const [performers, setPerformers] = useState<Performer[]>([]);
const isNew = gallery.id === undefined;
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
@ -139,12 +144,24 @@ export const GalleryEditPanel: React.FC<IProps> = ({
);
}
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<IProps> = ({
});
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<IProps> = ({
<Col sm={9} xl={12}>
<PerformerSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"performer_ids",
items.map((item) => item.id)
)
}
ids={formik.values.performer_ids}
onSelect={onSetPerformers}
values={performers}
/>
</Col>
</Form.Group>

View file

@ -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<IProps> = ({
const { configuration } = React.useContext(ConfigurationContext);
const [performers, setPerformers] = useState<Performer[]>([]);
const schema = yup.object({
title: yup.string().ensure(),
url: yup.string().ensure(),
@ -87,12 +89,24 @@ export const ImageEditPanel: React.FC<IProps> = ({
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<IProps> = ({
<Col sm={9} xl={12}>
<PerformerSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"performer_ids",
items.map((item) => item.id)
)
}
ids={formik.values.performer_ids}
onSelect={onSetPerformers}
values={performers}
/>
</Col>
</Form.Group>

View file

@ -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<Performer>;
export const PerformerSelect: React.FC<
IFilterProps & IFilterValueProps<Performer>
> = (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<Option[]> {
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<Option, boolean>> = (
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: (
<span>
<span>{name}</span>
{object.disambiguation && (
<span className="performer-disambiguation">{` (${object.disambiguation})`}</span>
)}
{alias && <span className="alias">{` (${alias})`}</span>}
</span>
),
};
return <reactSelectComponents.Option {...thisOptionProps} />;
};
const PerformerMultiValueLabel: React.FC<
MultiValueGenericProps<Option, boolean>
> = (optionProps) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
thisOptionProps = {
...optionProps,
children: object.name,
};
return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;
};
const PerformerValueLabel: React.FC<SingleValueProps<Option, boolean>> = (
optionProps
) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
thisOptionProps = {
...optionProps,
children: object.name,
};
return <reactSelectComponents.SingleValue {...thisOptionProps} />;
};
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 (
<FilterSelectComponent<Performer, boolean>
{...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<Performer>
> = (props) => {
const { ids, onSelect: onSelectValues } = props;
const [values, setValues] = useState<Performer[]>([]);
const idsChanged = useCompare(ids);
function onSelect(items: Performer[]) {
setValues(items);
onSelectValues?.(items);
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Performer[]> {
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 <PerformerSelect {...props} values={values} onSelect={onSelect} />;
};

View file

@ -223,3 +223,7 @@
content: "";
}
}
.react-select .alias {
font-weight: bold;
}

View file

@ -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<IProps> = ({
const [galleries, setGalleries] = useState<{ id: string; title: string }[]>(
[]
);
const [performers, setPerformers] = useState<Performer[]>([]);
const Scrapers = useListSceneScrapers();
const [fragmentScrapers, setFragmentScrapers] = useState<GQL.Scraper[]>([]);
@ -98,6 +102,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
);
}, [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<IProps> = ({
);
}
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<IProps> = ({
});
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<IProps> = ({
<Col sm={9} xl={12}>
<PerformerSelect
isMulti
onSelect={(items) =>
formik.setFieldValue(
"performer_ids",
items.map((item) => item.id)
)
}
ids={formik.values.performer_ids}
onSelect={onSetPerformers}
values={performers}
/>
</Col>
</Form.Group>

View 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;
}

View file

@ -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<

View file

@ -110,31 +110,6 @@ export const useCreatePerformer = () => {
update: (store, newPerformer) => {
if (!newPerformer?.data?.performerCreate) return;
const currentQuery = store.readQuery<
GQL.AllPerformersForFilterQuery,
GQL.AllPerformersForFilterQueryVariables
>({
query: GQL.AllPerformersForFilterDocument,
});
const allPerformers = sortBy(
[
...(currentQuery?.allPerformers ?? []),
newPerformer.data.performerCreate,
],
["name"]
);
if (allPerformers.length > 1) {
store.writeQuery<
GQL.AllPerformersForFilterQuery,
GQL.AllPerformersForFilterQueryVariables
>({
query: GQL.AllPerformersForFilterDocument,
data: {
allPerformers,
},
});
}
store.writeQuery<
GQL.FindPerformersQuery,
GQL.FindPerformersQueryVariables

View file

@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import cx from "classnames";
@ -6,9 +6,12 @@ import cx from "classnames";
import * as GQL from "src/core/generated-graphql";
import { Icon } from "src/components/Shared/Icon";
import { OperationButton } from "src/components/Shared/OperationButton";
import { PerformerSelect, SelectObject } from "src/components/Shared/Select";
import { OptionalField } from "../IncludeButton";
import { faSave } from "@fortawesome/free-solid-svg-icons";
import {
Performer,
PerformerSelect,
} from "src/components/Performers/PerformerSelect";
interface IPerformerResultProps {
performer: GQL.ScrapedPerformer;
@ -40,10 +43,25 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
stashID.stash_id === performer.remote_site_id
);
const handlePerformerSelect = (performers: SelectObject[]) => {
const [selectedPerformer, setSelectedPerformer] = useState<
Performer | undefined
>();
useEffect(() => {
if (
performerData?.findPerformer &&
selectedID === performerData?.findPerformer?.id
) {
setSelectedPerformer(performerData.findPerformer);
}
}, [performerData?.findPerformer, selectedID]);
const handlePerformerSelect = (performers: Performer[]) => {
if (performers.length) {
setSelectedPerformer(performers[0]);
setSelectedID(performers[0].id);
} else {
setSelectedPerformer(undefined);
setSelectedID(undefined);
}
};
@ -114,7 +132,7 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
<FormattedMessage id="actions.skip" />
</Button>
<PerformerSelect
ids={selectedID ? [selectedID] : []}
values={selectedPerformer ? [selectedPerformer] : []}
onSelect={handlePerformerSelect}
className={cx("performer-select", {
"performer-select-active": selectedSource === "existing",

View file

@ -268,8 +268,22 @@ export const queryFindPerformers = (filter: ListFilterModel) =>
},
});
export const useAllPerformersForFilter = () =>
GQL.useAllPerformersForFilterQuery();
export const queryFindPerformersByIDForSelect = (performerIDs: number[]) =>
client.query<GQL.FindPerformersForSelectQuery>({
query: GQL.FindPerformersForSelectDocument,
variables: {
performer_ids: performerIDs,
},
});
export const queryFindPerformersForSelect = (filter: ListFilterModel) =>
client.query<GQL.FindPerformersForSelectQuery>({
query: GQL.FindPerformersForSelectDocument,
variables: {
filter: filter.makeFindFilter(),
performer_filter: filter.makeFilter(),
},
});
export const useFindStudio = (id: string) => {
const skip = id === "new" || id === "";
@ -1372,8 +1386,6 @@ export const usePerformerCreate = () =>
const performer = result.data?.performerCreate;
if (!performer) return;
appendObject(cache, performer, GQL.AllPerformersForFilterDocument);
// update stats
updateStats(cache, "performer_count", 1);