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
19 changed files with 494 additions and 239 deletions

View File

@@ -33,3 +33,16 @@ fragment StudioData on Studio {
rating100 rating100
aliases 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 { query AllMoviesForFilter {
allMovies { allMovies {
id id

View File

@@ -12,3 +12,16 @@ query FindStudio($id: ID!) {
...StudioData ...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( findStudios(
studio_filter: StudioFilterType studio_filter: StudioFilterType
filter: FindFilterType filter: FindFilterType
ids: [ID!]
): FindStudiosResultType! ): FindStudiosResultType!
"Find a movie by ID" "Find a movie by ID"
@@ -202,11 +203,11 @@ type Query {
allSceneMarkers: [SceneMarker!]! allSceneMarkers: [SceneMarker!]!
allImages: [Image!]! allImages: [Image!]!
allGalleries: [Gallery!]! allGalleries: [Gallery!]!
allStudios: [Studio!]!
allMovies: [Movie!]! allMovies: [Movie!]!
allPerformers: [Performer!]! @deprecated(reason: "Use findPerformers instead") allPerformers: [Performer!]! @deprecated(reason: "Use findPerformers instead")
allTags: [Tag!]! @deprecated(reason: "Use findTags instead") allTags: [Tag!]! @deprecated(reason: "Use findTags instead")
allStudios: [Studio!]! @deprecated(reason: "Use findStudios instead")
# Get everything with minimal metadata # Get everything with minimal metadata

View File

@@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "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) { 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 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 { 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 { if err != nil {
return err return err
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,8 +41,8 @@ function useCreateObject<T>(
} }
interface IUseCreateNewStudioProps { interface IUseCreateNewStudioProps {
scrapeResult: ScrapeResult<string>; scrapeResult: ScrapeResult<GQL.ScrapedStudio>;
setScrapeResult: (scrapeResult: ScrapeResult<string>) => void; setScrapeResult: (scrapeResult: ScrapeResult<GQL.ScrapedStudio>) => void;
setNewObject: (newObject: GQL.ScrapedStudio | undefined) => void; setNewObject: (newObject: GQL.ScrapedStudio | undefined) => void;
} }
@@ -62,7 +62,12 @@ export function useCreateScrapedStudio(props: IUseCreateNewStudioProps) {
}); });
// set the new studio as the value // 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); setNewObject(undefined);
} }

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import Select, { import Select, {
OnChangeValue, OnChangeValue,
StylesConfig, StylesConfig,
@@ -15,9 +15,7 @@ import CreatableSelect from "react-select/creatable";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
useAllMoviesForFilter, useAllMoviesForFilter,
useAllStudiosForFilter,
useMarkerStrings, useMarkerStrings,
useStudioCreate,
useMovieCreate, useMovieCreate,
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
@@ -33,6 +31,7 @@ import { PerformerIDSelect } from "../Performers/PerformerSelect";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { faTableColumns } from "@fortawesome/free-solid-svg-icons"; import { faTableColumns } from "@fortawesome/free-solid-svg-icons";
import { TagIDSelect } from "../Tags/TagSelect"; import { TagIDSelect } from "../Tags/TagSelect";
import { StudioIDSelect } from "../Studios/StudioSelect";
export type SelectObject = { export type SelectObject = {
id: string; id: string;
@@ -534,144 +533,7 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
export const StudioSelect: React.FC< export const StudioSelect: React.FC<
IFilterProps & { excludeIds?: string[] } IFilterProps & { excludeIds?: string[] }
> = (props) => { > = (props) => {
const [studioAliases, setStudioAliases] = useState<Record<string, string[]>>( return <StudioIDSelect {...props} />;
{}
);
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}
/>
);
}; };
export const MovieSelect: React.FC<IFilterProps> = (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 * as yup from "yup";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { StudioSelect } from "src/components/Shared/Select";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import ImageUtils from "src/utils/image"; import ImageUtils from "src/utils/image";
@@ -16,6 +15,7 @@ import { useToast } from "src/hooks/Toast";
import { handleUnsavedChanges } from "src/utils/navigation"; import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form"; import { formikUtils } from "src/utils/form";
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
import { Studio, StudioSelect } from "../StudioSelect";
interface IStudioEditPanel { interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>; studio: Partial<GQL.StudioDataFragment>;
@@ -42,6 +42,8 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
// Network state // Network state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [parentStudio, setParentStudio] = useState<Studio | null>(null);
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
url: yup.string().ensure(), url: yup.string().ensure(),
@@ -73,10 +75,27 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
onSubmit: (values) => onSave(schema.cast(values)), 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) => const encodingImage = ImageUtils.usePasteImage((imageData) =>
formik.setFieldValue("image", 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(() => { useEffect(() => {
setImage(formik.values.image); setImage(formik.values.image);
}, [formik.values.image, setImage]); }, [formik.values.image, setImage]);
@@ -129,12 +148,9 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
const control = ( const control = (
<StudioSelect <StudioSelect
onSelect={(items) => onSelect={(items) =>
formik.setFieldValue( onSetParentStudio(items.length > 0 ? items[0] : null)
"parent_id",
items.length > 0 ? items[0]?.id : 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) => { export const useFindTag = (id: string) => {
const skip = id === "new" || id === ""; const skip = id === "new" || id === "";
@@ -1519,8 +1534,6 @@ export const useStudioCreate = () =>
const studio = result.data?.studioCreate; const studio = result.data?.studioCreate;
if (!studio || !variables) return; if (!studio || !variables) return;
appendObject(cache, studio, GQL.AllStudiosForFilterDocument);
// update stats // update stats
updateStats(cache, "studio_count", 1); updateStats(cache, "studio_count", 1);