stash/ui/v2.5/src/components/Shared/Select.tsx
gitgiggety d4d45d5a06
Rebuild Studio page by splitting view and edit (#1629)
* Rebuild Studio page by splitting view and edit
* Fix parent studio id, open studio link in same tab

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2021-08-11 14:44:18 +10:00

607 lines
15 KiB
TypeScript

import React, { useEffect, useMemo, useState } from "react";
import Select, {
ValueType,
Styles,
OptionProps,
components as reactSelectComponents,
GroupedOptionsType,
OptionsType,
} from "react-select";
import CreatableSelect from "react-select/creatable";
import { debounce } from "lodash";
import * as GQL from "src/core/generated-graphql";
import {
useAllTagsForFilter,
useAllMoviesForFilter,
useAllStudiosForFilter,
useAllPerformersForFilter,
useMarkerStrings,
useTagCreate,
useStudioCreate,
usePerformerCreate,
} from "src/core/StashService";
import { useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import { SelectComponents } from "react-select/src/components";
export type ValidTypes =
| GQL.SlimPerformerDataFragment
| GQL.SlimTagDataFragment
| GQL.SlimStudioDataFragment
| GQL.SlimMovieDataFragment;
type Option = { value: string; label: string };
interface ITypeProps {
type?:
| "performers"
| "studios"
| "parent_studios"
| "tags"
| "sceneTags"
| "performerTags"
| "movies";
}
interface IFilterProps {
ids?: string[];
initialIds?: string[];
onSelect?: (item: ValidTypes[]) => void;
noSelectionString?: string;
className?: string;
isMulti?: boolean;
isClearable?: boolean;
isDisabled?: boolean;
creatable?: boolean;
menuPortalTarget?: HTMLElement | null;
}
interface ISelectProps<T extends boolean> {
className?: string;
items: Option[];
selectedOptions?: ValueType<Option, T>;
creatable?: boolean;
onCreateOption?: (value: string) => void;
isLoading: boolean;
isDisabled?: boolean;
onChange: (item: ValueType<Option, T>) => void;
initialIds?: string[];
isMulti: T;
isClearable?: boolean;
onInputChange?: (input: string) => void;
components?: Partial<SelectComponents<Option, T>>;
filterOption?: (option: Option, rawInput: string) => boolean;
isValidNewOption?: (
inputValue: string,
value: ValueType<Option, T>,
options: OptionsType<Option> | GroupedOptionsType<Option>
) => boolean;
placeholder?: string;
showDropdown?: boolean;
groupHeader?: string;
menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean;
noOptionsMessage?: string | null;
}
interface IFilterComponentProps extends IFilterProps {
items: Array<ValidTypes>;
onCreate?: (name: string) => Promise<{ item: ValidTypes; message: string }>;
}
interface IFilterSelectProps<T extends boolean>
extends Omit<ISelectProps<T>, "onChange" | "items" | "onCreateOption"> {}
type Gallery = { id: string; title: string };
interface IGallerySelect {
galleries: Gallery[];
onSelect: (items: Gallery[]) => void;
}
type Scene = { id: string; title: string };
interface ISceneSelect {
scenes: Scene[];
onSelect: (items: Scene[]) => void;
}
const getSelectedItems = (selectedItems: ValueType<Option, boolean>) =>
selectedItems
? Array.isArray(selectedItems)
? selectedItems
: [selectedItems]
: [];
const getSelectedValues = (selectedItems: ValueType<Option, boolean>) =>
getSelectedItems(selectedItems).map((item) => item.value);
const SelectComponent = <T extends boolean>({
type,
initialIds,
onChange,
className,
items,
selectedOptions,
isLoading,
isDisabled = false,
onCreateOption,
isClearable = true,
creatable = false,
isMulti,
onInputChange,
filterOption,
isValidNewOption,
components,
placeholder,
showDropdown = true,
groupHeader,
menuPortalTarget,
closeMenuOnSelect = true,
noOptionsMessage = type !== "tags" ? "None" : null,
}: ISelectProps<T> & ITypeProps) => {
const values = items.filter((item) => initialIds?.indexOf(item.value) !== -1);
const defaultValue = (isMulti ? values : values[0] ?? null) as ValueType<
Option,
T
>;
const options = groupHeader
? [
{
label: groupHeader,
options: items,
},
]
: items;
const styles: Partial<Styles<Option, T>> = {
option: (base) => ({
...base,
color: "#000",
}),
container: (base, props) => ({
...base,
zIndex: props.selectProps.isFocused ? 10 : base.zIndex,
}),
multiValueRemove: (base, props) => ({
...base,
color: props.selectProps.isFocused ? base.color : "#333333",
}),
};
const props = {
options,
value: selectedOptions,
className,
classNamePrefix: "react-select",
onChange,
isMulti,
isClearable,
defaultValue: defaultValue ?? undefined,
noOptionsMessage: () => noOptionsMessage,
placeholder: isDisabled ? "" : placeholder,
onInputChange,
filterOption,
isValidNewOption,
isDisabled,
isLoading,
styles,
closeMenuOnSelect,
menuPortalTarget,
components: {
...components,
IndicatorSeparator: () => null,
...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }),
...(isDisabled && { MultiValueRemove: () => null }),
},
};
return creatable ? (
<CreatableSelect
{...props}
isDisabled={isLoading || isDisabled}
onCreateOption={onCreateOption}
/>
) : (
<Select {...props} />
);
};
const FilterSelectComponent = <T extends boolean>(
props: IFilterComponentProps & ITypeProps & IFilterSelectProps<T>
) => {
const { items, ids, isMulti, onSelect } = props;
const [loading, setLoading] = useState(false);
const selectedIds = ids ?? [];
const Toast = useToast();
const options = items.map((i) => ({
value: i.id,
label: i.name ?? "",
}));
const selected = options.filter((option) =>
selectedIds.includes(option.value)
);
const selectedOptions = (isMulti
? selected
: selected[0] ?? null) as ValueType<Option, T>;
const onChange = (selectedItems: ValueType<Option, boolean>) => {
const selectedValues = getSelectedValues(selectedItems);
onSelect?.(items.filter((item) => selectedValues.includes(item.id)));
};
const onCreate = async (name: string) => {
try {
setLoading(true);
const { item: newItem, message } = await props.onCreate!(name);
props.onSelect?.([
...items.filter((item) => selectedIds.includes(item.id)),
newItem,
]);
setLoading(false);
Toast.success({
content: (
<span>
{message}: <b>{name}</b>
</span>
),
});
} catch (e) {
Toast.error(e);
}
};
return (
<SelectComponent<T>
{...props}
isLoading={props.isLoading || loading}
onChange={onChange}
items={options}
selectedOptions={selectedOptions}
onCreateOption={props.creatable ? onCreate : undefined}
/>
);
};
export const GallerySelect: React.FC<IGallerySelect> = (props) => {
const [query, setQuery] = useState<string>("");
const { data, loading } = GQL.useFindGalleriesQuery({
skip: query === "",
variables: {
filter: {
q: query,
},
},
});
const galleries = data?.findGalleries.galleries ?? [];
const items = galleries.map((g) => ({
label: g.title ?? TextUtils.fileNameFromPath(g.path ?? ""),
value: g.id,
}));
const onInputChange = debounce((input: string) => {
setQuery(input);
}, 500);
const onChange = (selectedItems: ValueType<Option, boolean>) => {
const selected = getSelectedItems(selectedItems);
props.onSelect(
selected.map((s) => ({
id: s.value,
title: s.label,
}))
);
};
const options = props.galleries.map((g) => ({
value: g.id,
label: g.title ?? "Unknown",
}));
return (
<SelectComponent
onChange={onChange}
onInputChange={onInputChange}
isLoading={loading}
items={items}
selectedOptions={options}
isMulti
placeholder="Search for gallery..."
noOptionsMessage={query === "" ? null : "No galleries found."}
showDropdown={false}
/>
);
};
export const SceneSelect: React.FC<ISceneSelect> = (props) => {
const [query, setQuery] = useState<string>("");
const { data, loading } = GQL.useFindScenesQuery({
skip: query === "",
variables: {
filter: {
q: query,
},
},
});
const scenes = data?.findScenes.scenes ?? [];
const items = scenes.map((s) => ({
label: s.title ?? TextUtils.fileNameFromPath(s.path ?? ""),
value: s.id,
}));
const onInputChange = debounce((input: string) => {
setQuery(input);
}, 500);
const onChange = (selectedItems: ValueType<Option, true>) => {
const selected = getSelectedItems(selectedItems);
props.onSelect(
(selected ?? []).map((s) => ({
id: s.value,
title: s.label,
}))
);
};
const options = props.scenes.map((s) => ({
value: s.id,
label: s.title,
}));
return (
<SelectComponent
onChange={onChange}
onInputChange={onInputChange}
isLoading={loading}
items={items}
selectedOptions={options}
isMulti
placeholder="Search for scene..."
noOptionsMessage={query === "" ? null : "No scenes found."}
showDropdown={false}
/>
);
};
interface IMarkerSuggestProps {
initialMarkerTitle?: string;
onChange: (title: string) => void;
}
export const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = (props) => {
const { data, loading } = useMarkerStrings();
const suggestions = data?.markerStrings ?? [];
const onChange = (selectedItem: ValueType<Option, false>) =>
props.onChange(selectedItem?.value ?? "");
const items = suggestions.map((item) => ({
label: item?.title ?? "",
value: item?.title ?? "",
}));
const initialIds = props.initialMarkerTitle ? [props.initialMarkerTitle] : [];
return (
<SelectComponent
isMulti={false}
creatable
onChange={onChange}
isLoading={loading}
items={items}
initialIds={initialIds}
placeholder="Marker title..."
className="select-suggest"
showDropdown={false}
groupHeader="Previously used titles..."
/>
);
};
export const PerformerSelect: React.FC<IFilterProps> = (props) => {
const { data, loading } = useAllPerformersForFilter();
const [createPerformer] = usePerformerCreate();
const performers = data?.allPerformers ?? [];
const onCreate = async (name: string) => {
const result = await createPerformer({
variables: { input: { name } },
});
return {
item: result.data!.performerCreate!,
message: "Created performer",
};
};
return (
<FilterSelectComponent
{...props}
isMulti={props.isMulti ?? false}
creatable={props.creatable ?? true}
onCreate={onCreate}
type="performers"
isLoading={loading}
items={performers}
placeholder={props.noSelectionString ?? "Select performer..."}
/>
);
};
export const StudioSelect: React.FC<
IFilterProps & { excludeIds?: string[] }
> = (props) => {
const { data, loading } = useAllStudiosForFilter();
const [createStudio] = useStudioCreate();
const exclude = props.excludeIds ?? [];
const studios = (data?.allStudios ?? []).filter(
(studio) => !exclude.includes(studio.id)
);
const onCreate = async (name: string) => {
const result = await createStudio({
variables: {
input: { name },
},
});
return { item: result.data!.studioCreate!, message: "Created studio" };
};
return (
<FilterSelectComponent
{...props}
isMulti={props.isMulti ?? false}
type="studios"
isLoading={loading}
items={studios}
placeholder={props.noSelectionString ?? "Select studio..."}
creatable={props.creatable ?? true}
onCreate={onCreate}
/>
);
};
export const MovieSelect: React.FC<IFilterProps> = (props) => {
const { data, loading } = useAllMoviesForFilter();
const items = data?.allMovies ?? [];
return (
<FilterSelectComponent
{...props}
isMulti={props.isMulti ?? false}
type="movies"
isLoading={loading}
items={items}
placeholder={props.noSelectionString ?? "Select movie..."}
/>
);
};
export const TagSelect: React.FC<IFilterProps & { excludeIds?: string[] }> = (
props
) => {
const [tagAliases, setTagAliases] = useState<Record<string, string[]>>({});
const [allAliases, setAllAliases] = useState<string[]>([]);
const { data, loading } = useAllTagsForFilter();
const [createTag] = useTagCreate();
const placeholder = props.noSelectionString ?? "Select tags...";
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
const tags = useMemo(
() => (data?.allTags ?? []).filter((tag) => !exclude.includes(tag.id)),
[data?.allTags, exclude]
);
useEffect(() => {
// build the tag aliases map
const newAliases: Record<string, string[]> = {};
const newAll: string[] = [];
tags.forEach((t) => {
newAliases[t.id] = t.aliases;
newAll.push(...t.aliases);
});
setTagAliases(newAliases);
setAllAliases(newAll);
}, [tags]);
const TagOption: React.FC<OptionProps<Option, boolean>> = (optionProps) => {
const { inputValue } = optionProps.selectProps;
let thisOptionProps = optionProps;
if (
inputValue &&
!optionProps.label.toLowerCase().includes(inputValue.toLowerCase())
) {
// must be alias
const newLabel = `${optionProps.data.label} (alias)`;
thisOptionProps = {
...optionProps,
children: newLabel,
};
}
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 tag aliases
const aliases = tagAliases[option.value];
// only match on alias if exact
if (aliases && aliases.some((a) => a.toLowerCase() === input)) {
return true;
}
return false;
};
const onCreate = async (name: string) => {
const result = await createTag({
variables: {
input: {
name,
},
},
});
return { item: result.data!.tagCreate!, message: "Created tag" };
};
const isValidNewOption = (
inputValue: string,
value: ValueType<Option, boolean>,
options: OptionsType<Option> | GroupedOptionsType<Option>
) => {
if (!inputValue) {
return false;
}
if (
(options as OptionsType<Option>).some((o: Option) => {
return o.label.toLowerCase() === inputValue.toLowerCase();
})
) {
return false;
}
if (allAliases.some((a) => a.toLowerCase() === inputValue.toLowerCase())) {
return false;
}
return true;
};
return (
<FilterSelectComponent
{...props}
filterOption={filterOption}
isValidNewOption={isValidNewOption}
components={{ Option: TagOption }}
isMulti={props.isMulti ?? false}
items={tags}
creatable={props.creatable ?? true}
type="tags"
placeholder={placeholder}
isLoading={loading}
onCreate={onCreate}
closeMenuOnSelect={!props.isMulti}
/>
);
};
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) =>
props.type === "performers" ? (
<PerformerSelect {...props} creatable={false} />
) : props.type === "studios" || props.type === "parent_studios" ? (
<StudioSelect {...props} creatable={false} />
) : props.type === "movies" ? (
<MovieSelect {...props} creatable={false} />
) : (
<TagSelect {...props} creatable={false} />
);