diff --git a/graphql/documents/mutations/movie.graphql b/graphql/documents/mutations/movie.graphql index 527b7fddb..e1236b9dd 100644 --- a/graphql/documents/mutations/movie.graphql +++ b/graphql/documents/mutations/movie.graphql @@ -24,4 +24,8 @@ mutation MovieUpdate($input: MovieUpdateInput!) { mutation MovieDestroy($id: ID!) { movieDestroy(input: { id: $id }) -} \ No newline at end of file +} + +mutation MoviesDestroy($ids: [ID!]!) { + moviesDestroy(ids: $ids) +} diff --git a/graphql/documents/mutations/performer.graphql b/graphql/documents/mutations/performer.graphql index 9ea5a4eff..0f29c12b4 100644 --- a/graphql/documents/mutations/performer.graphql +++ b/graphql/documents/mutations/performer.graphql @@ -55,3 +55,7 @@ mutation PerformerUpdate( mutation PerformerDestroy($id: ID!) { performerDestroy(input: { id: $id }) } + +mutation PerformersDestroy($ids: [ID!]!) { + performersDestroy(ids: $ids) +} diff --git a/graphql/documents/mutations/studio.graphql b/graphql/documents/mutations/studio.graphql index 71015a3ac..d2d11d222 100644 --- a/graphql/documents/mutations/studio.graphql +++ b/graphql/documents/mutations/studio.graphql @@ -21,3 +21,7 @@ mutation StudioUpdate( mutation StudioDestroy($id: ID!) { studioDestroy(input: { id: $id }) } + +mutation StudiosDestroy($ids: [ID!]!) { + studiosDestroy(ids: $ids) +} diff --git a/graphql/documents/mutations/tag.graphql b/graphql/documents/mutations/tag.graphql index 91ad0136b..6e2cd0b99 100644 --- a/graphql/documents/mutations/tag.graphql +++ b/graphql/documents/mutations/tag.graphql @@ -8,8 +8,12 @@ mutation TagDestroy($id: ID!) { tagDestroy(input: { id: $id }) } +mutation TagsDestroy($ids: [ID!]!) { + tagsDestroy(ids: $ids) +} + mutation TagUpdate($input: TagUpdateInput!) { tagUpdate(input: $input) { ...TagData } -} \ No newline at end of file +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 7ae7a95aa..d69d4f77c 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -175,18 +175,22 @@ type Mutation { performerCreate(input: PerformerCreateInput!): Performer performerUpdate(input: PerformerUpdateInput!): Performer performerDestroy(input: PerformerDestroyInput!): Boolean! + performersDestroy(ids: [ID!]!): Boolean! studioCreate(input: StudioCreateInput!): Studio studioUpdate(input: StudioUpdateInput!): Studio studioDestroy(input: StudioDestroyInput!): Boolean! + studiosDestroy(ids: [ID!]!): Boolean! movieCreate(input: MovieCreateInput!): Movie movieUpdate(input: MovieUpdateInput!): Movie movieDestroy(input: MovieDestroyInput!): Boolean! + moviesDestroy(ids: [ID!]!): Boolean! tagCreate(input: TagCreateInput!): Tag tagUpdate(input: TagUpdateInput!): Tag tagDestroy(input: TagDestroyInput!): Boolean! + tagsDestroy(ids: [ID!]!): Boolean! """Change general configuration options""" configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult! diff --git a/pkg/api/resolver_mutation_movie.go b/pkg/api/resolver_mutation_movie.go index 8af2a7126..23756054e 100644 --- a/pkg/api/resolver_mutation_movie.go +++ b/pkg/api/resolver_mutation_movie.go @@ -222,3 +222,18 @@ func (r *mutationResolver) MovieDestroy(ctx context.Context, input models.MovieD } return true, nil } + +func (r *mutationResolver) MoviesDestroy(ctx context.Context, ids []string) (bool, error) { + qb := models.NewMovieQueryBuilder() + tx := database.DB.MustBeginTx(ctx, nil) + for _, id := range ids { + if err := qb.Destroy(id, tx); err != nil { + _ = tx.Rollback() + return false, err + } + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} diff --git a/pkg/api/resolver_mutation_performer.go b/pkg/api/resolver_mutation_performer.go index bf79f6ae6..d249891cf 100644 --- a/pkg/api/resolver_mutation_performer.go +++ b/pkg/api/resolver_mutation_performer.go @@ -242,3 +242,18 @@ func (r *mutationResolver) PerformerDestroy(ctx context.Context, input models.Pe } return true, nil } + +func (r *mutationResolver) PerformersDestroy(ctx context.Context, ids []string) (bool, error) { + qb := models.NewPerformerQueryBuilder() + tx := database.DB.MustBeginTx(ctx, nil) + for _, id := range ids { + if err := qb.Destroy(id, tx); err != nil { + _ = tx.Rollback() + return false, err + } + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} diff --git a/pkg/api/resolver_mutation_studio.go b/pkg/api/resolver_mutation_studio.go index 163f3f5ec..c685d3155 100644 --- a/pkg/api/resolver_mutation_studio.go +++ b/pkg/api/resolver_mutation_studio.go @@ -182,3 +182,18 @@ func (r *mutationResolver) StudioDestroy(ctx context.Context, input models.Studi } return true, nil } + +func (r *mutationResolver) StudiosDestroy(ctx context.Context, ids []string) (bool, error) { + qb := models.NewStudioQueryBuilder() + tx := database.DB.MustBeginTx(ctx, nil) + for _, id := range ids { + if err := qb.Destroy(id, tx); err != nil { + _ = tx.Rollback() + return false, err + } + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} diff --git a/pkg/api/resolver_mutation_tag.go b/pkg/api/resolver_mutation_tag.go index 96d0b4982..28fe22de6 100644 --- a/pkg/api/resolver_mutation_tag.go +++ b/pkg/api/resolver_mutation_tag.go @@ -152,3 +152,19 @@ func (r *mutationResolver) TagDestroy(ctx context.Context, input models.TagDestr } return true, nil } + +func (r *mutationResolver) TagsDestroy(ctx context.Context, ids []string) (bool, error) { + qb := models.NewTagQueryBuilder() + tx := database.DB.MustBeginTx(ctx, nil) + + for _, id := range ids { + if err := qb.Destroy(id, tx); err != nil { + _ = tx.Rollback() + return false, err + } + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} diff --git a/ui/v2.5/src/components/Changelog/versions/v050.md b/ui/v2.5/src/components/Changelog/versions/v050.md index 8e2e70076..f9f0d911e 100644 --- a/ui/v2.5/src/components/Changelog/versions/v050.md +++ b/ui/v2.5/src/components/Changelog/versions/v050.md @@ -6,6 +6,7 @@ * Allow configuration of visible navbar items. ### 🎨 Improvements +* Add batch deleting of performers, tags, studios, and movies. * Reset cache after scan/clean to ensure scenes are updated. * Add more video/image resolution tags. * Add option to strip file extension from scene title when populating from scanning task. diff --git a/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx b/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx index 3c3615018..7b37eeafc 100644 --- a/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx +++ b/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx @@ -76,11 +76,13 @@ export const DeleteImagesDialog: React.FC = (

setDeleteFile(!deleteFile)} /> setDeleteGenerated(!deleteGenerated)} diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 28dbe10a4..4e59388cc 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -426,29 +426,25 @@ export const ListFilter: React.FC = ( } function maybeRenderSelectedButtons() { - if (props.itemsSelected) { + if (props.itemsSelected && (props.onEdit || props.onDelete)) { return ( - <> - {props.onEdit ? ( - - Edit}> - - - - ) : undefined} + + {props.onEdit && ( + Edit}> + + + )} - {props.onDelete ? ( - - Delete}> - - - - ) : undefined} - + {props.onDelete && ( + Delete}> + + + )} + ); } } @@ -456,8 +452,8 @@ export const ListFilter: React.FC = ( function render() { return ( <> - -
+ +
= ( ))} - - {maybeRenderSelectedButtons()} - {renderMore()} - + {maybeRenderSelectedButtons()} - - {renderDisplayModeOptions()} - +
{renderMore()}
+ + {renderDisplayModeOptions()} {maybeRenderZoom()} -
+
{renderFilterTags()}
diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx index baa2dcc29..2e7a52a55 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -1,14 +1,17 @@ import React, { useState } from "react"; import _ from "lodash"; import Mousetrap from "mousetrap"; -import { FindMoviesQueryResult } from "src/core/generated-graphql"; +import { useHistory } from "react-router-dom"; +import { + FindMoviesQueryResult, + SlimMovieDataFragment, +} from "src/core/generated-graphql"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { queryFindMovies } from "src/core/StashService"; +import { queryFindMovies, useMoviesDestroy } from "src/core/StashService"; import { showWhenSelected, useMoviesList } from "src/hooks/ListHook"; -import { useHistory } from "react-router-dom"; +import { ExportDialog, DeleteEntityDialog } from "src/components/Shared"; import { MovieCard } from "./MovieCard"; -import { ExportDialog } from "../Shared/ExportDialog"; export const MovieList: React.FC = () => { const history = useHistory(); @@ -44,12 +47,26 @@ export const MovieList: React.FC = () => { }; }; + const renderDeleteDialog = ( + selectedMovies: SlimMovieDataFragment[], + onClose: (confirmed: boolean) => void + ) => ( + + ); + const listData = useMoviesList({ renderContent, addKeybinds, otherOperations, selectable: true, persistState: true, + renderDeleteDialog, }); async function viewRandom( diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 6614288a3..b7300a718 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -2,13 +2,19 @@ import _ from "lodash"; import React, { useState } from "react"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; -import { FindPerformersQueryResult } from "src/core/generated-graphql"; -import { queryFindPerformers } from "src/core/StashService"; +import { + FindPerformersQueryResult, + SlimPerformerDataFragment, +} from "src/core/generated-graphql"; +import { + queryFindPerformers, + usePerformersDestroy, +} from "src/core/StashService"; import { usePerformersList } from "src/hooks"; import { showWhenSelected } from "src/hooks/ListHook"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { ExportDialog } from "src/components/Shared/ExportDialog"; +import { ExportDialog, DeleteEntityDialog } from "src/components/Shared"; import { PerformerCard } from "./PerformerCard"; import { PerformerListTable } from "./PerformerListTable"; @@ -76,12 +82,26 @@ export const PerformerList: React.FC = () => { } } + const renderDeleteDialog = ( + selectedPerformers: SlimPerformerDataFragment[], + onClose: (confirmed: boolean) => void + ) => ( + + ); + const listData = usePerformersList({ otherOperations, renderContent, addKeybinds, selectable: true, persistState: true, + renderDeleteDialog, }); async function getRandom( diff --git a/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx b/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx index bbf8d269f..6356f5669 100644 --- a/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx @@ -76,11 +76,13 @@ export const DeleteScenesDialog: React.FC = (

setDeleteFile(!deleteFile)} /> setDeleteGenerated(!deleteGenerated)} diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index ce67bb3f3..4f2df0914 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -68,13 +68,18 @@ export const SceneList: React.FC = ({ }; }; + const renderDeleteDialog = ( + selectedScenes: SlimSceneDataFragment[], + onClose: (confirmed: boolean) => void + ) => ; + const listData = useScenesList({ zoomable: true, selectable: true, otherOperations, renderContent, renderEditDialog: renderEditScenesDialog, - renderDeleteDialog: renderDeleteScenesDialog, + renderDeleteDialog, filterHook, addKeybinds, persistState, @@ -166,17 +171,6 @@ export const SceneList: React.FC = ({ ); } - function renderDeleteScenesDialog( - selectedScenes: SlimSceneDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - <> - - - ); - } - function renderSceneCard( scene: SlimSceneDataFragment, selectedIds: Set, diff --git a/ui/v2.5/src/components/Shared/DeleteEntityDialog.tsx b/ui/v2.5/src/components/Shared/DeleteEntityDialog.tsx new file mode 100644 index 000000000..87bf6d3ab --- /dev/null +++ b/ui/v2.5/src/components/Shared/DeleteEntityDialog.tsx @@ -0,0 +1,123 @@ +import React, { useState } from "react"; +import { defineMessages, FormattedMessage, useIntl } from "react-intl"; +import { FetchResult } from "@apollo/client"; + +import { Modal } from "src/components/Shared"; +import { useToast } from "src/hooks"; + +interface IDeletionEntity { + id: string; + name?: string | null; +} + +type DestroyMutation = (input: { + ids: string[]; +}) => [() => Promise, {}]; + +interface IDeleteEntityDialogProps { + selected: IDeletionEntity[]; + onClose: (confirmed: boolean) => void; + singularEntity: string; + pluralEntity: string; + destroyMutation: DestroyMutation; +} + +const messages = defineMessages({ + deleteHeader: { + id: "delete-header", + defaultMessage: + "Delete {count, plural, =1 {{singularEntity}} other {{pluralEntity}}}", + }, + deleteToast: { + id: "delete-toast", + defaultMessage: + "Deleted {count, plural, =1 {{singularEntity}} other {{pluralEntity}}}", + }, + deleteMessage: { + id: "delete-message", + defaultMessage: + "Are you sure you want to delete {count, plural, =1 {this {singularEntity}} other {these {pluralEntity}}}?", + }, + overflowMessage: { + id: "overflow-message", + defaultMessage: + "...and {count} other {count, plural, =1 {{ singularEntity}} other {{ pluralEntity }}}.", + }, +}); + +const DeleteEntityDialog: React.FC = ({ + selected, + onClose, + singularEntity, + pluralEntity, + destroyMutation, +}) => { + const intl = useIntl(); + const Toast = useToast(); + const [deleteEntities] = destroyMutation({ ids: selected.map((p) => p.id) }); + const count = selected.length; + + // Network state + const [isDeleting, setIsDeleting] = useState(false); + + async function onDelete() { + setIsDeleting(true); + try { + await deleteEntities(); + Toast.success({ + content: intl.formatMessage(messages.deleteToast, { + count, + singularEntity, + pluralEntity, + }), + }); + } catch (e) { + Toast.error(e); + } + setIsDeleting(false); + onClose(true); + } + + return ( + onClose(false), + text: "Cancel", + variant: "secondary", + }} + isRunning={isDeleting} + > +

+ +

+
    + {selected.slice(0, 10).map((s) => ( +
  • {s.name}
  • + ))} + {selected.length > 10 && ( + + )} +
+
+ ); +}; + +export default DeleteEntityDialog; diff --git a/ui/v2.5/src/components/Shared/index.ts b/ui/v2.5/src/components/Shared/index.ts index 6395cf0ae..fd5b04863 100644 --- a/ui/v2.5/src/components/Shared/index.ts +++ b/ui/v2.5/src/components/Shared/index.ts @@ -24,3 +24,5 @@ export { default as ErrorMessage } from "./ErrorMessage"; export { default as TruncatedText } from "./TruncatedText"; export { BasicCard } from "./BasicCard"; export { RatingStars } from "./RatingStars"; +export { ExportDialog } from "./ExportDialog"; +export { default as DeleteEntityDialog } from "./DeleteEntityDialog"; diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index abae73fd5..6156046d2 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -2,13 +2,16 @@ import React, { useState } from "react"; import _ from "lodash"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; -import { FindStudiosQueryResult } from "src/core/generated-graphql"; +import { + FindStudiosQueryResult, + SlimStudioDataFragment, +} from "src/core/generated-graphql"; import { useStudiosList } from "src/hooks"; import { showWhenSelected } from "src/hooks/ListHook"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { queryFindStudios } from "src/core/StashService"; -import { ExportDialog } from "../Shared/ExportDialog"; +import { queryFindStudios, useStudiosDestroy } from "src/core/StashService"; +import { ExportDialog, DeleteEntityDialog } from "src/components/Shared"; import { StudioCard } from "./StudioCard"; interface IStudioList { @@ -109,6 +112,19 @@ export const StudioList: React.FC = ({ } } + const renderDeleteDialog = ( + selectedStudios: SlimStudioDataFragment[], + onClose: (confirmed: boolean) => void + ) => ( + + ); + const listData = useStudiosList({ renderContent, filterHook, @@ -116,6 +132,7 @@ export const StudioList: React.FC = ({ otherOperations, selectable: true, persistState: !fromParent, + renderDeleteDialog, }); function renderStudios( diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index bba42d3d2..bb03f3a2f 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -12,11 +12,12 @@ import { queryFindTags, mutateMetadataAutoTag, useTagDestroy, + useTagsDestroy, } from "src/core/StashService"; import { useToast } from "src/hooks"; import { FormattedNumber } from "react-intl"; import { NavUtils } from "src/utils"; -import { Icon, Modal } from "src/components/Shared"; +import { Icon, Modal, DeleteEntityDialog } from "src/components/Shared"; import { TagCard } from "./TagCard"; import { ExportDialog } from "../Shared/ExportDialog"; @@ -121,6 +122,19 @@ export const TagList: React.FC = ({ filterHook }) => { } } + const renderDeleteDialog = ( + selectedTags: GQL.TagDataFragment[], + onClose: (confirmed: boolean) => void + ) => ( + + ); + const listData = useTagsList({ renderContent, filterHook, @@ -130,6 +144,7 @@ export const TagList: React.FC = ({ filterHook }) => { zoomable: true, defaultZoomIndex: 0, persistState: true, + renderDeleteDialog, }); function getDeleteTagInput() { diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 8d0203e94..581108095 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -309,6 +309,18 @@ export const usePerformerDestroy = () => update: deleteCache(performerMutationImpactedQueries), }); +export const usePerformersDestroy = ( + variables: GQL.PerformersDestroyMutationVariables +) => + GQL.usePerformersDestroyMutation({ + variables, + refetchQueries: getQueryNames([ + GQL.FindPerformersDocument, + GQL.AllPerformersForFilterDocument, + ]), + update: deleteCache(performerMutationImpactedQueries), + }); + const sceneMutationImpactedQueries = [ GQL.FindPerformerDocument, GQL.FindPerformersDocument, @@ -562,6 +574,12 @@ export const useStudioDestroy = (input: GQL.StudioDestroyInput) => update: deleteCache(studioMutationImpactedQueries), }); +export const useStudiosDestroy = (input: GQL.StudiosDestroyMutationVariables) => + GQL.useStudiosDestroyMutation({ + variables: input, + update: deleteCache(studioMutationImpactedQueries), + }); + export const movieMutationImpactedQueries = [ GQL.FindSceneDocument, GQL.FindScenesDocument, @@ -589,6 +607,12 @@ export const useMovieDestroy = (input: GQL.MovieDestroyInput) => update: deleteCache(movieMutationImpactedQueries), }); +export const useMoviesDestroy = (input: GQL.MoviesDestroyMutationVariables) => + GQL.useMoviesDestroyMutation({ + variables: input, + update: deleteCache(movieMutationImpactedQueries), + }); + export const tagMutationImpactedQueries = [ GQL.FindSceneDocument, GQL.FindScenesDocument, @@ -622,6 +646,12 @@ export const useTagDestroy = (input: GQL.TagDestroyInput) => update: deleteCache(tagMutationImpactedQueries), }); +export const useTagsDestroy = (input: GQL.TagsDestroyMutationVariables) => + GQL.useTagsDestroyMutation({ + variables: input, + update: deleteCache(tagMutationImpactedQueries), + }); + export const useConfigureGeneral = (input: GQL.ConfigGeneralInput) => GQL.useConfigureGeneralMutation({ variables: { input },