Allow adding performer & studio from scenes page (#663)

* Allow adding performer & studio from scenes page

Adds "create" options for performer and studio select in SceneEditPanel.
Adds new FilterSelectComponent to reduce duplicate logic in selects.

Make invalidateQueries case insensitive so we can pass upper-case query
names that also work with refetchQueries
This commit is contained in:
friendlycrab
2020-08-05 07:38:11 +02:00
committed by GitHub
parent f5c3cafa63
commit f59ad0ca2b
3 changed files with 118 additions and 128 deletions

View File

@@ -13,6 +13,7 @@ const markup = `
* Add support for parent/child studios. * Add support for parent/child studios.
### 🎨 Improvements ### 🎨 Improvements
* Allow adding performers and studios from selectors.
* Add support for chrome dp in xpath scrapers. * Add support for chrome dp in xpath scrapers.
* Allow customisation of preview video generation. * Allow customisation of preview video generation.
* Add support for live transcoding in Safari. * Add support for live transcoding in Safari.

View File

@@ -13,6 +13,8 @@ import {
useScrapePerformerList, useScrapePerformerList,
useValidGalleriesForScene, useValidGalleriesForScene,
useTagCreate, useTagCreate,
useStudioCreate,
usePerformerCreate,
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
@@ -59,6 +61,16 @@ interface ISelectProps {
groupHeader?: string; groupHeader?: string;
closeMenuOnSelect?: boolean; closeMenuOnSelect?: boolean;
} }
interface IFilterItem {
id: string;
name?: string | null;
}
interface IFilterComponentProps extends IFilterProps {
items: Array<IFilterItem>;
onCreate?: (name: string) => Promise<{ item: IFilterItem; message: string }>;
}
interface IFilterSelectProps
extends Omit<ISelectProps, "onChange" | "items" | "onCreateOption"> {}
interface ISceneGallerySelect { interface ISceneGallerySelect {
initialId?: string; initialId?: string;
@@ -191,180 +203,151 @@ export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) =>
export const PerformerSelect: React.FC<IFilterProps> = (props) => { export const PerformerSelect: React.FC<IFilterProps> = (props) => {
const { data, loading } = useAllPerformersForFilter(); const { data, loading } = useAllPerformersForFilter();
const [createPerformer] = usePerformerCreate();
const normalizedData = data?.allPerformersSlim ?? []; const performers = data?.allPerformersSlim ?? [];
const items: Option[] = normalizedData.map((item) => ({
value: item.id,
label: item.name ?? "",
}));
const placeholder = props.noSelectionString ?? "Select performer...";
const selectedOptions: Option[] = props.ids
? items.filter((item) => props.ids?.indexOf(item.value) !== -1)
: [];
const onChange = (selectedItems: ValueType<Option>) => { const onCreate = async (name: string) => {
const selectedIds = getSelectedValues(selectedItems); const result = await createPerformer({
props.onSelect?.( variables: { name },
normalizedData.filter((item) => selectedIds.indexOf(item.id) !== -1) });
); return {
item: result.data!.performerCreate!,
message: "Created performer",
};
}; };
return ( return (
<SelectComponent <FilterSelectComponent
{...props} {...props}
selectedOptions={selectedOptions} creatable
onChange={onChange} onCreate={onCreate}
type="performers" type="performers"
isLoading={loading} isLoading={loading}
items={items} items={performers}
placeholder={placeholder} placeholder={props.noSelectionString ?? "Select performer..."}
/> />
); );
}; };
export const StudioSelect: React.FC<IFilterProps> = (props) => { export const StudioSelect: React.FC<IFilterProps> = (props) => {
const { data, loading } = useAllStudiosForFilter(); const { data, loading } = useAllStudiosForFilter();
const [createStudio] = useStudioCreate({ name: "" });
const normalizedData = data?.allStudiosSlim ?? []; const studios = data?.allStudiosSlim ?? [];
const items = (normalizedData.length > 0 const onCreate = async (name: string) => {
? [{ name: "None", id: "0" }, ...normalizedData] const result = await createStudio({ variables: { name } });
: [] return { item: result.data!.studioCreate!, message: "Created studio" };
).map((item) => ({
value: item.id,
label: item.name,
}));
const placeholder = props.noSelectionString ?? "Select studio...";
const selectedOptions: Option[] = props.ids
? items.filter((item) => props.ids?.indexOf(item.value) !== -1)
: [];
const onChange = (selectedItems: ValueType<Option>) => {
const selectedIds = getSelectedValues(selectedItems);
props.onSelect?.(
normalizedData.filter((item) => selectedIds.indexOf(item.id) !== -1)
);
}; };
return ( return (
<SelectComponent <FilterSelectComponent
{...props} {...props}
onChange={onChange}
type="studios" type="studios"
isLoading={loading} isLoading={loading}
items={items} items={studios}
placeholder={placeholder} placeholder={props.noSelectionString ?? "Select studio..."}
selectedOptions={selectedOptions} creatable
onCreate={onCreate}
/> />
); );
}; };
export const MovieSelect: React.FC<IFilterProps> = (props) => { export const MovieSelect: React.FC<IFilterProps> = (props) => {
const { data, loading } = useAllMoviesForFilter(); const { data, loading } = useAllMoviesForFilter();
const items = data?.allMoviesSlim ?? [];
const normalizedData = data?.allMoviesSlim ?? [];
const items = (normalizedData.length > 0
? [{ name: "None", id: "0" }, ...normalizedData]
: []
).map((item) => ({
value: item.id,
label: item.name,
}));
const placeholder = props.noSelectionString ?? "Select movie...";
const selectedOptions: Option[] = props.ids
? items.filter((item) => props.ids?.indexOf(item.value) !== -1)
: [];
const onChange = (selectedItems: ValueType<Option>) => {
const selectedIds = getSelectedValues(selectedItems);
props.onSelect?.(
normalizedData.filter((item) => selectedIds.indexOf(item.id) !== -1)
);
};
return ( return (
<SelectComponent <FilterSelectComponent
{...props} {...props}
onChange={onChange} type="movies"
type="studios"
isLoading={loading} isLoading={loading}
items={items} items={items}
placeholder={placeholder} placeholder={props.noSelectionString ?? "Select movie..."}
selectedOptions={selectedOptions}
/> />
); );
}; };
export const TagSelect: React.FC<IFilterProps> = (props) => { export const TagSelect: React.FC<IFilterProps> = (props) => {
const [loading, setLoading] = useState(false); const { data, loading } = useAllTagsForFilter();
const [selectedIds, setSelectedIds] = useState<string[]>(props.ids ?? []);
const { data, loading: dataLoading } = useAllTagsForFilter();
const [createTag] = useTagCreate({ name: "" }); const [createTag] = useTagCreate({ name: "" });
const Toast = useToast();
const placeholder = props.noSelectionString ?? "Select tags..."; const placeholder = props.noSelectionString ?? "Select tags...";
const selectedTags = props.ids ?? selectedIds;
const tags = data?.allTagsSlim ?? []; const tags = data?.allTagsSlim ?? [];
const selected = tags
.filter((tag) => selectedTags.indexOf(tag.id) !== -1)
.map((tag) => ({ value: tag.id, label: tag.name }));
const items: Option[] = tags.map((item) => ({
value: item.id,
label: item.name,
}));
const onCreate = async (tagName: string) => { const onCreate = async (name: string) => {
const result = await createTag({
variables: { name },
});
return { item: result.data!.tagCreate!, message: "Created tag" };
};
return (
<FilterSelectComponent
{...props}
items={tags}
creatable
type="tags"
placeholder={placeholder}
isLoading={loading}
onCreate={onCreate}
closeMenuOnSelect={false}
/>
);
};
const FilterSelectComponent: React.FC<
IFilterComponentProps & ITypeProps & IFilterSelectProps
> = (props) => {
const [loading, setLoading] = useState(false);
const selectedIds = props.ids ?? [];
const Toast = useToast();
const { items } = props;
const options = items.map((i) => ({
value: i.id,
label: i.name ?? "",
}));
const selectedOptions = options.filter((option) =>
selectedIds.includes(option.value)
);
const onChange = (selectedItems: ValueType<Option>) => {
const selectedValues = getSelectedValues(selectedItems);
props.onSelect?.(items.filter((item) => selectedValues.includes(item.id)));
};
const onCreate = async (name: string) => {
try { try {
setLoading(true); setLoading(true);
const result = await createTag({ const { item: newItem, message } = await props.onCreate!(name);
variables: { name: tagName },
});
if (result?.data?.tagCreate) {
setSelectedIds([...selectedIds, result.data.tagCreate.id]);
props.onSelect?.([ props.onSelect?.([
...tags.filter((item) => selectedIds.indexOf(item.id) !== -1), ...items.filter((item) => selectedIds.includes(item.id)),
result.data.tagCreate, newItem,
]); ]);
setLoading(false); setLoading(false);
Toast.success({ Toast.success({
content: ( content: (
<span> <span>
Created tag: <b>{tagName}</b> {message}: <b>{name}</b>
</span> </span>
), ),
}); });
}
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
}; };
const onChange = (selectedItems: ValueType<Option>) => {
const selectedValues = getSelectedValues(selectedItems);
setSelectedIds(selectedValues);
props.onSelect?.(
tags.filter((item) => selectedValues.indexOf(item.id) !== -1)
);
};
return ( return (
<SelectComponent <SelectComponent
{...props} {...(props as ITypeProps)}
{...(props as IFilterSelectProps)}
isLoading={props.isLoading || loading}
onChange={onChange} onChange={onChange}
creatable items={options}
type="tags" selectedOptions={selectedOptions}
placeholder={placeholder} onCreateOption={props.creatable ? onCreate : undefined}
isLoading={loading || dataLoading}
items={items}
onCreateOption={onCreate}
selectedOptions={selected}
closeMenuOnSelect={false}
/> />
); );
}; };

View File

@@ -11,7 +11,7 @@ export const getClient = () => client;
const invalidateQueries = (queries: string[]) => { const invalidateQueries = (queries: string[]) => {
if (cache) { if (cache) {
const keyMatchers = queries.map((query) => { const keyMatchers = queries.map((query) => {
return new RegExp(`^${query}`); return new RegExp(`^${query}`, "i");
}); });
// TODO: Hack to invalidate, manipulating private data // TODO: Hack to invalidate, manipulating private data
@@ -194,22 +194,26 @@ export const useDirectory = (path?: string) =>
GQL.useDirectoryQuery({ variables: { path } }); GQL.useDirectoryQuery({ variables: { path } });
export const performerMutationImpactedQueries = [ export const performerMutationImpactedQueries = [
"findPerformers", "FindPerformers",
"findScenes", "FindScenes",
"findSceneMarkers", "FindSceneMarkers",
"allPerformers", "AllPerformers",
"AllPerformersForFilter",
]; ];
export const usePerformerCreate = () => export const usePerformerCreate = () =>
GQL.usePerformerCreateMutation({ GQL.usePerformerCreateMutation({
refetchQueries: performerMutationImpactedQueries,
update: () => invalidateQueries(performerMutationImpactedQueries), update: () => invalidateQueries(performerMutationImpactedQueries),
}); });
export const usePerformerUpdate = () => export const usePerformerUpdate = () =>
GQL.usePerformerUpdateMutation({ GQL.usePerformerUpdateMutation({
refetchQueries: performerMutationImpactedQueries,
update: () => invalidateQueries(performerMutationImpactedQueries), update: () => invalidateQueries(performerMutationImpactedQueries),
}); });
export const usePerformerDestroy = () => export const usePerformerDestroy = () =>
GQL.usePerformerDestroyMutation({ GQL.usePerformerDestroyMutation({
refetchQueries: performerMutationImpactedQueries,
update: () => invalidateQueries(performerMutationImpactedQueries), update: () => invalidateQueries(performerMutationImpactedQueries),
}); });
@@ -282,14 +286,16 @@ export const useSceneGenerateScreenshot = () =>
}); });
export const studioMutationImpactedQueries = [ export const studioMutationImpactedQueries = [
"findStudios", "FindStudios",
"findScenes", "FindScenes",
"allStudios", "AllStudios",
"AllStudiosForFilter",
]; ];
export const useStudioCreate = (input: GQL.StudioCreateInput) => export const useStudioCreate = (input: GQL.StudioCreateInput) =>
GQL.useStudioCreateMutation({ GQL.useStudioCreateMutation({
variables: input, variables: input,
refetchQueries: studioMutationImpactedQueries,
update: () => invalidateQueries(studioMutationImpactedQueries), update: () => invalidateQueries(studioMutationImpactedQueries),
}); });