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
This commit is contained in:
WithoutPants
2024-02-19 10:25:08 +11:00
committed by GitHub
parent e7f610ce18
commit e231812203
27 changed files with 696 additions and 437 deletions

View File

@@ -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

View File

@@ -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,13 +47,52 @@ 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{
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
}
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"),
@@ -61,13 +101,9 @@ func (r *queryResolver) FindImages(ctx context.Context, imageFilter *models.Imag
Megapixels: sliceutil.Contains(fields, "megapixels"),
TotalSize: sliceutil.Contains(fields, "filesize"),
})
if err != nil {
return err
if err == nil {
images, err = result.Resolve(ctx)
}
images, err := result.Resolve(ctx)
if err != nil {
return err
}
ret = &FindImagesResultType{

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -4,3 +4,9 @@ fragment SlimMovieData on Movie {
front_image_path
rating100
}
fragment SelectMovieData on Movie {
id
name
front_image_path
}

View File

@@ -6,13 +6,6 @@ query MarkerStrings($q: String, $sort: String) {
}
}
query AllMoviesForFilter {
allMovies {
id
name
}
}
query Stats {
stats {
scene_count

View File

@@ -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
}
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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<IFilterProps & IFilterIDProps<Gallery>> = (
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Gallery[]> {
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;

View File

@@ -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<GQL.Movie, "id" | "name">;
type Option = SelectOption<Movie>;
const _MovieSelect: React.FC<
IFilterProps &
IFilterValueProps<Movie> & {
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<Option[]> {
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<Option, boolean>> = (optionProps) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
const title = object.name;
thisOptionProps = {
...optionProps,
children: <span>{title}</span>,
};
return <reactSelectComponents.Option {...thisOptionProps} />;
};
const MovieMultiValueLabel: React.FC<
MultiValueGenericProps<Option, boolean>
> = (optionProps) => {
let thisOptionProps = optionProps;
const { object } = optionProps.data;
thisOptionProps = {
...optionProps,
children: object.name,
};
return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;
};
const MovieValueLabel: 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 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 (
<FilterSelectComponent<Movie, boolean>
{...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<IFilterProps & IFilterIDProps<Movie>> = (
props
) => {
const { ids, onSelect: onSelectValues } = props;
const [values, setValues] = useState<Movie[]>([]);
const idsChanged = useCompare(ids);
function onSelect(items: Movie[]) {
setValues(items);
onSelectValues?.(items);
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Movie[]> {
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 <MovieSelect {...props} values={values} onSelect={onSelect} />;
};
export const MovieIDSelect = PatchComponent("MovieIDSelect", _MovieIDSelect);

View File

@@ -256,8 +256,7 @@ const _PerformerIDSelect: React.FC<IFilterProps & IFilterIDProps<Performer>> = (
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Performer[]> {
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;

View File

@@ -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<IProps> = ({
const [galleries, setGalleries] = useState<Gallery[]>([]);
const [performers, setPerformers] = useState<Performer[]>([]);
const [movies, setMovies] = useState<Movie[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [studio, setStudio] = useState<Studio | null>(null);
@@ -104,6 +105,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
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<IProps> = ({
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<IProps> = ({
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<IProps> = ({
setIsLoading(false);
}
function renderTableMovies() {
return (
<SceneMovieTable
movieScenes={formik.values.movies}
onUpdate={(items) => {
formik.setFieldValue("movies", items);
}}
/>
);
}
const encodingImage = ImageUtils.usePasteImage(onImageLoad);
function onImageLoad(imageData: string) {
@@ -400,6 +407,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
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<IProps> = ({
});
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<IProps> = ({
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 = (
<>
<MovieSelect
isMulti
onSelect={(items) => setMovieIds(items.map((item) => item.id))}
ids={formik.values.movies.map((m) => m.movie_id)}
/>
{renderTableMovies()}
</>
<SceneMovieTable value={movieEntries} onUpdate={onSetMovieEntries} />
);
return renderField("movies", title, control, fullWidthProps);

View File

@@ -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<string, number | undefined>;
export interface IMovieEntry {
movie: Movie;
scene_index?: GQL.InputMaybe<number> | undefined;
}
export interface IProps {
movieScenes: GQL.SceneMovieInput[];
onUpdate: (value: GQL.SceneMovieInput[]) => void;
value: IMovieEntry[];
onUpdate: (input: IMovieEntry[]) => void;
}
export const SceneMovieTable: React.FC<IProps> = (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) => {
const updateFieldChanged = (index: number, sceneIndex: number | null) => {
const newValues = value.map((existing, i) => {
if (i === index) {
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) {
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) => (
<Row key={m.movie_id}>
<Form.Label column xs={9}>
{m.movie?.name ?? ""}
</Form.Label>
{value.map((m, i) => (
<Row key={m.movie.id} className="movie-row">
<Col xs={9}>
<MovieSelect
onSelect={(items) => onMovieSet(i, items)}
values={[m.movie!]}
excludeIds={movieIDs}
/>
</Col>
<Col xs={3}>
<Form.Control
className="text-input"
@@ -52,36 +98,38 @@ export const SceneMovieTable: React.FC<IProps> = (props) => {
value={m.scene_index ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
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)
);
}}
/>
</Col>
</Row>
))}
<Row className="movie-row">
<Col xs={12}>
<MovieSelect
onSelect={(items) => onNewMovieSet(items)}
values={[]}
excludeIds={movieIDs}
/>
</Col>
</Row>
</>
);
}
if (props.movieScenes.length > 0) {
return (
<div className="movie-table">
<Row>
<Form.Label column xs={9}>
{intl.formatMessage({ id: "movie" })}
</Form.Label>
<Form.Label column xs={3}>
<div className={cx("movie-table", { "no-movies": !value.length })}>
<Row className="movie-table-header">
<Col xs={9}></Col>
<Form.Label column xs={3} className="movie-scene-number-header">
{intl.formatMessage({ id: "movie_scene_number" })}
</Form.Label>
</Row>
{renderTableData()}
</div>
);
}
return <></>;
};

View File

@@ -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<GQL.SceneUpdateInput>;
sceneStudio: Studio | null;
scenePerformers: Performer[];
sceneTags: Tag[];
sceneMovies: Movie[];
scraped: GQL.ScrapedScene;
endpoint?: string;
@@ -48,6 +49,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
sceneStudio,
scenePerformers,
sceneTags,
sceneMovies,
scraped,
onClose,
endpoint,
@@ -96,44 +98,6 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
)
);
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<GQL.ScrapedPerformer>
>(
@@ -151,10 +115,17 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
scraped.performers?.filter((t) => !t.stored_id) ?? []
);
const [movies, setMovies] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(
sortIdList(scene.movies?.map((p) => p.movie_id)),
mapStoredIdObjects(scraped.movies ?? undefined)
const [movies, setMovies] = useState<
ObjectListScrapeResult<GQL.ScrapedMovie>
>(
new ObjectListScrapeResult<GQL.ScrapedMovie>(
sortStoredIdObjects(
sceneMovies.map((p) => ({
stored_id: p.id,
name: p.name,
}))
),
sortStoredIdObjects(scraped.movies ?? undefined)
)
);
const [newMovies, setNewMovies] = useState<GQL.ScrapedMovie[]>(
@@ -249,12 +220,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
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(),

View File

@@ -93,6 +93,13 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
};
}
function movieToStoredID(o: { movie: { id: string; name: string } }) {
return {
stored_id: o.movie.id,
name: o.movie.name,
};
}
const [studio, setStudio] = useState<ScrapeResult<GQL.ScrapedStudio>>(
new ScrapeResult<GQL.ScrapedStudio>(
dest.studio ? idToStoredID(dest.studio) : undefined
@@ -127,8 +134,12 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
)
);
const [movies, setMovies] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(sortIdList(dest.movies.map((p) => p.movie.id)))
const [movies, setMovies] = useState<
ObjectListScrapeResult<GQL.ScrapedMovie>
>(
new ObjectListScrapeResult<GQL.ScrapedMovie>(
sortStoredIdObjects(dest.movies.map(movieToStoredID))
)
);
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
@@ -235,9 +246,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
);
setMovies(
new ScrapeResult(
dest.movies.map((m) => m.movie.id),
uniq(all.map((s) => s.movies.map((m) => m.movie.id)).flat())
new ObjectListScrapeResult<GQL.ScrapedMovie>(
sortStoredIdObjects(dest.movies.map(movieToStoredID)),
uniqIDStoredIDs(all.map((s) => s.movies.map(movieToStoredID)).flat())
)
);
@@ -571,9 +582,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
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,
};
}),

View File

@@ -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 {

View File

@@ -25,16 +25,11 @@ import { StringListInput } from "../StringListInput";
import { ImageSelector } from "../ImageSelector";
import { ScrapeResult } from "./scrapeResult";
export interface IHasName {
name: string | undefined;
}
interface IScrapedFieldProps<T> {
result: ScrapeResult<T>;
}
interface IScrapedRowProps<T, V extends IHasName>
extends IScrapedFieldProps<T> {
interface IScrapedRowProps<T, V> extends IScrapedFieldProps<T> {
className?: string;
title: string;
renderOriginalField: (result: ScrapeResult<T>) => JSX.Element | undefined;
@@ -42,6 +37,7 @@ interface IScrapedRowProps<T, V extends IHasName>
onChange: (value: ScrapeResult<T>) => 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 = <T, V extends IHasName>(
props: IScrapedRowProps<T, V>
) => {
export const ScrapeDialogRow = <T, V>(props: IScrapedRowProps<T, V>) => {
const { getName = () => "" } = props;
function handleSelectClick(isNew: boolean) {
const ret = clone(props.result);
ret.useNewValue = isNew;
@@ -83,10 +79,10 @@ export const ScrapeDialogRow = <T, V extends IHasName>(
<Badge
className="tag-item"
variant="secondary"
key={t.name}
key={getName(t)}
onClick={() => props.onCreateNew!(i)}
>
{t.name}
{getName(t)}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon={faPlus} />
</Button>
@@ -173,6 +169,10 @@ const ScrapedInputGroup: React.FC<IScrapedInputGroupProps> = (props) => {
);
};
function getNameString(value: string) {
return value;
}
interface IScrapedInputGroupRowProps {
title: string;
placeholder?: string;
@@ -206,6 +206,7 @@ export const ScrapedInputGroupRow: React.FC<IScrapedInputGroupRowProps> = (
/>
)}
onChange={props.onChange}
getName={getNameString}
/>
);
};
@@ -271,6 +272,7 @@ export const ScrapedStringListRow: React.FC<IScrapedStringListRowProps> = (
/>
)}
onChange={props.onChange}
getName={getNameString}
/>
);
};
@@ -316,6 +318,7 @@ export const ScrapedTextAreaRow: React.FC<IScrapedInputGroupRowProps> = (
/>
)}
onChange={props.onChange}
getName={getNameString}
/>
);
};
@@ -369,6 +372,7 @@ export const ScrapedImageRow: React.FC<IScrapedImageRowProps> = (props) => {
/>
)}
onChange={props.onChange}
getName={getNameString}
/>
);
};
@@ -412,6 +416,7 @@ export const ScrapedImagesRow: React.FC<IScrapedImagesRowProps> = (props) => {
</div>
)}
onChange={props.onChange}
getName={getNameString}
/>
);
};
@@ -514,5 +519,6 @@ export const ScrapedCountryRow: React.FC<IScrapedCountryRowProps> = ({
/>
)}
onChange={onChange}
getName={getNameString}
/>
);

View File

@@ -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<T extends { name: string }>(value: T) {
return value.name;
}
export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
title,
result,
@@ -76,28 +77,35 @@ export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
onCreateNew={() => {
if (onCreateNew && newStudio) onCreateNew(newStudio);
}}
getName={getObjectName}
/>
);
};
interface IScrapedObjectsRow<T, R> {
interface IScrapedObjectsRow<T> {
title: string;
result: ScrapeResult<R[]>;
onChange: (value: ScrapeResult<R[]>) => void;
result: ScrapeResult<T[]>;
onChange: (value: ScrapeResult<T[]>) => void;
newObjects?: T[];
onCreateNew?: (value: T) => void;
renderObjects: (
result: ScrapeResult<R[]>,
result: ScrapeResult<T[]>,
isNew?: boolean,
onChange?: (value: R[]) => void
onChange?: (value: T[]) => void
) => JSX.Element;
getName: (value: T) => string;
}
export const ScrapedObjectsRow = <T extends IHasName, R>(
props: IScrapedObjectsRow<T, R>
) => {
const { title, result, onChange, newObjects, onCreateNew, renderObjects } =
props;
export const ScrapedObjectsRow = <T,>(props: IScrapedObjectsRow<T>) => {
const {
title,
result,
onChange,
newObjects,
onCreateNew,
renderObjects,
getName,
} = props;
return (
<ScrapeDialogRow
@@ -114,17 +122,18 @@ export const ScrapedObjectsRow = <T extends IHasName, R>(
onCreateNew={(i) => {
if (onCreateNew) onCreateNew(newObjects![i]);
}}
getName={getName}
/>
);
};
type IScrapedObjectRowImpl<T, R> = Omit<
IScrapedObjectsRow<T, R>,
"renderObjects"
type IScrapedObjectRowImpl<T> = Omit<
IScrapedObjectsRow<T>,
"renderObjects" | "getName"
>;
export const ScrapedPerformersRow: React.FC<
IScrapedObjectRowImpl<GQL.ScrapedPerformer, GQL.ScrapedPerformer>
IScrapedObjectRowImpl<GQL.ScrapedPerformer>
> = ({ 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 (
<ScrapedObjectsRow<PerformerType, GQL.ScrapedPerformer>
<ScrapedObjectsRow<GQL.ScrapedPerformer>
title={title}
result={result}
renderObjects={renderScrapedPerformers}
onChange={onChange}
newObjects={performersCopy}
onCreateNew={onCreateNew}
getName={(value) => value.name ?? ""}
/>
);
};
export const ScrapedMoviesRow: React.FC<
IScrapedObjectRowImpl<GQL.ScrapedMovie, string>
IScrapedObjectRowImpl<GQL.ScrapedMovie>
> = ({ 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<string[]>,
scrapeResult: ScrapeResult<GQL.ScrapedMovie[]>,
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 (
<MovieSelect
isMulti
@@ -219,28 +230,30 @@ export const ScrapedMoviesRow: React.FC<
isDisabled={!isNew}
onSelect={(items) => {
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 (
<ScrapedObjectsRow<MovieType, string>
<ScrapedObjectsRow<GQL.ScrapedMovie>
title={title}
result={result}
renderObjects={renderScrapedMovies}
onChange={onChange}
newObjects={moviesCopy}
onCreateNew={onCreateNew}
getName={(value) => value.name ?? ""}
/>
);
};
export const ScrapedTagsRow: React.FC<
IScrapedObjectRowImpl<GQL.ScrapedTag, GQL.ScrapedTag>
IScrapedObjectRowImpl<GQL.ScrapedTag>
> = ({ title, result, onChange, newObjects, onCreateNew }) => {
function renderScrapedTags(
scrapeResult: ScrapeResult<GQL.ScrapedTag[]>,
@@ -278,13 +291,14 @@ export const ScrapedTagsRow: React.FC<
}
return (
<ScrapedObjectsRow<GQL.ScrapedTag, GQL.ScrapedTag>
<ScrapedObjectsRow<GQL.ScrapedTag>
title={title}
result={result}
renderObjects={renderScrapedTags}
onChange={onChange}
newObjects={newObjects}
onCreateNew={onCreateNew}
getName={getObjectName}
/>
);
};

View File

@@ -123,62 +123,41 @@ export function useCreateScrapedPerformer(
return useCreateObject("performer", createNewPerformer);
}
interface IUseCreateNewObjectIDListProps<
T extends { name?: string | undefined | null }
> {
scrapeResult: ScrapeResult<string[]>;
setScrapeResult: (scrapeResult: ScrapeResult<string[]>) => void;
newObjects: T[];
setNewObjects: (newObject: T[]) => void;
}
function useCreateNewObjectIDList<
T extends { name?: string | undefined | null }
>(
entityTypeID: string,
props: IUseCreateNewObjectIDListProps<T>,
createObject: (toCreate: T) => Promise<string>
export function useCreateScrapedMovie(
props: IUseCreateNewObjectProps<GQL.ScrapedMovie>
) {
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<GQL.ScrapedMovie>
) {
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(

View File

@@ -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<T extends boolean> {
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<T extends boolean>
extends Pick<
ISelectProps<T>,
| "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<Option, boolean>) => {
}
};
const getSelectedValues = (selectedItems: OnChangeValue<Option, boolean>) =>
getSelectedItems(selectedItems).map((item) => item.value);
const LimitedSelectMenu = <T extends boolean>(
props: MenuListProps<Option, T, GroupBase<Option>>
) => {
@@ -273,67 +248,6 @@ const SelectComponent = <T extends boolean>({
);
};
const FilterSelectComponent = <T extends boolean>(
props: IFilterComponentProps & ITypeProps & IFilterSelectProps<T>
) => {
const { items, ids, isMulti, onSelect } = props;
const [loading, setLoading] = useState(false);
const selectedIds = ids ?? [];
const Toast = useToast();
const options = items.map((i) => {
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<Option, T>;
const onChange = (selectedItems: OnChangeValue<Option, boolean>) => {
const selectedValues = getSelectedValues(selectedItems);
onSelect?.(items.filter((item) => selectedValues.includes(item.id)));
};
const onCreate = async (name: string) => {
try {
setLoading(true);
const { item: newItem, message } = await props.onCreate!(name);
props.onSelect?.([
...items.filter((item) => selectedIds.includes(item.id)),
newItem,
]);
setLoading(false);
Toast.success(
<span>
{message}: <b>{name}</b>
</span>
);
} catch (e) {
Toast.error(e);
}
};
return (
<SelectComponent<T>
{...props}
isLoading={props.isLoading || loading}
onChange={onChange}
items={options}
selectedOptions={selectedOptions}
onCreateOption={props.creatable ? onCreate : undefined}
/>
);
};
export const GallerySelect: React.FC<
IFilterProps & { excludeIds?: string[] }
> = (props) => {
@@ -493,50 +407,7 @@ export const StudioSelect: React.FC<
};
export const MovieSelect: React.FC<IFilterProps> = (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 (
<FilterSelectComponent
{...props}
isMulti={props.isMulti ?? false}
type="movies"
isLoading={loading}
items={items}
placeholder={
props.noSelectionString ??
intl.formatMessage(
{ id: "actions.select_entity" },
{
entityType: intl.formatMessage({
id: props.isMulti ? "movies" : "movie",
}),
}
)
}
creatable={props.creatable ?? defaultCreatable}
onCreate={onCreate}
/>
);
return <MovieIDSelect {...props} />;
};
export const TagSelect: React.FC<

View File

@@ -233,8 +233,7 @@ const _StudioIDSelect: React.FC<IFilterProps & IFilterIDProps<Studio>> = (
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Studio[]> {
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;

View File

@@ -251,8 +251,7 @@ const _TagIDSelect: React.FC<IFilterProps & IFilterIDProps<Tag>> = (props) => {
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Tag[]> {
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;

View File

@@ -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<unknown>,
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<GQL.FindMoviesForSelectQuery>({
query: GQL.FindMoviesForSelectDocument,
variables: {
ids: movieIDs,
},
});
export const queryFindMoviesForSelect = (filter: ListFilterModel) =>
client.query<GQL.FindMoviesForSelectQuery>({
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<GQL.FindGalleriesForSelectQuery>({
query: GQL.FindGalleriesForSelectDocument,
variables: {
filter: filter.makeFindFilter(),
gallery_filter: filter.makeFilter(),
},
});
export const queryFindGalleriesByIDForSelect = (galleryIDs: string[]) =>
client.query<GQL.FindGalleriesForSelectQuery>({
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<GQL.FindPerformersForSelectQuery>({
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<GQL.FindStudiosForSelectQuery>({
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<GQL.FindTagsForSelectQuery>({
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);

View File

@@ -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",