From e231812203c47582a8ae68a3337bbf9476f557ff Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 19 Feb 2024 10:25:08 +1100 Subject: [PATCH] Movie select overhaul (#4563) * Add ids to findMovies input * Use ids for other find interfaces * Update client side * Fix gallery select function * Replace movie select * Re-add creatable * Overhaul movie table * Remove and deprecated unused code --- graphql/schema/schema.graphql | 23 +- internal/api/resolver_query_find_image.go | 68 +++-- internal/api/resolver_query_find_movie.go | 20 +- internal/api/resolver_query_find_performer.go | 10 +- internal/api/resolver_query_find_scene.go | 16 +- internal/api/resolver_query_find_tag.go | 12 +- ui/v2.5/graphql/data/movie-slim.graphql | 6 + ui/v2.5/graphql/queries/misc.graphql | 7 - ui/v2.5/graphql/queries/movie.graphql | 13 + ui/v2.5/graphql/queries/performer.graphql | 4 +- ui/v2.5/graphql/queries/tag.graphql | 2 +- .../components/Galleries/GallerySelect.tsx | 7 +- ui/v2.5/src/components/Movies/MovieSelect.tsx | 238 ++++++++++++++++++ .../components/Performers/PerformerSelect.tsx | 3 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 72 ++++-- .../Scenes/SceneDetails/SceneMovieTable.tsx | 142 +++++++---- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 66 ++--- .../components/Scenes/SceneMergeDialog.tsx | 25 +- ui/v2.5/src/components/Scenes/styles.scss | 16 +- .../Shared/ScrapeDialog/ScrapeDialog.tsx | 28 ++- .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 86 ++++--- .../Shared/ScrapeDialog/createObjects.ts | 65 ++--- ui/v2.5/src/components/Shared/Select.tsx | 135 +--------- .../src/components/Studios/StudioSelect.tsx | 3 +- ui/v2.5/src/components/Tags/TagSelect.tsx | 3 +- ui/v2.5/src/core/StashService.ts | 61 ++--- ui/v2.5/src/locales/en-GB.json | 2 +- 27 files changed, 696 insertions(+), 437 deletions(-) create mode 100644 ui/v2.5/src/components/Movies/MovieSelect.tsx diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index e60cdb683..9db3d90ab 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -12,7 +12,8 @@ type Query { "A function which queries Scene objects" findScenes( scene_filter: SceneFilterType - scene_ids: [Int!] + scene_ids: [Int!] @deprecated(reason: "use ids") + ids: [ID!] filter: FindFilterType ): FindScenesResultType! @@ -50,7 +51,8 @@ type Query { "A function which queries Scene objects" findImages( image_filter: ImageFilterType - image_ids: [Int!] + image_ids: [Int!] @deprecated(reason: "use ids") + ids: [ID!] filter: FindFilterType ): FindImagesResultType! @@ -60,7 +62,8 @@ type Query { findPerformers( performer_filter: PerformerFilterType filter: FindFilterType - performer_ids: [Int!] + performer_ids: [Int!] @deprecated(reason: "use ids") + ids: [ID!] ): FindPerformersResultType! "Find a studio by ID" @@ -78,6 +81,7 @@ type Query { findMovies( movie_filter: MovieFilterType filter: FindFilterType + ids: [ID!] ): FindMoviesResultType! findGallery(id: ID!): Gallery @@ -91,7 +95,7 @@ type Query { findTags( tag_filter: TagFilterType filter: FindFilterType - ids: [Int!] + ids: [ID!] ): FindTagsResultType! "Retrieve random scene markers for the wall" @@ -200,15 +204,16 @@ type Query { # Get everything - allScenes: [Scene!]! + allScenes: [Scene!]! @deprecated(reason: "Use findScenes instead") allSceneMarkers: [SceneMarker!]! - allImages: [Image!]! - allGalleries: [Gallery!]! - allMovies: [Movie!]! + @deprecated(reason: "Use findSceneMarkers instead") + allImages: [Image!]! @deprecated(reason: "Use findImages instead") + allGalleries: [Gallery!]! @deprecated(reason: "Use findGalleries instead") - allPerformers: [Performer!]! @deprecated(reason: "Use findPerformers instead") + allPerformers: [Performer!]! allTags: [Tag!]! @deprecated(reason: "Use findTags instead") allStudios: [Studio!]! @deprecated(reason: "Use findStudios instead") + allMovies: [Movie!]! @deprecated(reason: "Use findMovies instead") # Get everything with minimal metadata diff --git a/internal/api/resolver_query_find_image.go b/internal/api/resolver_query_find_image.go index dde193a5f..70729f1a7 100644 --- a/internal/api/resolver_query_find_image.go +++ b/internal/api/resolver_query_find_image.go @@ -7,6 +7,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) { @@ -46,28 +47,63 @@ func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *str return image, nil } -func (r *queryResolver) FindImages(ctx context.Context, imageFilter *models.ImageFilterType, imageIds []int, filter *models.FindFilterType) (ret *FindImagesResultType, err error) { +func (r *queryResolver) FindImages( + ctx context.Context, + imageFilter *models.ImageFilterType, + imageIds []int, + ids []string, + filter *models.FindFilterType, +) (ret *FindImagesResultType, err error) { + if len(ids) > 0 { + imageIds, err = stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + } + if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Image + var images []*models.Image fields := graphql.CollectAllFields(ctx) + result := &models.ImageQueryResult{} - result, err := qb.Query(ctx, models.ImageQueryOptions{ - QueryOptions: models.QueryOptions{ - FindFilter: filter, - Count: sliceutil.Contains(fields, "count"), - }, - ImageFilter: imageFilter, - Megapixels: sliceutil.Contains(fields, "megapixels"), - TotalSize: sliceutil.Contains(fields, "filesize"), - }) - if err != nil { - return err - } + if len(imageIds) > 0 { + images, err = r.repository.Image.FindMany(ctx, imageIds) + if err == nil { + result.Count = len(images) + for _, s := range images { + if err = s.LoadPrimaryFile(ctx, r.repository.File); err != nil { + break + } - images, err := result.Resolve(ctx) - if err != nil { - return err + f := s.Files.Primary() + if f == nil { + continue + } + + imageFile, ok := f.(*models.ImageFile) + if !ok { + continue + } + + result.Megapixels += float64(imageFile.Width*imageFile.Height) / float64(1000000) + result.TotalSize += float64(f.Base().Size) + } + } + } else { + result, err = qb.Query(ctx, models.ImageQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: filter, + Count: sliceutil.Contains(fields, "count"), + }, + ImageFilter: imageFilter, + Megapixels: sliceutil.Contains(fields, "megapixels"), + TotalSize: sliceutil.Contains(fields, "filesize"), + }) + if err == nil { + images, err = result.Resolve(ctx) + } } ret = &FindImagesResultType{ diff --git a/internal/api/resolver_query_find_movie.go b/internal/api/resolver_query_find_movie.go index dc98b6abe..84e01e282 100644 --- a/internal/api/resolver_query_find_movie.go +++ b/internal/api/resolver_query_find_movie.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Movie, err error) { @@ -23,9 +24,24 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.M return ret, nil } -func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.MovieFilterType, filter *models.FindFilterType) (ret *FindMoviesResultType, err error) { +func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.MovieFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) { + idInts, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + if err := r.withReadTxn(ctx, func(ctx context.Context) error { - movies, total, err := r.repository.Movie.Query(ctx, movieFilter, filter) + var movies []*models.Movie + var err error + var total int + + if len(idInts) > 0 { + movies, err = r.repository.Movie.FindMany(ctx, idInts) + total = len(movies) + } else { + movies, total, err = r.repository.Movie.Query(ctx, movieFilter, filter) + } + if err != nil { return err } diff --git a/internal/api/resolver_query_find_performer.go b/internal/api/resolver_query_find_performer.go index a47b7a18d..41218fd01 100644 --- a/internal/api/resolver_query_find_performer.go +++ b/internal/api/resolver_query_find_performer.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *models.Performer, err error) { @@ -23,7 +24,14 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode return ret, nil } -func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int) (ret *FindPerformersResultType, err error) { +func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int, ids []string) (ret *FindPerformersResultType, err error) { + if len(ids) > 0 { + performerIDs, err = stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + } + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var performers []*models.Performer var err error diff --git a/internal/api/resolver_query_find_scene.go b/internal/api/resolver_query_find_scene.go index bd6fc6ffe..0ea35a490 100644 --- a/internal/api/resolver_query_find_scene.go +++ b/internal/api/resolver_query_find_scene.go @@ -9,6 +9,7 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) { @@ -74,7 +75,20 @@ func (r *queryResolver) FindSceneByHash(ctx context.Context, input SceneHashInpu return scene, nil } -func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.SceneFilterType, sceneIDs []int, filter *models.FindFilterType) (ret *FindScenesResultType, err error) { +func (r *queryResolver) FindScenes( + ctx context.Context, + sceneFilter *models.SceneFilterType, + sceneIDs []int, + ids []string, + filter *models.FindFilterType, +) (ret *FindScenesResultType, err error) { + if len(ids) > 0 { + sceneIDs, err = stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + } + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var scenes []*models.Scene var err error diff --git a/internal/api/resolver_query_find_tag.go b/internal/api/resolver_query_find_tag.go index 6ec93d21a..b157cb329 100644 --- a/internal/api/resolver_query_find_tag.go +++ b/internal/api/resolver_query_find_tag.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag, err error) { @@ -23,14 +24,19 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag return ret, nil } -func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []int) (ret *FindTagsResultType, err error) { +func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []string) (ret *FindTagsResultType, err error) { + idInts, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var tags []*models.Tag var err error var total int - if len(ids) > 0 { - tags, err = r.repository.Tag.FindMany(ctx, ids) + if len(idInts) > 0 { + tags, err = r.repository.Tag.FindMany(ctx, idInts) total = len(tags) } else { tags, total, err = r.repository.Tag.Query(ctx, tagFilter, filter) diff --git a/ui/v2.5/graphql/data/movie-slim.graphql b/ui/v2.5/graphql/data/movie-slim.graphql index 28986b232..0e533f650 100644 --- a/ui/v2.5/graphql/data/movie-slim.graphql +++ b/ui/v2.5/graphql/data/movie-slim.graphql @@ -4,3 +4,9 @@ fragment SlimMovieData on Movie { front_image_path rating100 } + +fragment SelectMovieData on Movie { + id + name + front_image_path +} diff --git a/ui/v2.5/graphql/queries/misc.graphql b/ui/v2.5/graphql/queries/misc.graphql index 1730585c6..9367f0cc2 100644 --- a/ui/v2.5/graphql/queries/misc.graphql +++ b/ui/v2.5/graphql/queries/misc.graphql @@ -6,13 +6,6 @@ query MarkerStrings($q: String, $sort: String) { } } -query AllMoviesForFilter { - allMovies { - id - name - } -} - query Stats { stats { scene_count diff --git a/ui/v2.5/graphql/queries/movie.graphql b/ui/v2.5/graphql/queries/movie.graphql index 3fd347c73..088629b87 100644 --- a/ui/v2.5/graphql/queries/movie.graphql +++ b/ui/v2.5/graphql/queries/movie.graphql @@ -12,3 +12,16 @@ query FindMovie($id: ID!) { ...MovieData } } + +query FindMoviesForSelect( + $filter: FindFilterType + $movie_filter: MovieFilterType + $ids: [ID!] +) { + findMovies(filter: $filter, movie_filter: $movie_filter, ids: $ids) { + count + movies { + ...SelectMovieData + } + } +} diff --git a/ui/v2.5/graphql/queries/performer.graphql b/ui/v2.5/graphql/queries/performer.graphql index 3c3f689c3..933d3f227 100644 --- a/ui/v2.5/graphql/queries/performer.graphql +++ b/ui/v2.5/graphql/queries/performer.graphql @@ -24,12 +24,12 @@ query FindPerformer($id: ID!) { query FindPerformersForSelect( $filter: FindFilterType $performer_filter: PerformerFilterType - $performer_ids: [Int!] + $ids: [ID!] ) { findPerformers( filter: $filter performer_filter: $performer_filter - performer_ids: $performer_ids + ids: $ids ) { count performers { diff --git a/ui/v2.5/graphql/queries/tag.graphql b/ui/v2.5/graphql/queries/tag.graphql index aec1ef8bd..ab1fc62f8 100644 --- a/ui/v2.5/graphql/queries/tag.graphql +++ b/ui/v2.5/graphql/queries/tag.graphql @@ -16,7 +16,7 @@ query FindTag($id: ID!) { query FindTagsForSelect( $filter: FindFilterType $tag_filter: TagFilterType - $ids: [Int!] + $ids: [ID!] ) { findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids) { count diff --git a/ui/v2.5/src/components/Galleries/GallerySelect.tsx b/ui/v2.5/src/components/Galleries/GallerySelect.tsx index 6fbd4ec62..2adf6823a 100644 --- a/ui/v2.5/src/components/Galleries/GallerySelect.tsx +++ b/ui/v2.5/src/components/Galleries/GallerySelect.tsx @@ -9,7 +9,7 @@ import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { - queryFindGalleries, + queryFindGalleriesForSelect, queryFindGalleriesByIDForSelect, } from "src/core/StashService"; import { ConfigurationContext } from "src/hooks/Config"; @@ -56,7 +56,7 @@ const _GallerySelect: React.FC< filter.itemsPerPage = maxOptionsShown; filter.sortBy = "title"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - const query = await queryFindGalleries(filter); + const query = await queryFindGalleriesForSelect(filter); let ret = query.data.findGalleries.galleries.filter((gallery) => { // HACK - we should probably exclude these in the backend query, but // this will do in the short-term @@ -190,8 +190,7 @@ const _GalleryIDSelect: React.FC> = ( } async function loadObjectsByID(idsToLoad: string[]): Promise { - const galleryIDs = idsToLoad.map((id) => parseInt(id)); - const query = await queryFindGalleriesByIDForSelect(galleryIDs); + const query = await queryFindGalleriesByIDForSelect(idsToLoad); const { galleries: loadedGalleries } = query.data.findGalleries; return loadedGalleries; diff --git a/ui/v2.5/src/components/Movies/MovieSelect.tsx b/ui/v2.5/src/components/Movies/MovieSelect.tsx new file mode 100644 index 000000000..c708c820d --- /dev/null +++ b/ui/v2.5/src/components/Movies/MovieSelect.tsx @@ -0,0 +1,238 @@ +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 { + queryFindMoviesForSelect, + queryFindMoviesByIDForSelect, + useMovieCreate, +} from "src/core/StashService"; +import { ConfigurationContext } from "src/hooks/Config"; +import { useIntl } from "react-intl"; +import { defaultMaxOptionsShown } 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"; +import { sortByRelevance } from "src/utils/query"; +import { PatchComponent } from "src/pluginApi"; + +export type Movie = Pick; +type Option = SelectOption; + +const _MovieSelect: React.FC< + IFilterProps & + IFilterValueProps & { + hoverPlacement?: Placement; + excludeIds?: string[]; + } +> = (props) => { + const [createMovie] = useMovieCreate(); + + const { configuration } = React.useContext(ConfigurationContext); + const intl = useIntl(); + const maxOptionsShown = + configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; + const defaultCreatable = + !configuration?.interface.disableDropdownCreate.movie ?? true; + + const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + + async function loadMovies(input: string): Promise { + const filter = new ListFilterModel(GQL.FilterMode.Movies); + filter.searchTerm = input; + filter.currentPage = 1; + filter.itemsPerPage = maxOptionsShown; + filter.sortBy = "name"; + filter.sortDirection = GQL.SortDirectionEnum.Asc; + const query = await queryFindMoviesForSelect(filter); + let ret = query.data.findMovies.movies.filter((movie) => { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(movie.id.toString()); + }); + + return sortByRelevance(input, ret, (m) => m.name).map((movie) => ({ + value: movie.id, + object: movie, + })); + } + + const MovieOption: React.FC> = (optionProps) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + const title = object.name; + + thisOptionProps = { + ...optionProps, + children: {title}, + }; + + return ; + }; + + const MovieMultiValueLabel: React.FC< + MultiValueGenericProps + > = (optionProps) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + thisOptionProps = { + ...optionProps, + children: object.name, + }; + + return ; + }; + + const MovieValueLabel: React.FC> = ( + optionProps + ) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + thisOptionProps = { + ...optionProps, + children: <>{object.name}, + }; + + return ; + }; + + const onCreate = async (name: string) => { + const result = await createMovie({ + variables: { input: { name } }, + }); + return { + value: result.data!.movieCreate!.id, + item: result.data!.movieCreate!, + message: "Created movie", + }; + }; + + const getNamedObject = (id: string, name: string) => { + return { + id, + name, + }; + }; + + const isValidNewOption = (inputValue: string, options: Movie[]) => { + if (!inputValue) { + return false; + } + + if ( + options.some((o) => { + return o.name.toLowerCase() === inputValue.toLowerCase(); + }) + ) { + return false; + } + + return true; + }; + + return ( + + {...props} + className={cx( + "movie-select", + { + "movie-select-active": props.active, + }, + props.className + )} + loadOptions={loadMovies} + getNamedObject={getNamedObject} + isValidNewOption={isValidNewOption} + components={{ + Option: MovieOption, + MultiValueLabel: MovieMultiValueLabel, + SingleValue: MovieValueLabel, + }} + 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 ? "movies" : "movie", + }), + } + ) + } + closeMenuOnSelect={!props.isMulti} + /> + ); +}; + +export const MovieSelect = PatchComponent("MovieSelect", _MovieSelect); + +const _MovieIDSelect: React.FC> = ( + props +) => { + const { ids, onSelect: onSelectValues } = props; + + const [values, setValues] = useState([]); + const idsChanged = useCompare(ids); + + function onSelect(items: Movie[]) { + setValues(items); + onSelectValues?.(items); + } + + async function loadObjectsByID(idsToLoad: string[]): Promise { + const query = await queryFindMoviesByIDForSelect(idsToLoad); + const { movies: loadedMovies } = query.data.findMovies; + + return loadedMovies; + } + + 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 ; +}; + +export const MovieIDSelect = PatchComponent("MovieIDSelect", _MovieIDSelect); diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index f733769ce..3f836862d 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -256,8 +256,7 @@ const _PerformerIDSelect: React.FC> = ( } async function loadObjectsByID(idsToLoad: string[]): Promise { - const performerIDs = idsToLoad.map((id) => parseInt(id)); - const query = await queryFindPerformersByIDForSelect(performerIDs); + const query = await queryFindPerformersByIDForSelect(idsToLoad); const { performers: loadedPerformers } = query.data.findPerformers; return loadedPerformers; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 5aa83d545..b4c3048c3 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -19,7 +19,6 @@ import { mutateReloadScrapers, queryScrapeSceneQueryFragment, } from "src/core/StashService"; -import { 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"; @@ -30,7 +29,7 @@ import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import { ConfigurationContext } from "src/hooks/Config"; import { stashboxDisplayName } from "src/utils/stashbox"; -import { SceneMovieTable } from "./SceneMovieTable"; +import { IMovieEntry, SceneMovieTable } from "./SceneMovieTable"; import { faSearch, faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; @@ -50,6 +49,7 @@ import { formikUtils } from "src/utils/form"; import { Tag, TagSelect } from "src/components/Tags/TagSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; +import { Movie } from "src/components/Movies/MovieSelect"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -76,6 +76,7 @@ export const SceneEditPanel: React.FC = ({ const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); + const [movies, setMovies] = useState([]); const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); @@ -104,6 +105,10 @@ export const SceneEditPanel: React.FC = ({ setPerformers(scene.performers ?? []); }, [scene.performers]); + useEffect(() => { + setMovies(scene.movies?.map((m) => m.movie) ?? []); + }, [scene.movies]); + useEffect(() => { setTags(scene.tags ?? []); }, [scene.tags]); @@ -185,6 +190,17 @@ export const SceneEditPanel: React.FC = ({ return sceneImage; }, [formik.values.cover_image, scene.paths?.screenshot]); + const movieEntries = useMemo(() => { + return formik.values.movies + .map((m) => { + return { + movie: movies.find((mm) => mm.id === m.movie_id), + scene_index: m.scene_index, + }; + }) + .filter((m) => m.movie !== undefined) as IMovieEntry[]; + }, [formik.values.movies, movies]); + function setRating(v: number) { formik.setFieldValue("rating100", v); } @@ -258,17 +274,19 @@ export const SceneEditPanel: React.FC = ({ setQueryableScrapers(newQueryableScrapers); }, [Scrapers, stashConfig]); - function setMovieIds(movieIds: string[]) { + function onSetMovies(items: Movie[]) { + setMovies(items); + const existingMovies = formik.values.movies; - const newMovies = movieIds.map((m) => { - const existing = existingMovies.find((mm) => mm.movie_id === m); + const newMovies = items.map((m) => { + const existing = existingMovies.find((mm) => mm.movie_id === m.id); if (existing) { return existing; } return { - movie_id: m, + movie_id: m.id, scene_index: null, }; }); @@ -287,17 +305,6 @@ export const SceneEditPanel: React.FC = ({ setIsLoading(false); } - function renderTableMovies() { - return ( - { - formik.setFieldValue("movies", items); - }} - /> - ); - } - const encodingImage = ImageUtils.usePasteImage(onImageLoad); function onImageLoad(imageData: string) { @@ -400,6 +407,7 @@ export const SceneEditPanel: React.FC = ({ sceneStudio={studio} sceneTags={tags} scenePerformers={performers} + sceneMovies={movies} scraped={scrapedScene} endpoint={endpoint} onClose={(s) => onScrapeDialogClosed(s)} @@ -589,8 +597,14 @@ export const SceneEditPanel: React.FC = ({ }); if (idMovis.length > 0) { - const newIds = idMovis.map((p) => p.stored_id); - setMovieIds(newIds as string[]); + onSetMovies( + idMovis.map((p) => { + return { + id: p.stored_id!, + name: p.name ?? "", + }; + }) + ); } } @@ -751,17 +765,21 @@ export const SceneEditPanel: React.FC = ({ return renderField("performer_ids", title, control, fullWidthProps); } + function onSetMovieEntries(input: IMovieEntry[]) { + setMovies(input.map((m) => m.movie)); + + const newMovies = input.map((m) => ({ + movie_id: m.movie.id, + scene_index: m.scene_index, + })); + + formik.setFieldValue("movies", newMovies); + } + function renderMoviesField() { const title = intl.formatMessage({ id: "movies" }); const control = ( - <> - setMovieIds(items.map((item) => item.id))} - ids={formik.values.movies.map((m) => m.movie_id)} - /> - {renderTableMovies()} - + ); return renderField("movies", title, control, fullWidthProps); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx index a7a8ba56b..edd4bba8a 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx @@ -1,50 +1,96 @@ -import React from "react"; +import React, { useMemo } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; -import { useAllMoviesForFilter } from "src/core/StashService"; import { Form, Row, Col } from "react-bootstrap"; +import { Movie, MovieSelect } from "src/components/Movies/MovieSelect"; +import cx from "classnames"; export type MovieSceneIndexMap = Map; +export interface IMovieEntry { + movie: Movie; + scene_index?: GQL.InputMaybe | undefined; +} + export interface IProps { - movieScenes: GQL.SceneMovieInput[]; - onUpdate: (value: GQL.SceneMovieInput[]) => void; + value: IMovieEntry[]; + onUpdate: (input: IMovieEntry[]) => void; } export const SceneMovieTable: React.FC = (props) => { + const { value, onUpdate } = props; + const intl = useIntl(); - const { data } = useAllMoviesForFilter(); - const items = !!data && !!data.allMovies ? data.allMovies : []; + const movieIDs = useMemo(() => value.map((m) => m.movie.id), [value]); - const movieEntries = props.movieScenes.map((m) => { - return { - movie: items.find((mm) => m.movie_id === mm.id), - ...m, - }; - }); - - const updateFieldChanged = (movieId: string, value: number) => { - const newValues = props.movieScenes.map((ms) => { - if (ms.movie_id === movieId) { + const updateFieldChanged = (index: number, sceneIndex: number | null) => { + const newValues = value.map((existing, i) => { + if (i === index) { return { - movie_id: movieId, - scene_index: value, + ...existing, + scene_index: sceneIndex, }; } - return ms; + return existing; }); - props.onUpdate(newValues); + + onUpdate(newValues); }; + function onMovieSet(index: number, movies: Movie[]) { + if (!movies.length) { + // remove this entry + const newValues = value.filter((_, i) => i !== index); + onUpdate(newValues); + return; + } + + const movie = movies[0]; + + const newValues = value.map((existing, i) => { + if (i === index) { + return { + ...existing, + movie: movie, + }; + } + return existing; + }); + + onUpdate(newValues); + } + + function onNewMovieSet(movies: Movie[]) { + if (!movies.length) { + return; + } + + const movie = movies[0]; + + const newValues = [ + ...value, + { + movie: movie, + scene_index: null, + }, + ]; + + onUpdate(newValues); + } + function renderTableData() { return ( <> - {movieEntries.map((m) => ( - - - {m.movie?.name ?? ""} - + {value.map((m, i) => ( + + + onMovieSet(i, items)} + values={[m.movie!]} + excludeIds={movieIDs} + /> + = (props) => { value={m.scene_index ?? ""} onChange={(e: React.ChangeEvent) => { updateFieldChanged( - m.movie_id, - Number.parseInt( - e.currentTarget.value ? e.currentTarget.value : "0", - 10 - ) + i, + e.currentTarget.value === "" + ? null + : Number.parseInt(e.currentTarget.value, 10) ); }} /> ))} + + + onNewMovieSet(items)} + values={[]} + excludeIds={movieIDs} + /> + + ); } - if (props.movieScenes.length > 0) { - return ( -
- - - {intl.formatMessage({ id: "movie" })} - - - {intl.formatMessage({ id: "movie_scene_number" })} - - - {renderTableData()} -
- ); - } - - return <>; + return ( +
+ + + + {intl.formatMessage({ id: "movie_scene_number" })} + + + {renderTableData()} +
+ ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index e70f1b610..91bc9457c 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -7,11 +7,10 @@ import { ScrapedImageRow, ScrapedStringListRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; -import clone from "lodash-es/clone"; import { useIntl } from "react-intl"; import { uniq } from "lodash-es"; import { Performer } from "src/components/Performers/PerformerSelect"; -import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; +import { sortStoredIdObjects } from "src/utils/data"; import { ObjectListScrapeResult, ObjectScrapeResult, @@ -31,12 +30,14 @@ import { } from "src/components/Shared/ScrapeDialog/createObjects"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; +import { Movie } from "src/components/Movies/MovieSelect"; interface ISceneScrapeDialogProps { scene: Partial; sceneStudio: Studio | null; scenePerformers: Performer[]; sceneTags: Tag[]; + sceneMovies: Movie[]; scraped: GQL.ScrapedScene; endpoint?: string; @@ -48,6 +49,7 @@ export const SceneScrapeDialog: React.FC = ({ sceneStudio, scenePerformers, sceneTags, + sceneMovies, scraped, onClose, endpoint, @@ -96,44 +98,6 @@ export const SceneScrapeDialog: React.FC = ({ ) ); - function mapStoredIdObjects( - scrapedObjects?: IHasStoredID[] - ): string[] | undefined { - if (!scrapedObjects) { - return undefined; - } - const ret = scrapedObjects - .map((p) => p.stored_id) - .filter((p) => { - return p !== undefined && p !== null; - }) as string[]; - - if (ret.length === 0) { - return undefined; - } - - // sort by id numerically - ret.sort((a, b) => { - return parseInt(a, 10) - parseInt(b, 10); - }); - - return ret; - } - - function sortIdList(idList?: string[] | null) { - if (!idList) { - return; - } - - const ret = clone(idList); - // sort by id numerically - ret.sort((a, b) => { - return parseInt(a, 10) - parseInt(b, 10); - }); - - return ret; - } - const [performers, setPerformers] = useState< ObjectListScrapeResult >( @@ -151,10 +115,17 @@ export const SceneScrapeDialog: React.FC = ({ scraped.performers?.filter((t) => !t.stored_id) ?? [] ); - const [movies, setMovies] = useState>( - new ScrapeResult( - sortIdList(scene.movies?.map((p) => p.movie_id)), - mapStoredIdObjects(scraped.movies ?? undefined) + const [movies, setMovies] = useState< + ObjectListScrapeResult + >( + new ObjectListScrapeResult( + sortStoredIdObjects( + sceneMovies.map((p) => ({ + stored_id: p.id, + name: p.name, + })) + ), + sortStoredIdObjects(scraped.movies ?? undefined) ) ); const [newMovies, setNewMovies] = useState( @@ -249,12 +220,7 @@ export const SceneScrapeDialog: React.FC = ({ director: director.getNewValue(), studio: newStudioValue, performers: performers.getNewValue(), - movies: movies.getNewValue()?.map((m) => { - return { - stored_id: m, - name: "", - }; - }), + movies: movies.getNewValue(), tags: tags.getNewValue(), details: details.getNewValue(), image: image.getNewValue(), diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 84a028526..a2bbf4792 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -93,6 +93,13 @@ const SceneMergeDetails: React.FC = ({ }; } + function movieToStoredID(o: { movie: { id: string; name: string } }) { + return { + stored_id: o.movie.id, + name: o.movie.name, + }; + } + const [studio, setStudio] = useState>( new ScrapeResult( dest.studio ? idToStoredID(dest.studio) : undefined @@ -127,8 +134,12 @@ const SceneMergeDetails: React.FC = ({ ) ); - const [movies, setMovies] = useState>( - new ScrapeResult(sortIdList(dest.movies.map((p) => p.movie.id))) + const [movies, setMovies] = useState< + ObjectListScrapeResult + >( + new ObjectListScrapeResult( + sortStoredIdObjects(dest.movies.map(movieToStoredID)) + ) ); const [tags, setTags] = useState>( @@ -235,9 +246,9 @@ const SceneMergeDetails: React.FC = ({ ); setMovies( - new ScrapeResult( - dest.movies.map((m) => m.movie.id), - uniq(all.map((s) => s.movies.map((m) => m.movie.id)).flat()) + new ObjectListScrapeResult( + sortStoredIdObjects(dest.movies.map(movieToStoredID)), + uniqIDStoredIDs(all.map((s) => s.movies.map(movieToStoredID)).flat()) ) ); @@ -571,9 +582,9 @@ const SceneMergeDetails: React.FC = ({ const found = all .map((s) => s.movies) .flat() - .find((mm) => mm.movie.id === m); + .find((mm) => mm.movie.id === m.stored_id); return { - movie_id: m, + movie_id: m.stored_id!, scene_index: found!.scene_index, }; }), diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 3b65e20c4..436b75b77 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -237,9 +237,21 @@ textarea.scene-description { .movie-table { width: 100%; - td { - vertical-align: middle; + .movie-row { + align-items: center; + margin-bottom: 0.25rem; } + + .movie-scene-number-header { + color: $text-muted; + font-size: 0.8em; + padding-bottom: 0; + padding-top: 0; + } +} + +.movie-table.no-movies .movie-table-header { + display: none; } .scene-tabs { diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx index cbe52d113..59d5f3985 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx @@ -25,16 +25,11 @@ import { StringListInput } from "../StringListInput"; import { ImageSelector } from "../ImageSelector"; import { ScrapeResult } from "./scrapeResult"; -export interface IHasName { - name: string | undefined; -} - interface IScrapedFieldProps { result: ScrapeResult; } -interface IScrapedRowProps - extends IScrapedFieldProps { +interface IScrapedRowProps extends IScrapedFieldProps { className?: string; title: string; renderOriginalField: (result: ScrapeResult) => JSX.Element | undefined; @@ -42,6 +37,7 @@ interface IScrapedRowProps onChange: (value: ScrapeResult) => void; newValues?: V[]; onCreateNew?: (index: number) => void; + getName?: (value: V) => string; } function renderButtonIcon(selected: boolean) { @@ -55,9 +51,9 @@ function renderButtonIcon(selected: boolean) { ); } -export const ScrapeDialogRow = ( - props: IScrapedRowProps -) => { +export const ScrapeDialogRow = (props: IScrapedRowProps) => { + const { getName = () => "" } = props; + function handleSelectClick(isNew: boolean) { const ret = clone(props.result); ret.useNewValue = isNew; @@ -83,10 +79,10 @@ export const ScrapeDialogRow = ( props.onCreateNew!(i)} > - {t.name} + {getName(t)} @@ -173,6 +169,10 @@ const ScrapedInputGroup: React.FC = (props) => { ); }; +function getNameString(value: string) { + return value; +} + interface IScrapedInputGroupRowProps { title: string; placeholder?: string; @@ -206,6 +206,7 @@ export const ScrapedInputGroupRow: React.FC = ( /> )} onChange={props.onChange} + getName={getNameString} /> ); }; @@ -271,6 +272,7 @@ export const ScrapedStringListRow: React.FC = ( /> )} onChange={props.onChange} + getName={getNameString} /> ); }; @@ -316,6 +318,7 @@ export const ScrapedTextAreaRow: React.FC = ( /> )} onChange={props.onChange} + getName={getNameString} /> ); }; @@ -369,6 +372,7 @@ export const ScrapedImageRow: React.FC = (props) => { /> )} onChange={props.onChange} + getName={getNameString} /> ); }; @@ -412,6 +416,7 @@ export const ScrapedImagesRow: React.FC = (props) => { )} onChange={props.onChange} + getName={getNameString} /> ); }; @@ -514,5 +519,6 @@ export const ScrapedCountryRow: React.FC = ({ /> )} onChange={onChange} + getName={getNameString} /> ); diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 50a195233..8d0102985 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -1,10 +1,6 @@ import React, { useMemo } from "react"; import * as GQL from "src/core/generated-graphql"; -import { MovieSelect } from "src/components/Shared/Select"; -import { - ScrapeDialogRow, - IHasName, -} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; +import { ScrapeDialogRow } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { PerformerSelect } from "src/components/Performers/PerformerSelect"; import { ObjectScrapeResult, @@ -12,6 +8,7 @@ import { } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { TagSelect } from "src/components/Tags/TagSelect"; import { StudioSelect } from "src/components/Studios/StudioSelect"; +import { MovieSelect } from "src/components/Movies/MovieSelect"; interface IScrapedStudioRow { title: string; @@ -21,6 +18,10 @@ interface IScrapedStudioRow { onCreateNew?: (value: GQL.ScrapedStudio) => void; } +function getObjectName(value: T) { + return value.name; +} + export const ScrapedStudioRow: React.FC = ({ title, result, @@ -76,28 +77,35 @@ export const ScrapedStudioRow: React.FC = ({ onCreateNew={() => { if (onCreateNew && newStudio) onCreateNew(newStudio); }} + getName={getObjectName} /> ); }; -interface IScrapedObjectsRow { +interface IScrapedObjectsRow { title: string; - result: ScrapeResult; - onChange: (value: ScrapeResult) => void; + result: ScrapeResult; + onChange: (value: ScrapeResult) => void; newObjects?: T[]; onCreateNew?: (value: T) => void; renderObjects: ( - result: ScrapeResult, + result: ScrapeResult, isNew?: boolean, - onChange?: (value: R[]) => void + onChange?: (value: T[]) => void ) => JSX.Element; + getName: (value: T) => string; } -export const ScrapedObjectsRow = ( - props: IScrapedObjectsRow -) => { - const { title, result, onChange, newObjects, onCreateNew, renderObjects } = - props; +export const ScrapedObjectsRow = (props: IScrapedObjectsRow) => { + const { + title, + result, + onChange, + newObjects, + onCreateNew, + renderObjects, + getName, + } = props; return ( ( onCreateNew={(i) => { if (onCreateNew) onCreateNew(newObjects![i]); }} + getName={getName} /> ); }; -type IScrapedObjectRowImpl = Omit< - IScrapedObjectsRow, - "renderObjects" +type IScrapedObjectRowImpl = Omit< + IScrapedObjectsRow, + "renderObjects" | "getName" >; export const ScrapedPerformersRow: React.FC< - IScrapedObjectRowImpl + IScrapedObjectRowImpl > = ({ title, result, onChange, newObjects, onCreateNew }) => { const performersCopy = useMemo(() => { return ( @@ -170,24 +179,21 @@ export const ScrapedPerformersRow: React.FC< ); } - type PerformerType = GQL.ScrapedPerformer & { - name: string; - }; - return ( - + title={title} result={result} renderObjects={renderScrapedPerformers} onChange={onChange} newObjects={performersCopy} onCreateNew={onCreateNew} + getName={(value) => value.name ?? ""} /> ); }; export const ScrapedMoviesRow: React.FC< - IScrapedObjectRowImpl + IScrapedObjectRowImpl > = ({ title, result, onChange, newObjects, onCreateNew }) => { const moviesCopy = useMemo(() => { return ( @@ -198,20 +204,25 @@ export const ScrapedMoviesRow: React.FC< ); }, [newObjects]); - type MovieType = GQL.ScrapedMovie & { - name: string; - }; - function renderScrapedMovies( - scrapeResult: ScrapeResult, + scrapeResult: ScrapeResult, isNew?: boolean, - onChangeFn?: (value: string[]) => void + onChangeFn?: (value: GQL.ScrapedMovie[]) => void ) { const resultValue = isNew ? scrapeResult.newValue : scrapeResult.originalValue; const value = resultValue ?? []; + const selectValue = value.map((p) => { + const aliases: string[] = []; + return { + id: p.stored_id ?? "", + name: p.name ?? "", + aliases, + }; + }); + return ( { if (onChangeFn) { - onChangeFn(items.map((i) => i.id)); + // map the id back to stored_id + onChangeFn(items.map((p) => ({ ...p, stored_id: p.id }))); } }} - ids={value} + values={selectValue} /> ); } return ( - + title={title} result={result} renderObjects={renderScrapedMovies} onChange={onChange} newObjects={moviesCopy} onCreateNew={onCreateNew} + getName={(value) => value.name ?? ""} /> ); }; export const ScrapedTagsRow: React.FC< - IScrapedObjectRowImpl + IScrapedObjectRowImpl > = ({ title, result, onChange, newObjects, onCreateNew }) => { function renderScrapedTags( scrapeResult: ScrapeResult, @@ -278,13 +291,14 @@ export const ScrapedTagsRow: React.FC< } return ( - + title={title} result={result} renderObjects={renderScrapedTags} onChange={onChange} newObjects={newObjects} onCreateNew={onCreateNew} + getName={getObjectName} /> ); }; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts index faa378d11..009677e59 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts @@ -123,62 +123,41 @@ export function useCreateScrapedPerformer( return useCreateObject("performer", createNewPerformer); } -interface IUseCreateNewObjectIDListProps< - T extends { name?: string | undefined | null } -> { - scrapeResult: ScrapeResult; - setScrapeResult: (scrapeResult: ScrapeResult) => void; - newObjects: T[]; - setNewObjects: (newObject: T[]) => void; -} - -function useCreateNewObjectIDList< - T extends { name?: string | undefined | null } ->( - entityTypeID: string, - props: IUseCreateNewObjectIDListProps, - createObject: (toCreate: T) => Promise +export function useCreateScrapedMovie( + props: IUseCreateNewObjectProps ) { const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; + const [createMovie] = useMovieCreate(); - async function createNewObject(toCreate: T) { - const newID = await createObject(toCreate); + async function createNewMovie(toCreate: GQL.ScrapedMovie) { + const input = scrapedMovieToCreateInput(toCreate); - // add the new object to the new objects value - const newResult = scrapeResult.cloneWithValue(scrapeResult.newValue); - if (!newResult.newValue) { - newResult.newValue = []; - } - newResult.newValue.push(newID); - setScrapeResult(newResult); + const result = await createMovie({ + variables: { input: input }, + }); + + const newValue = [...(scrapeResult.newValue ?? [])]; + if (result.data?.movieCreate) + newValue.push({ + stored_id: result.data.movieCreate.id, + name: result.data.movieCreate.name, + }); + + // add the new object to the new object value + const resultClone = scrapeResult.cloneWithValue(newValue); + setScrapeResult(resultClone); // remove the object from the list const newObjectsClone = newObjects.concat(); const pIndex = newObjectsClone.findIndex((p) => p.name === toCreate.name); - if (pIndex === -1) throw new Error("Could not find object to remove"); + if (pIndex === -1) throw new Error("Could not find movie to remove"); + newObjectsClone.splice(pIndex, 1); setNewObjects(newObjectsClone); } - return useCreateObject(entityTypeID, createNewObject); -} - -export function useCreateScrapedMovie( - props: IUseCreateNewObjectIDListProps -) { - const [createMovie] = useMovieCreate(); - - async function createNewMovie(toCreate: GQL.ScrapedMovie) { - const movieInput = scrapedMovieToCreateInput(toCreate); - const result = await createMovie({ - variables: { input: movieInput }, - }); - - return result.data?.movieCreate?.id ?? ""; - } - - return useCreateNewObjectIDList("movie", props, createNewMovie); + return useCreateObject("movie", createNewMovie); } export function useCreateScrapedTag( diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 1b096c141..905147ddf 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -13,15 +13,9 @@ import Select, { import CreatableSelect from "react-select/creatable"; import * as GQL from "src/core/generated-graphql"; -import { - useAllMoviesForFilter, - useMarkerStrings, - useMovieCreate, -} from "src/core/StashService"; -import { useToast } from "src/hooks/Toast"; +import { useMarkerStrings } from "src/core/StashService"; import { SelectComponents } from "react-select/dist/declarations/src/components"; import { ConfigurationContext } from "src/hooks/Config"; -import { useIntl } from "react-intl"; import { objectTitle } from "src/core/files"; import { defaultMaxOptionsShown } from "src/core/config"; import { useDebounce } from "src/hooks/debounce"; @@ -32,6 +26,7 @@ import { faTableColumns } from "@fortawesome/free-solid-svg-icons"; import { TagIDSelect } from "../Tags/TagSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; import { GalleryIDSelect } from "../Galleries/GallerySelect"; +import { MovieIDSelect } from "../Movies/MovieSelect"; export type SelectObject = { id: string; @@ -89,23 +84,6 @@ interface ISelectProps { closeMenuOnSelect?: boolean; noOptionsMessage?: string | null; } -interface IFilterComponentProps extends IFilterProps { - items: SelectObject[]; - toOption?: (item: SelectObject) => Option; - onCreate?: (name: string) => Promise<{ item: SelectObject; message: string }>; -} -interface IFilterSelectProps - extends Pick< - ISelectProps, - | "isLoading" - | "isMulti" - | "components" - | "filterOption" - | "isValidNewOption" - | "placeholder" - | "closeMenuOnSelect" - > {} - type TitledObject = { id: string; title: string }; interface ITitledSelect { className?: string; @@ -125,9 +103,6 @@ const getSelectedItems = (selectedItems: OnChangeValue) => { } }; -const getSelectedValues = (selectedItems: OnChangeValue) => - getSelectedItems(selectedItems).map((item) => item.value); - const LimitedSelectMenu = ( props: MenuListProps> ) => { @@ -273,67 +248,6 @@ const SelectComponent = ({ ); }; -const FilterSelectComponent = ( - props: IFilterComponentProps & ITypeProps & IFilterSelectProps -) => { - const { items, ids, isMulti, onSelect } = props; - const [loading, setLoading] = useState(false); - const selectedIds = ids ?? []; - const Toast = useToast(); - - const options = items.map((i) => { - if (props.toOption) { - return props.toOption(i); - } - return { - value: i.id, - label: i.name ?? i.title ?? "", - }; - }); - - const selected = options.filter((option) => - selectedIds.includes(option.value) - ); - const selectedOptions = ( - isMulti ? selected : selected[0] ?? null - ) as OnChangeValue; - - const onChange = (selectedItems: OnChangeValue) => { - 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( - - {message}: {name} - - ); - } catch (e) { - Toast.error(e); - } - }; - - return ( - - {...props} - isLoading={props.isLoading || loading} - onChange={onChange} - items={options} - selectedOptions={selectedOptions} - onCreateOption={props.creatable ? onCreate : undefined} - /> - ); -}; - export const GallerySelect: React.FC< IFilterProps & { excludeIds?: string[] } > = (props) => { @@ -493,50 +407,7 @@ export const StudioSelect: React.FC< }; export const MovieSelect: React.FC = (props) => { - const { data, loading } = useAllMoviesForFilter(); - const [createMovie] = useMovieCreate(); - const items = data?.allMovies ?? []; - const intl = useIntl(); - - const { configuration } = React.useContext(ConfigurationContext); - const defaultCreatable = - !configuration?.interface.disableDropdownCreate.movie ?? true; - - const onCreate = async (name: string) => { - const result = await createMovie({ - variables: { input: { name } }, - }); - return { - item: result.data!.movieCreate!, - message: intl.formatMessage( - { id: "toast.created_entity" }, - { entity: intl.formatMessage({ id: "movie" }).toLocaleLowerCase() } - ), - }; - }; - - return ( - - ); + return ; }; export const TagSelect: React.FC< diff --git a/ui/v2.5/src/components/Studios/StudioSelect.tsx b/ui/v2.5/src/components/Studios/StudioSelect.tsx index 39ebccc70..e29c2711a 100644 --- a/ui/v2.5/src/components/Studios/StudioSelect.tsx +++ b/ui/v2.5/src/components/Studios/StudioSelect.tsx @@ -233,8 +233,7 @@ const _StudioIDSelect: React.FC> = ( } async function loadObjectsByID(idsToLoad: string[]): Promise { - const studioIDs = idsToLoad.map((id) => parseInt(id)); - const query = await queryFindStudiosByIDForSelect(studioIDs); + const query = await queryFindStudiosByIDForSelect(idsToLoad); const { studios: loadedStudios } = query.data.findStudios; return loadedStudios; diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index 82d30c54d..a81048e7a 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -251,8 +251,7 @@ const _TagIDSelect: React.FC> = (props) => { } async function loadObjectsByID(idsToLoad: string[]): Promise { - const tagIDs = idsToLoad.map((id) => parseInt(id)); - const query = await queryFindTagsByIDForSelect(tagIDs); + const query = await queryFindTagsByIDForSelect(idsToLoad); const { tags: loadedTags } = query.data.findTags; return loadedTags; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 00209f2e1..9dd7054d5 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -73,26 +73,6 @@ function evictTypeFields( } } -// Appends obj to the cached result of the given query. -// Use to append objects to "All*" queries in "Create" mutations. -function appendObject( - cache: ApolloCache, - obj: StoreObject, - query: DocumentNode -) { - const field = getQueryDefinition(query).selectionSet.selections[0]; - if (!isField(field)) return; - const keyName = field.name.value; - - cache.modify({ - fields: { - [keyName]: (value, { toReference }) => { - return [...(value as unknown[]), toReference(obj)]; - }, - }, - }); -} - // Deletes obj from the cache, and sets the // cached result of the given query to null. // Use with "Destroy" mutations. @@ -199,7 +179,22 @@ export const queryFindMovies = (filter: ListFilterModel) => }, }); -export const useAllMoviesForFilter = () => GQL.useAllMoviesForFilterQuery(); +export const queryFindMoviesByIDForSelect = (movieIDs: string[]) => + client.query({ + query: GQL.FindMoviesForSelectDocument, + variables: { + ids: movieIDs, + }, + }); + +export const queryFindMoviesForSelect = (filter: ListFilterModel) => + client.query({ + query: GQL.FindMoviesForSelectDocument, + variables: { + filter: filter.makeFindFilter(), + movie_filter: filter.makeFilter(), + }, + }); export const useFindSceneMarkers = (filter?: ListFilterModel) => GQL.useFindSceneMarkersQuery({ @@ -244,7 +239,16 @@ export const queryFindGalleries = (filter: ListFilterModel) => }, }); -export const queryFindGalleriesByIDForSelect = (galleryIDs: number[]) => +export const queryFindGalleriesForSelect = (filter: ListFilterModel) => + client.query({ + query: GQL.FindGalleriesForSelectDocument, + variables: { + filter: filter.makeFindFilter(), + gallery_filter: filter.makeFilter(), + }, + }); + +export const queryFindGalleriesByIDForSelect = (galleryIDs: string[]) => client.query({ query: GQL.FindGalleriesForSelectDocument, variables: { @@ -281,11 +285,11 @@ export const queryFindPerformers = (filter: ListFilterModel) => }, }); -export const queryFindPerformersByIDForSelect = (performerIDs: number[]) => +export const queryFindPerformersByIDForSelect = (performerIDs: string[]) => client.query({ query: GQL.FindPerformersForSelectDocument, variables: { - performer_ids: performerIDs, + ids: performerIDs, }, }); @@ -327,7 +331,7 @@ export const queryFindStudios = (filter: ListFilterModel) => }, }); -export const queryFindStudiosByIDForSelect = (studioIDs: number[]) => +export const queryFindStudiosByIDForSelect = (studioIDs: string[]) => client.query({ query: GQL.FindStudiosForSelectDocument, variables: { @@ -367,7 +371,7 @@ export const queryFindTags = (filter: ListFilterModel) => }, }); -export const queryFindTagsByIDForSelect = (tagIDs: number[]) => +export const queryFindTagsByIDForSelect = (tagIDs: string[]) => client.query({ query: GQL.FindTagsForSelectDocument, variables: { @@ -1072,6 +1076,7 @@ export const mutateImageSetPrimaryFile = (id: string, fileID: string) => }); const movieMutationImpactedTypeFields = { + Performer: ["movie_count"], Studio: ["movie_count"], }; @@ -1085,10 +1090,8 @@ export const useMovieCreate = () => const movie = result.data?.movieCreate; if (!movie) return; - appendObject(cache, movie, GQL.AllMoviesForFilterDocument); - // update stats - updateStats(cache, "studio_count", 1); + updateStats(cache, "movie_count", 1); evictTypeFields(cache, movieMutationImpactedTypeFields); evictQueries(cache, movieMutationImpactedQueries); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 8310c474f..bfddf0885 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1073,7 +1073,7 @@ "megabits_per_second": "{value} mbps", "metadata": "Metadata", "movie": "Movie", - "movie_scene_number": "Movie Scene Number", + "movie_scene_number": "Scene Number", "movies": "Movies", "name": "Name", "new": "New",