Studio select refactor (#4493)

* Add id interface to findStudios
* Replace existing selects
* Remove unused code
* Fix scrape/merge select
* Make clearable
This commit is contained in:
WithoutPants 2024-02-06 11:26:16 +11:00 committed by GitHub
parent 217c02f181
commit de2b28d3f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 494 additions and 239 deletions

View file

@ -33,3 +33,16 @@ fragment StudioData on Studio {
rating100
aliases
}
fragment SelectStudioData on Studio {
id
name
aliases
details
image_path
parent_studio {
id
name
}
}

View file

@ -6,14 +6,6 @@ query MarkerStrings($q: String, $sort: String) {
}
}
query AllStudiosForFilter {
allStudios {
id
name
aliases
}
}
query AllMoviesForFilter {
allMovies {
id

View file

@ -12,3 +12,16 @@ query FindStudio($id: ID!) {
...StudioData
}
}
query FindStudiosForSelect(
$filter: FindFilterType
$studio_filter: StudioFilterType
$ids: [ID!]
) {
findStudios(filter: $filter, studio_filter: $studio_filter, ids: $ids) {
count
studios {
...SelectStudioData
}
}
}

View file

@ -69,6 +69,7 @@ type Query {
findStudios(
studio_filter: StudioFilterType
filter: FindFilterType
ids: [ID!]
): FindStudiosResultType!
"Find a movie by ID"
@ -202,11 +203,11 @@ type Query {
allSceneMarkers: [SceneMarker!]!
allImages: [Image!]!
allGalleries: [Gallery!]!
allStudios: [Studio!]!
allMovies: [Movie!]!
allPerformers: [Performer!]! @deprecated(reason: "Use findPerformers instead")
allTags: [Tag!]! @deprecated(reason: "Use findTags instead")
allStudios: [Studio!]! @deprecated(reason: "Use findStudios instead")
# Get everything with minimal metadata

View file

@ -5,6 +5,7 @@ import (
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.Studio, err error) {
@ -24,9 +25,23 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.
return ret, nil
}
func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType) (ret *FindStudiosResultType, err error) {
func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return nil, err
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
studios, total, err := r.repository.Studio.Query(ctx, studioFilter, filter)
var studios []*models.Studio
var err error
var total int
if len(idInts) > 0 {
studios, err = r.repository.Studio.FindMany(ctx, idInts)
total = len(studios)
} else {
studios, total, err = r.repository.Studio.Query(ctx, studioFilter, filter)
}
if err != nil {
return err
}

View file

@ -18,7 +18,7 @@ import {
useListGalleryScrapers,
mutateReloadScrapers,
} from "src/core/StashService";
import { SceneSelect, StudioSelect } from "src/components/Shared/Select";
import { SceneSelect } from "src/components/Shared/Select";
import { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { useToast } from "src/hooks/Toast";
@ -41,6 +41,7 @@ import {
} from "src/utils/yup";
import { formikUtils } from "src/utils/form";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
@ -66,6 +67,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [studio, setStudio] = useState<Studio | null>(null);
const isNew = gallery.id === undefined;
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
@ -152,6 +154,11 @@ export const GalleryEditPanel: React.FC<IProps> = ({
);
}
function onSetStudio(item: Studio | null) {
setStudio(item);
formik.setFieldValue("studio_id", item ? item.id : null);
}
useRatingKeybinds(
isVisible,
stashConfig?.ui?.ratingSystemOptions?.type,
@ -166,6 +173,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
setTags(gallery.tags ?? []);
}, [gallery.tags]);
useEffect(() => {
setStudio(gallery.studio ?? null);
}, [gallery.studio]);
useEffect(() => {
if (isVisible) {
Mousetrap.bind("s s", () => {
@ -252,6 +263,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
return (
<GalleryScrapeDialog
gallery={currentGallery}
galleryStudio={studio}
galleryTags={tags}
galleryPerformers={performers}
scraped={scrapedGallery}
@ -324,7 +336,11 @@ export const GalleryEditPanel: React.FC<IProps> = ({
}
if (galleryData.studio?.stored_id) {
formik.setFieldValue("studio_id", galleryData.studio.stored_id);
onSetStudio({
id: galleryData.studio.stored_id,
name: galleryData.studio.name ?? "",
aliases: [],
});
}
if (galleryData.performers?.length) {
@ -429,13 +445,8 @@ export const GalleryEditPanel: React.FC<IProps> = ({
const title = intl.formatMessage({ id: "studio" });
const control = (
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)}
values={studio ? [studio] : []}
/>
);

View file

@ -25,9 +25,11 @@ import {
} from "src/components/Shared/ScrapeDialog/createObjects";
import { uniq } from "lodash-es";
import { Tag } from "src/components/Tags/TagSelect";
import { Studio } from "src/components/Studios/StudioSelect";
interface IGalleryScrapeDialogProps {
gallery: Partial<GQL.GalleryUpdateInput>;
galleryStudio: Studio | null;
galleryTags: Tag[];
galleryPerformers: Performer[];
scraped: GQL.ScrapedGallery;
@ -37,6 +39,7 @@ interface IGalleryScrapeDialogProps {
export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
gallery,
galleryStudio,
galleryTags,
galleryPerformers,
scraped,
@ -63,8 +66,16 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
const [photographer, setPhotographer] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(gallery.photographer, scraped.photographer)
);
const [studio, setStudio] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(gallery.studio_id, scraped.studio?.stored_id)
const [studio, setStudio] = useState<ScrapeResult<GQL.ScrapedStudio>>(
new ScrapeResult<GQL.ScrapedStudio>(
galleryStudio
? {
stored_id: galleryStudio.id,
name: galleryStudio.name,
}
: undefined,
scraped.studio
)
);
const [newStudio, setNewStudio] = useState<GQL.ScrapedStudio | undefined>(
scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined
@ -156,12 +167,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
urls: urls.getNewValue(),
date: date.getNewValue(),
photographer: photographer.getNewValue(),
studio: newStudioValue
? {
stored_id: newStudioValue,
name: "",
}
: undefined,
studio: newStudioValue,
performers: performers.getNewValue(),
tags: tags.getNewValue(),
details: details.getNewValue(),

View file

@ -4,7 +4,6 @@ import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import { StudioSelect } from "src/components/Shared/Select";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { useToast } from "src/hooks/Toast";
import { useFormik } from "formik";
@ -23,6 +22,7 @@ import {
} from "src/components/Performers/PerformerSelect";
import { formikUtils } from "src/utils/form";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
interface IProps {
image: GQL.ImageDataFragment;
@ -47,6 +47,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [studio, setStudio] = useState<Studio | null>(null);
const schema = yup.object({
title: yup.string().ensure(),
@ -103,6 +104,11 @@ export const ImageEditPanel: React.FC<IProps> = ({
);
}
function onSetStudio(item: Studio | null) {
setStudio(item);
formik.setFieldValue("studio_id", item ? item.id : null);
}
useRatingKeybinds(
true,
configuration?.ui?.ratingSystemOptions?.type,
@ -117,6 +123,10 @@ export const ImageEditPanel: React.FC<IProps> = ({
setTags(image.tags ?? []);
}, [image.tags]);
useEffect(() => {
setStudio(image.studio ?? null);
}, [image.studio]);
useEffect(() => {
if (isVisible) {
Mousetrap.bind("s s", () => {
@ -183,13 +193,8 @@ export const ImageEditPanel: React.FC<IProps> = ({
const title = intl.formatMessage({ id: "studio" });
const control = (
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)}
values={studio ? [studio] : []}
/>
);

View file

@ -8,7 +8,6 @@ import {
useListMovieScrapers,
} from "src/core/StashService";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { StudioSelect } from "src/components/Shared/Select";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { URLField } from "src/components/Shared/URLField";
import { useToast } from "src/hooks/Toast";
@ -22,6 +21,7 @@ import isEqual from "lodash-es/isEqual";
import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form";
import { yupDateString, yupFormikValidate } from "src/utils/yup";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
interface IMovieEditPanel {
movie: Partial<GQL.MovieDataFragment>;
@ -55,6 +55,8 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
const Scrapers = useListMovieScrapers();
const [scrapedMovie, setScrapedMovie] = useState<GQL.ScrapedMovie>();
const [studio, setStudio] = useState<Studio | null>(null);
const schema = yup.object({
name: yup.string().required(),
aliases: yup.string().ensure(),
@ -88,6 +90,15 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
onSubmit: (values) => onSave(schema.cast(values)),
});
function onSetStudio(item: Studio | null) {
setStudio(item);
formik.setFieldValue("studio_id", item ? item.id : null);
}
useEffect(() => {
setStudio(movie.studio ?? null);
}, [movie.studio]);
// set up hotkeys
useEffect(() => {
// Mousetrap.bind("u", (e) => {
@ -129,7 +140,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
}
if (state.studio && state.studio.stored_id) {
formik.setFieldValue("studio_id", state.studio.stored_id);
onSetStudio({
id: state.studio.stored_id,
name: state.studio.name ?? "",
aliases: [],
});
}
if (state.director) {
@ -324,13 +339,8 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
const title = intl.formatMessage({ id: "studio" });
const control = (
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)}
values={studio ? [studio] : []}
/>
);

View file

@ -19,11 +19,7 @@ import {
mutateReloadScrapers,
queryScrapeSceneQueryFragment,
} from "src/core/StashService";
import {
StudioSelect,
GallerySelect,
MovieSelect,
} from "src/components/Shared/Select";
import { GallerySelect, MovieSelect } from "src/components/Shared/Select";
import { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { ImageInput } from "src/components/Shared/ImageInput";
@ -52,6 +48,7 @@ import {
} from "src/components/Performers/PerformerSelect";
import { formikUtils } from "src/utils/form";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
@ -81,6 +78,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
);
const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [studio, setStudio] = useState<Studio | null>(null);
const Scrapers = useListSceneScrapers();
const [fragmentScrapers, setFragmentScrapers] = useState<GQL.Scraper[]>([]);
@ -109,6 +107,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
setTags(scene.tags ?? []);
}, [scene.tags]);
useEffect(() => {
setStudio(scene.studio ?? null);
}, [scene.studio]);
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
// Network state
@ -215,6 +217,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
);
}
function onSetStudio(item: Studio | null) {
setStudio(item);
formik.setFieldValue("studio_id", item ? item.id : null);
}
useRatingKeybinds(
isVisible,
stashConfig?.ui?.ratingSystemOptions?.type,
@ -394,6 +401,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
return (
<SceneScrapeDialog
scene={currentScene}
sceneStudio={studio}
sceneTags={tags}
scenePerformers={performers}
scraped={scrapedScene}
@ -554,7 +562,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
}
if (updatedScene.studio && updatedScene.studio.stored_id) {
formik.setFieldValue("studio_id", updatedScene.studio.stored_id);
onSetStudio({
id: updatedScene.studio.stored_id,
name: updatedScene.studio.name ?? "",
aliases: [],
});
}
if (updatedScene.performers && updatedScene.performers.length > 0) {
@ -726,13 +738,8 @@ export const SceneEditPanel: React.FC<IProps> = ({
const title = intl.formatMessage({ id: "studio" });
const control = (
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"studio_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.studio_id ? [formik.values.studio_id] : []}
onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)}
values={studio ? [studio] : []}
/>
);

View file

@ -29,9 +29,11 @@ import {
useCreateScrapedTag,
} from "src/components/Shared/ScrapeDialog/createObjects";
import { Tag } from "src/components/Tags/TagSelect";
import { Studio } from "src/components/Studios/StudioSelect";
interface ISceneScrapeDialogProps {
scene: Partial<GQL.SceneUpdateInput>;
sceneStudio: Studio | null;
scenePerformers: Performer[];
sceneTags: Tag[];
scraped: GQL.ScrapedScene;
@ -42,6 +44,7 @@ interface ISceneScrapeDialogProps {
export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
scene,
sceneStudio,
scenePerformers,
sceneTags,
scraped,
@ -70,8 +73,16 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
const [director, setDirector] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(scene.director, scraped.director)
);
const [studio, setStudio] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(scene.studio_id, scraped.studio?.stored_id)
const [studio, setStudio] = useState<ScrapeResult<GQL.ScrapedStudio>>(
new ScrapeResult<GQL.ScrapedStudio>(
sceneStudio
? {
stored_id: sceneStudio.id,
name: sceneStudio.name,
}
: undefined,
scraped.studio?.stored_id ? scraped.studio : undefined
)
);
const [newStudio, setNewStudio] = useState<GQL.ScrapedStudio | undefined>(
scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined
@ -235,12 +246,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
urls: urls.getNewValue(),
date: date.getNewValue(),
director: director.getNewValue(),
studio: newStudioValue
? {
stored_id: newStudioValue,
name: "",
}
: undefined,
studio: newStudioValue,
performers: performers.getNewValue(),
movies: movies.getNewValue()?.map((m) => {
return {

View file

@ -87,8 +87,17 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
new ScrapeResult<number>(dest.play_duration)
);
const [studio, setStudio] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.studio?.id)
function idToStoredID(o: { id: string; name: string }) {
return {
stored_id: o.id,
name: o.name,
};
}
const [studio, setStudio] = useState<ScrapeResult<GQL.ScrapedStudio>>(
new ScrapeResult<GQL.ScrapedStudio>(
dest.studio ? idToStoredID(dest.studio) : undefined
)
);
function sortIdList(idList?: string[] | null) {
@ -105,13 +114,6 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
return ret;
}
function idToStoredID(o: { id: string; name: string }) {
return {
stored_id: o.id,
name: o.name,
};
}
function uniqIDStoredIDs<T extends IHasStoredID>(objs: T[]) {
return objs.filter((o, i) => {
return objs.findIndex((oo) => oo.stored_id === o.stored_id) === i;
@ -197,10 +199,18 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
setDate(
new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date)
);
const foundStudio = sources.find((s) => s.studio)?.studio;
setStudio(
new ScrapeResult(
dest.studio?.id,
sources.find((s) => s.studio)?.studio?.id,
new ScrapeResult<GQL.ScrapedStudio>(
dest.studio ? idToStoredID(dest.studio) : undefined,
foundStudio
? {
stored_id: foundStudio.id,
name: foundStudio.name,
}
: undefined,
!dest.studio
)
);
@ -581,7 +591,7 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
play_count: playCount.getNewValue(),
play_duration: playDuration.getNewValue(),
gallery_ids: galleries.getNewValue(),
studio_id: studio.getNewValue(),
studio_id: studio.getNewValue()?.stored_id,
performer_ids: performers.getNewValue()?.map((p) => p.stored_id!),
movies: movies.getNewValue()?.map((m) => {
// find the equivalent movie in the original scenes

View file

@ -89,6 +89,7 @@ const SelectComponent = <T, IsMulti extends boolean>(
...props,
styles,
defaultOptions: true,
isClearable: true,
value: selectedOptions ?? null,
className: cx("react-select", props.className),
classNamePrefix: "react-select",

View file

@ -1,6 +1,6 @@
import React, { useMemo } from "react";
import * as GQL from "src/core/generated-graphql";
import { MovieSelect, StudioSelect } from "src/components/Shared/Select";
import { MovieSelect } from "src/components/Shared/Select";
import {
ScrapeDialogRow,
IHasName,
@ -8,11 +8,12 @@ import {
import { PerformerSelect } from "src/components/Performers/PerformerSelect";
import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult";
import { TagSelect } from "src/components/Tags/TagSelect";
import { StudioSelect } from "src/components/Studios/StudioSelect";
interface IScrapedStudioRow {
title: string;
result: ScrapeResult<string>;
onChange: (value: ScrapeResult<string>) => void;
result: ScrapeResult<GQL.ScrapedStudio>;
onChange: (value: ScrapeResult<GQL.ScrapedStudio>) => void;
newStudio?: GQL.ScrapedStudio;
onCreateNew?: (value: GQL.ScrapedStudio) => void;
}
@ -25,25 +26,34 @@ export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
onCreateNew,
}) => {
function renderScrapedStudio(
scrapeResult: ScrapeResult<string>,
scrapeResult: ScrapeResult<GQL.ScrapedStudio>,
isNew?: boolean,
onChangeFn?: (value: string) => void
onChangeFn?: (value: GQL.ScrapedStudio) => void
) {
const resultValue = isNew
? scrapeResult.newValue
: scrapeResult.originalValue;
const value = resultValue ? [resultValue] : [];
const selectValue = value.map((p) => {
const aliases: string[] = [];
return {
id: p.stored_id ?? "",
name: p.name ?? "",
aliases,
};
});
return (
<StudioSelect
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChangeFn) {
onChangeFn(items[0]?.id);
onChangeFn(items[0]);
}
}}
ids={value}
values={selectValue}
/>
);
}

View file

@ -41,8 +41,8 @@ function useCreateObject<T>(
}
interface IUseCreateNewStudioProps {
scrapeResult: ScrapeResult<string>;
setScrapeResult: (scrapeResult: ScrapeResult<string>) => void;
scrapeResult: ScrapeResult<GQL.ScrapedStudio>;
setScrapeResult: (scrapeResult: ScrapeResult<GQL.ScrapedStudio>) => void;
setNewObject: (newObject: GQL.ScrapedStudio | undefined) => void;
}
@ -62,7 +62,12 @@ export function useCreateScrapedStudio(props: IUseCreateNewStudioProps) {
});
// set the new studio as the value
setScrapeResult(scrapeResult.cloneWithValue(result.data!.studioCreate!.id));
setScrapeResult(
scrapeResult.cloneWithValue({
stored_id: result.data!.studioCreate!.id,
name: toCreate.name,
})
);
setNewObject(undefined);
}

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useMemo, useState } from "react";
import Select, {
OnChangeValue,
StylesConfig,
@ -15,9 +15,7 @@ import CreatableSelect from "react-select/creatable";
import * as GQL from "src/core/generated-graphql";
import {
useAllMoviesForFilter,
useAllStudiosForFilter,
useMarkerStrings,
useStudioCreate,
useMovieCreate,
} from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
@ -33,6 +31,7 @@ import { PerformerIDSelect } from "../Performers/PerformerSelect";
import { Icon } from "./Icon";
import { faTableColumns } from "@fortawesome/free-solid-svg-icons";
import { TagIDSelect } from "../Tags/TagSelect";
import { StudioIDSelect } from "../Studios/StudioSelect";
export type SelectObject = {
id: string;
@ -534,144 +533,7 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
export const StudioSelect: React.FC<
IFilterProps & { excludeIds?: string[] }
> = (props) => {
const [studioAliases, setStudioAliases] = useState<Record<string, string[]>>(
{}
);
const [allAliases, setAllAliases] = useState<string[]>([]);
const { data, loading } = useAllStudiosForFilter();
const [createStudio] = useStudioCreate();
const intl = useIntl();
const { configuration } = React.useContext(ConfigurationContext);
const defaultCreatable =
!configuration?.interface.disableDropdownCreate.studio ?? true;
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
const studios = useMemo(
() =>
(data?.allStudios ?? []).filter((studio) => !exclude.includes(studio.id)),
[data?.allStudios, exclude]
);
useEffect(() => {
// build the studio aliases map
const newAliases: Record<string, string[]> = {};
const newAll: string[] = [];
studios.forEach((s) => {
newAliases[s.id] = s.aliases;
newAll.push(...s.aliases);
});
setStudioAliases(newAliases);
setAllAliases(newAll);
}, [studios]);
const StudioOption: 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 studio aliases
const aliases = studioAliases[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 createStudio({
variables: {
input: { name },
},
});
return {
item: result.data!.studioCreate!,
message: intl.formatMessage(
{ id: "toast.created_entity" },
{ entity: intl.formatMessage({ id: "studio" }).toLocaleLowerCase() }
),
};
};
const isValidNewOption = (
inputValue: string,
value: OnChangeValue<Option, boolean>,
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;
};
return (
<FilterSelectComponent
{...props}
filterOption={filterOption}
isValidNewOption={isValidNewOption}
components={{ Option: StudioOption }}
isMulti={props.isMulti ?? false}
type="studios"
isLoading={loading}
items={studios}
placeholder={
props.noSelectionString ??
intl.formatMessage(
{ id: "actions.select_entity" },
{
entityType: intl.formatMessage({
id: props.isMulti ? "studios" : "studio",
}),
}
)
}
creatable={props.creatable ?? defaultCreatable}
onCreate={onCreate}
/>
);
return <StudioIDSelect {...props} />;
};
export const MovieSelect: React.FC<IFilterProps> = (props) => {

View file

@ -4,7 +4,6 @@ import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import Mousetrap from "mousetrap";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { StudioSelect } from "src/components/Shared/Select";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { Form } from "react-bootstrap";
import ImageUtils from "src/utils/image";
@ -16,6 +15,7 @@ import { useToast } from "src/hooks/Toast";
import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form";
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
import { Studio, StudioSelect } from "../StudioSelect";
interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>;
@ -42,6 +42,8 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
// Network state
const [isLoading, setIsLoading] = useState(false);
const [parentStudio, setParentStudio] = useState<Studio | null>(null);
const schema = yup.object({
name: yup.string().required(),
url: yup.string().ensure(),
@ -73,10 +75,27 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
onSubmit: (values) => onSave(schema.cast(values)),
});
function onSetParentStudio(item: Studio | null) {
setParentStudio(item);
formik.setFieldValue("parent_id", item ? item.id : null);
}
const encodingImage = ImageUtils.usePasteImage((imageData) =>
formik.setFieldValue("image", imageData)
);
useEffect(() => {
setParentStudio(
studio.parent_studio
? {
id: studio.parent_studio.id,
name: studio.parent_studio.name,
aliases: [],
}
: null
);
}, [studio.parent_studio]);
useEffect(() => {
setImage(formik.values.image);
}, [formik.values.image, setImage]);
@ -129,12 +148,9 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
const control = (
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"parent_id",
items.length > 0 ? items[0]?.id : null
)
onSetParentStudio(items.length > 0 ? items[0] : null)
}
ids={formik.values.parent_id ? [formik.values.parent_id] : []}
values={parentStudio ? [parentStudio] : []}
/>
);

View file

@ -0,0 +1,259 @@
import React, { useEffect, useMemo, useState } from "react";
import {
OptionProps,
components as reactSelectComponents,
MultiValueGenericProps,
SingleValueProps,
} from "react-select";
import cx from "classnames";
import * as GQL from "src/core/generated-graphql";
import {
useStudioCreate,
queryFindStudiosByIDForSelect,
queryFindStudiosForSelect,
} 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";
import { Placement } from "react-bootstrap/esm/Overlay";
export type SelectObject = {
id: string;
name?: string | null;
title?: string | null;
};
export type Studio = Pick<GQL.Studio, "id" | "name" | "aliases" | "image_path">;
type Option = SelectOption<Studio>;
export const StudioSelect: React.FC<
IFilterProps &
IFilterValueProps<Studio> & {
hoverPlacement?: Placement;
excludeIds?: string[];
}
> = (props) => {
const [createStudio] = useStudioCreate();
const { configuration } = React.useContext(ConfigurationContext);
const intl = useIntl();
const maxOptionsShown =
(configuration?.ui as IUIConfig).maxOptionsShown ?? defaultMaxOptionsShown;
const defaultCreatable =
!configuration?.interface.disableDropdownCreate.studio ?? true;
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
async function loadStudios(input: string): Promise<Option[]> {
const filter = new ListFilterModel(GQL.FilterMode.Studios);
filter.searchTerm = input;
filter.currentPage = 1;
filter.itemsPerPage = maxOptionsShown;
filter.sortBy = "name";
filter.sortDirection = GQL.SortDirectionEnum.Asc;
const query = await queryFindStudiosForSelect(filter);
return query.data.findStudios.studios
.filter((studio) => {
// HACK - we should probably exclude these in the backend query, but
// this will do in the short-term
return !exclude.includes(studio.id.toString());
})
.map((studio) => ({
value: studio.id,
object: studio,
}));
}
const StudioOption: 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.aliases?.find((a) =>
a.toLowerCase().includes(inputValue.toLowerCase())
);
}
thisOptionProps = {
...optionProps,
children: (
<span className="react-select-image-option">
<span>{name}</span>
{alias && <span className="alias">{` (${alias})`}</span>}
</span>
),
};
return <reactSelectComponents.Option {...thisOptionProps} />;
};
const StudioMultiValueLabel: React.FC<
MultiValueGenericProps<Option, boolean>
> = (optionProps) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
thisOptionProps = {
...optionProps,
children: object.name,
};
return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;
};
const StudioValueLabel: 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 createStudio({
variables: { input: { name } },
});
return {
value: result.data!.studioCreate!.id,
item: result.data!.studioCreate!,
message: "Created studio",
};
};
const getNamedObject = (id: string, name: string) => {
return {
id,
name,
aliases: [],
};
};
const isValidNewOption = (inputValue: string, options: Studio[]) => {
if (!inputValue) {
return false;
}
if (
options.some((o) => {
return (
o.name.toLowerCase() === inputValue.toLowerCase() ||
o.aliases?.some((a) => a.toLowerCase() === inputValue.toLowerCase())
);
})
) {
return false;
}
return true;
};
return (
<FilterSelectComponent<Studio, boolean>
{...props}
className={cx(
"studio-select",
{
"studio-select-active": props.active,
},
props.className
)}
loadOptions={loadStudios}
getNamedObject={getNamedObject}
isValidNewOption={isValidNewOption}
components={{
Option: StudioOption,
MultiValueLabel: StudioMultiValueLabel,
SingleValue: StudioValueLabel,
}}
isMulti={props.isMulti ?? false}
creatable={props.creatable ?? defaultCreatable}
onCreate={onCreate}
placeholder={
props.noSelectionString ??
intl.formatMessage(
{ id: "actions.select_entity" },
{
entityType: intl.formatMessage({
id: props.isMulti ? "studios" : "studio",
}),
}
)
}
closeMenuOnSelect={!props.isMulti}
/>
);
};
export const StudioIDSelect: React.FC<IFilterProps & IFilterIDProps<Studio>> = (
props
) => {
const { ids, onSelect: onSelectValues } = props;
const [values, setValues] = useState<Studio[]>([]);
const idsChanged = useCompare(ids);
function onSelect(items: Studio[]) {
setValues(items);
onSelectValues?.(items);
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Studio[]> {
const studioIDs = idsToLoad.map((id) => parseInt(id));
const query = await queryFindStudiosByIDForSelect(studioIDs);
const { studios: loadedStudios } = query.data.findStudios;
return loadedStudios;
}
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 <StudioSelect {...props} values={values} onSelect={onSelect} />;
};

View file

@ -319,7 +319,22 @@ export const queryFindStudios = (filter: ListFilterModel) =>
},
});
export const useAllStudiosForFilter = () => GQL.useAllStudiosForFilterQuery();
export const queryFindStudiosByIDForSelect = (studioIDs: number[]) =>
client.query<GQL.FindStudiosForSelectQuery>({
query: GQL.FindStudiosForSelectDocument,
variables: {
ids: studioIDs,
},
});
export const queryFindStudiosForSelect = (filter: ListFilterModel) =>
client.query<GQL.FindStudiosForSelectQuery>({
query: GQL.FindStudiosForSelectDocument,
variables: {
filter: filter.makeFindFilter(),
studio_filter: filter.makeFilter(),
},
});
export const useFindTag = (id: string) => {
const skip = id === "new" || id === "";
@ -1519,8 +1534,6 @@ export const useStudioCreate = () =>
const studio = result.data?.studioCreate;
if (!studio || !variables) return;
appendObject(cache, studio, GQL.AllStudiosForFilterDocument);
// update stats
updateStats(cache, "studio_count", 1);