Bulk update scenes (#92)

* Add bulk update functionality

* Restore multiselect fixes from previous branch

* Prevent unsetting of studios/tags

* Detect when slice fields are omitted and ignore
This commit is contained in:
WithoutPants 2019-10-28 00:05:31 +11:00 committed by Leopere
parent 70ce01c604
commit 0655223c38
13 changed files with 754 additions and 61 deletions

View File

@ -26,6 +26,34 @@ mutation SceneUpdate(
}
}
mutation BulkSceneUpdate(
$ids: [ID!] = [],
$title: String,
$details: String,
$url: String,
$date: String,
$rating: Int,
$studio_id: ID,
$gallery_id: ID,
$performer_ids: [ID!],
$tag_ids: [ID!]) {
bulkSceneUpdate(input: {
ids: $ids,
title: $title,
details: $details,
url: $url,
date: $date,
rating: $rating,
studio_id: $studio_id,
gallery_id: $gallery_id,
performer_ids: $performer_ids,
tag_ids: $tag_ids
}) {
...SceneData
}
}
mutation SceneDestroy($id: ID!, $delete_file: Boolean, $delete_generated : Boolean) {
sceneDestroy(input: {id: $id, delete_file: $delete_file, delete_generated: $delete_generated})
}

View File

@ -77,6 +77,7 @@ type Query {
type Mutation {
sceneUpdate(input: SceneUpdateInput!): Scene
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
sceneDestroy(input: SceneDestroyInput!): Boolean!
sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker

View File

@ -53,6 +53,20 @@ input SceneUpdateInput {
tag_ids: [ID!]
}
input BulkSceneUpdateInput {
clientMutationId: String
ids: [ID!]
title: String
details: String
url: String
date: String
rating: Int
studio_id: ID
gallery_id: ID
performer_ids: [ID!]
tag_ids: [ID!]
}
input SceneDestroyInput {
id: ID!
delete_file: Boolean

View File

@ -5,6 +5,8 @@ import (
"sort"
"strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper"
)
@ -165,3 +167,13 @@ func (r *queryResolver) ScrapeFreeones(ctx context.Context, performer_name strin
func (r *queryResolver) ScrapeFreeonesPerformerList(ctx context.Context, query string) ([]string, error) {
return scraper.GetPerformerNames(query)
}
// wasFieldIncluded returns true if the given field was included in the request.
// Slices are unmarshalled to empty slices even if the field was omitted. This
// method determines if it was omitted altogether.
func wasFieldIncluded(ctx context.Context, field string) bool {
rctx := graphql.GetRequestContext(ctx)
_, ret := rctx.Variables[field]
return ret
}

View File

@ -119,6 +119,121 @@ func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUp
return scene, nil
}
func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.BulkSceneUpdateInput) ([]*models.Scene, error) {
// Populate scene from the input
updatedTime := time.Now()
// Start the transaction and save the scene marker
tx := database.DB.MustBeginTx(ctx, nil)
qb := models.NewSceneQueryBuilder()
jqb := models.NewJoinsQueryBuilder()
updatedScene := models.ScenePartial{
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
}
if input.Title != nil {
updatedScene.Title = &sql.NullString{String: *input.Title, Valid: true}
}
if input.Details != nil {
updatedScene.Details = &sql.NullString{String: *input.Details, Valid: true}
}
if input.URL != nil {
updatedScene.URL = &sql.NullString{String: *input.URL, Valid: true}
}
if input.Date != nil {
updatedScene.Date = &models.SQLiteDate{String: *input.Date, Valid: true}
}
if input.Rating != nil {
// a rating of 0 means unset the rating
if *input.Rating == 0 {
updatedScene.Rating = &sql.NullInt64{Int64: 0, Valid: false}
} else {
updatedScene.Rating = &sql.NullInt64{Int64: int64(*input.Rating), Valid: true}
}
}
if input.StudioID != nil {
// empty string means unset the studio
if *input.StudioID == "" {
updatedScene.StudioID = &sql.NullInt64{Int64: 0, Valid: false}
} else {
studioID, _ := strconv.ParseInt(*input.StudioID, 10, 64)
updatedScene.StudioID = &sql.NullInt64{Int64: studioID, Valid: true}
}
}
ret := []*models.Scene{}
for _, sceneIDStr := range input.Ids {
sceneID, _ := strconv.Atoi(sceneIDStr)
updatedScene.ID = sceneID
scene, err := qb.Update(updatedScene, tx)
if err != nil {
_ = tx.Rollback()
return nil, err
}
ret = append(ret, scene)
if input.GalleryID != nil {
// Save the gallery
galleryID, _ := strconv.Atoi(*input.GalleryID)
updatedGallery := models.Gallery{
ID: galleryID,
SceneID: sql.NullInt64{Int64: int64(sceneID), Valid: true},
UpdatedAt: models.SQLiteTimestamp{Timestamp: updatedTime},
}
gqb := models.NewGalleryQueryBuilder()
_, err := gqb.Update(updatedGallery, tx)
if err != nil {
_ = tx.Rollback()
return nil, err
}
}
// Save the performers
if wasFieldIncluded(ctx, "performer_ids") {
var performerJoins []models.PerformersScenes
for _, pid := range input.PerformerIds {
performerID, _ := strconv.Atoi(pid)
performerJoin := models.PerformersScenes{
PerformerID: performerID,
SceneID: sceneID,
}
performerJoins = append(performerJoins, performerJoin)
}
if err := jqb.UpdatePerformersScenes(sceneID, performerJoins, tx); err != nil {
_ = tx.Rollback()
return nil, err
}
}
// Save the tags
if wasFieldIncluded(ctx, "tag_ids") {
var tagJoins []models.ScenesTags
for _, tid := range input.TagIds {
tagID, _ := strconv.Atoi(tid)
tagJoin := models.ScenesTags{
SceneID: sceneID,
TagID: tagID,
}
tagJoins = append(tagJoins, tagJoin)
}
if err := jqb.UpdateScenesTags(sceneID, tagJoins, tx); err != nil {
_ = tx.Rollback()
return nil, err
}
}
}
// Commit
if err := tx.Commit(); err != nil {
return nil, err
}
return ret, nil
}
func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneDestroyInput) (bool, error) {
qb := models.NewSceneQueryBuilder()
tx := database.DB.MustBeginTx(ctx, nil)

View File

@ -25,6 +25,8 @@ interface IListFilterProps {
onChangeDisplayMode: (displayMode: DisplayMode) => void;
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
onRemoveCriterion: (criterion: Criterion) => void;
onSelectAll?: () => void;
onSelectNone?: () => void;
filter: ListFilterModel;
}
@ -134,6 +136,39 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
));
}
function onSelectAll() {
if (props.onSelectAll) {
props.onSelectAll();
}
}
function onSelectNone() {
if (props.onSelectNone) {
props.onSelectNone();
}
}
function renderSelectAll() {
if (props.onSelectAll) {
return <Button onClick={() => onSelectAll()} text="Select All"/>;
}
}
function renderSelectNone() {
if (props.onSelectNone) {
return <Button onClick={() => onSelectNone()} text="Select None"/>;
}
}
function renderSelectAllNone() {
return (
<>
{renderSelectAll()}
{renderSelectNone()}
</>
);
}
function render() {
return (
<>
@ -176,6 +211,10 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
<ButtonGroup className="filter-item">
{renderDisplayModeOptions()}
</ButtonGroup>
<ButtonGroup className="filter-item">
{renderSelectAllNone()}
</ButtonGroup>
</div>
<div style={{display: "flex", justifyContent: "center", margin: "10px auto"}}>
{renderFilterTags()}

View File

@ -2,6 +2,7 @@ import {
Button,
ButtonGroup,
Card,
Checkbox,
Divider,
Elevation,
H4,
@ -19,11 +20,14 @@ import { SceneHelpers } from "./helpers";
interface ISceneCardProps {
scene: GQL.SlimSceneDataFragment;
selected: boolean | undefined;
onSelectedChanged: (selected : boolean, shiftKey : boolean) => void;
}
export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardProps) => {
const [previewPath, setPreviewPath] = useState<string | undefined>(undefined);
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: false});
function maybeRenderRatingBanner() {
if (!props.scene.rating) { return; }
@ -115,6 +119,8 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
setPreviewPath("");
}
var shiftKey = false;
return (
<Card
className="grid-item"
@ -122,6 +128,12 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<Checkbox
className="card-select"
checked={props.selected}
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => { shiftKey = event.shiftKey; event.stopPropagation(); } }
/>
<Link to={`/scenes/${props.scene.id}`} className="image previewable">
{maybeRenderRatingBanner()}
<video className="preview" loop={true} poster={props.scene.paths.screenshot || ""} ref={videoHoverHook.videoEl}>

View File

@ -1,7 +1,7 @@
import _ from "lodash";
import React, { FunctionComponent } from "react";
import { QueryHookResult } from "react-apollo-hooks";
import { FindScenesQuery, FindScenesVariables } from "../../core/generated-graphql";
import { FindScenesQuery, FindScenesVariables, SlimSceneDataFragment } from "../../core/generated-graphql";
import { ListHook } from "../../hooks/ListHook";
import { IBaseProps } from "../../models/base-props";
import { ListFilterModel } from "../../models/list-filter/filter";
@ -9,6 +9,7 @@ import { DisplayMode, FilterMode } from "../../models/list-filter/types";
import { WallPanel } from "../Wall/WallPanel";
import { SceneCard } from "./SceneCard";
import { SceneListTable } from "./SceneListTable";
import { SceneSelectedOptions } from "./SceneSelectedOptions";
interface ISceneListProps extends IBaseProps {}
@ -17,14 +18,50 @@ export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListP
filterMode: FilterMode.Scenes,
props,
renderContent,
renderSelectedOptions
});
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel) {
function renderSelectedOptions(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, selectedIds: Set<string>) {
// find the selected items from the ids
if (!result.data || !result.data.findScenes) { return undefined; }
var scenes = result.data.findScenes.scenes;
var selectedScenes : SlimSceneDataFragment[] = [];
selectedIds.forEach((id) => {
var scene = scenes.find((scene) => {
return scene.id === id;
});
if (scene) {
selectedScenes.push(scene);
}
});
return (
<>
<SceneSelectedOptions selected={selectedScenes} onScenesUpdated={() => { return; }}/>
</>
);
}
function renderSceneCard(scene : SlimSceneDataFragment, selectedIds: Set<string>) {
return (
<SceneCard
key={scene.id}
scene={scene}
selected={selectedIds.has(scene.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) => listData.onSelectChange(scene.id, selected, shiftKey)}
/>
)
}
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>) {
if (!result.data || !result.data.findScenes) { return; }
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="grid">
{result.data.findScenes.scenes.map((scene) => (<SceneCard key={scene.id} scene={scene} />))}
{result.data.findScenes.scenes.map((scene) => renderSceneCard(scene, selectedIds))}
</div>
);
} else if (filter.displayMode === DisplayMode.List) {

View File

@ -0,0 +1,308 @@
import _ from "lodash";
import {
AnchorButton,
Button,
ButtonGroup,
ControlGroup,
FormGroup,
HTMLSelect,
InputGroup,
Menu,
MenuItem,
Popover,
Spinner,
Tag,
} from "@blueprintjs/core";
import React, { FunctionComponent, SyntheticEvent, useEffect, useRef, useState } from "react";
import { FilterSelect } from "../select/FilterSelect";
import { FilterMultiSelect } from "../select/FilterMultiSelect";
import { StashService } from "../../core/StashService";
import * as GQL from "../../core/generated-graphql";
import { ErrorUtils } from "../../utils/errors";
import { ToastUtils } from "../../utils/toasts";
interface IListOperationProps {
selected: GQL.SlimSceneDataFragment[],
onScenesUpdated: () => void;
}
export const SceneSelectedOptions: FunctionComponent<IListOperationProps> = (props: IListOperationProps) => {
const [rating, setRating] = useState<string>("");
const [studioId, setStudioId] = useState<string | undefined>(undefined);
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
const updateScenes = StashService.useBulkSceneUpdate(getSceneInput());
// Network state
const [isLoading, setIsLoading] = useState(false);
function getSceneInput() : GQL.BulkSceneUpdateInput {
// need to determine what we are actually setting on each scene
var aggregateRating = getRating(props.selected);
var aggregateStudioId = getStudioId(props.selected);
var aggregatePerformerIds = getPerformerIds(props.selected);
var aggregateTagIds = getTagIds(props.selected);
var sceneInput : GQL.BulkSceneUpdateInput = {
ids: props.selected.map((scene) => {
return scene.id;
})
};
// if rating is undefined
if (rating === "") {
// and all scenes have the same rating, then we are unsetting the rating.
if(aggregateRating) {
// an undefined rating is ignored in the server, so set it to 0 instead
sceneInput.rating = 0;
}
// otherwise not setting the rating
} else {
// if rating is set, then we are setting the rating for all
sceneInput.rating = Number.parseInt(rating);
}
// if studioId is undefined
if (studioId === undefined) {
// and all scenes have the same studioId,
// then unset the studioId, otherwise ignoring studioId
if (aggregateStudioId) {
// an undefined studio_id is ignored in the server, so set it to empty string instead
sceneInput.studio_id = "";
}
} else {
// if studioId is set, then we are setting it
sceneInput.studio_id = studioId;
}
// if performerIds are empty
if (!performerIds || performerIds.length === 0) {
// and all scenes have the same ids,
if (aggregatePerformerIds.length > 0) {
// then unset the performerIds, otherwise ignore
sceneInput.performer_ids = performerIds;
}
} else {
// if performerIds non-empty, then we are setting them
sceneInput.performer_ids = performerIds;
}
// if tagIds non-empty, then we are setting them
if (!tagIds || tagIds.length === 0) {
// and all scenes have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
sceneInput.tag_ids = tagIds;
}
} else {
// if tagIds non-empty, then we are setting them
sceneInput.tag_ids = tagIds;
}
return sceneInput;
}
async function onSave() {
setIsLoading(true);
try {
const result = await updateScenes();
ToastUtils.success("Updated scenes");
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
props.onScenesUpdated();
}
function getRating(state: GQL.SlimSceneDataFragment[]) {
var ret : number | undefined;
var first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.rating;
first = false;
} else {
if (ret !== scene.rating) {
ret = undefined;
}
}
});
return ret;
}
function getStudioId(state: GQL.SlimSceneDataFragment[]) {
var ret : string | undefined;
var first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.studio ? scene.studio.id : undefined;
first = false;
} else {
var studioId = scene.studio ? scene.studio.id : undefined;
if (ret != studioId) {
ret = undefined;
}
}
});
return ret;
}
function toId(object : any) {
return object.id;
}
function getPerformerIds(state: GQL.SlimSceneDataFragment[]) {
var ret : string[] = [];
var first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => {
if (first) {
ret = !!scene.performers ? scene.performers.map(toId).sort() : [];
first = false;
} else {
const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
if (!_.isEqual(ret, perfIds)) {
ret = [];
}
}
});
return ret;
}
function getTagIds(state: GQL.SlimSceneDataFragment[]) {
var ret : string[] = [];
var first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => {
if (first) {
ret = !!scene.tags ? scene.tags.map(toId).sort() : [];
first = false;
} else {
const tIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
function updateScenesEditState(state: GQL.SlimSceneDataFragment[]) {
function toId(object : any) {
return object.id;
}
var rating : string = "";
var studioId : string | undefined;
var performerIds : string[] = [];
var tagIds : string[] = [];
var first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => {
var thisRating = scene.rating ? scene.rating.toString() : "";
var thisStudio = scene.studio ? scene.studio.id : undefined;
if (first) {
rating = thisRating;
studioId = thisStudio;
performerIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
tagIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
first = false;
} else {
if (rating !== thisRating) {
rating = "";
}
if (studioId != thisStudio) {
studioId = undefined;
}
const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
const tIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
if (!_.isEqual(performerIds, perfIds)) {
performerIds = [];
}
if (!_.isEqual(tagIds, tIds)) {
tagIds = [];
}
}
});
setRating(rating);
setStudioId(studioId);
setPerformerIds(performerIds);
setTagIds(tagIds);
}
useEffect(() => {
updateScenesEditState(props.selected);
}, [props.selected]);
function renderMultiSelect(type: "performers" | "tags", initialIds: string[] | undefined) {
return (
<FilterMultiSelect
type={type}
onUpdate={(items) => {
const ids = items.map((i) => i.id);
switch (type) {
case "performers": setPerformerIds(ids); break;
case "tags": setTagIds(ids); break;
}
}}
initialIds={initialIds}
/>
);
}
function render() {
return (
<>
{isLoading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
<div className="operation-container">
<FormGroup className="operation-item" label="Rating">
<HTMLSelect
options={["", 1, 2, 3, 4, 5]}
onChange={(event) => setRating(event.target.value)}
value={rating}
/>
</FormGroup>
<FormGroup className="operation-item" label="Studio">
<FilterSelect
type="studios"
onSelectItem={(item : any) => setStudioId(item ? item.id : undefined)}
initialId={studioId}
/>
</FormGroup>
<FormGroup className="operation-item" label="Performers">
{renderMultiSelect("performers", performerIds)}
</FormGroup>
<FormGroup className="operation-item" label="Tags">
{renderMultiSelect("tags", tagIds)}
</FormGroup>
<ButtonGroup className="operation-item">
<Button
intent="primary"
onClick={() => onSave()}>
Apply
</Button>
</ButtonGroup>
</div>
</>
);
}
return render();
};

View File

@ -24,13 +24,21 @@ interface IProps extends HTMLInputProps, Partial<IMultiSelectProps<ValidTypes>>
}
export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps) => {
let items: ValidTypes[];
let InternalMultiSelect: new (props: IMultiSelectProps<any>) => MultiSelect<any>;
var createNewFunc = undefined;
let MultiSelectImpl = getMultiSelectImpl();
let InternalMultiSelect = MultiSelectImpl.getInternalMultiSelect();
const data = MultiSelectImpl.getData();
const [selectedItems, setSelectedItems] = React.useState<ValidTypes[]>([]);
const [items, setItems] = React.useState<ValidTypes[]>([]);
const [newTagName, setNewTagName] = React.useState<string>("");
const createTag = StashService.useTagCreate(getTagInput() as GQL.TagCreateInput);
React.useEffect(() => {
if (!!data) {
MultiSelectImpl.translateData();
}
}, [data]);
function getTagInput() {
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name: newTagName };
return tagInput;
@ -42,8 +50,10 @@ export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps
try {
created = await createTag();
items.push(created.data.tagCreate);
setItems(items.slice());
addSelectedItem(created.data.tagCreate);
ToastUtils.success("Created tag");
} catch (e) {
ErrorUtils.handle(e);
@ -76,43 +86,50 @@ export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps
);
}
switch (props.type) {
case "performers": {
const { data } = StashService.useAllPerformersForFilter();
items = !!data && !!data.allPerformers ? data.allPerformers : [];
InternalMultiSelect = InternalPerformerMultiSelect;
break;
}
case "studios": {
const { data } = StashService.useAllStudiosForFilter();
items = !!data && !!data.allStudios ? data.allStudios : [];
InternalMultiSelect = InternalStudioMultiSelect;
break;
}
case "tags": {
const { data } = StashService.useAllTagsForFilter();
items = !!data && !!data.allTags ? data.allTags : [];
InternalMultiSelect = InternalTagMultiSelect;
createNewFunc = createNewTag;
break;
}
default: {
console.error("Unhandled case in FilterMultiSelect");
return <>Unhandled case in FilterMultiSelect</>;
}
}
/* eslint-disable react-hooks/rules-of-hooks */
const [selectedItems, setSelectedItems] = React.useState<ValidTypes[]>([]);
const [isInitialized, setIsInitialized] = React.useState<boolean>(false);
/* eslint-enable */
if (!!props.initialIds && selectedItems.length === 0 && !isInitialized) {
const initialItems = items.filter((item) => props.initialIds!.includes(item.id));
if (initialItems.length > 0) {
React.useEffect(() => {
if (!!props.initialIds && !!items) {
const initialItems = items.filter((item) => props.initialIds!.includes(item.id));
setSelectedItems(initialItems);
setIsInitialized(true);
}
}, [props.initialIds, items]);
function getMultiSelectImpl() {
let getInternalMultiSelect: () => new (props: IMultiSelectProps<any>) => MultiSelect<any>;
let getData: () => GQL.AllPerformersForFilterQuery | GQL.AllStudiosForFilterQuery | GQL.AllTagsForFilterQuery | undefined;
let translateData: () => void;
let createNewObject: ((query : string) => void) | undefined = undefined;
switch (props.type) {
case "performers": {
getInternalMultiSelect = () => { return InternalPerformerMultiSelect; };
getData = () => { const { data } = StashService.useAllPerformersForFilter(); return data; }
translateData = () => { let perfData = data as GQL.AllPerformersForFilterQuery; setItems(!!perfData && !!perfData.allPerformers ? perfData.allPerformers : []); };
break;
}
case "studios": {
getInternalMultiSelect = () => { return InternalStudioMultiSelect; };
getData = () => { const { data } = StashService.useAllStudiosForFilter(); return data; }
translateData = () => { let studioData = data as GQL.AllStudiosForFilterQuery; setItems(!!studioData && !!studioData.allStudios ? studioData.allStudios : []); };
break;
}
case "tags": {
getInternalMultiSelect = () => { return InternalTagMultiSelect; };
getData = () => { const { data } = StashService.useAllTagsForFilter(); return data; }
translateData = () => { let tagData = data as GQL.AllTagsForFilterQuery; setItems(!!tagData && !!tagData.allTags ? tagData.allTags : []); };
createNewObject = createNewTag;
break;
}
default: {
throw "Unhandled case in FilterMultiSelect";
}
}
return {
getInternalMultiSelect: getInternalMultiSelect,
getData: getData,
translateData: translateData,
createNewObject: createNewObject
};
}
const renderItem: ItemRenderer<ValidTypes> = (item, itemProps) => {
@ -165,7 +182,7 @@ export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps
onItemSelect={onItemSelect}
resetOnSelect={true}
popoverProps={{position: "bottom"}}
createNewItemFromQuery={createNewFunc}
createNewItemFromQuery={MultiSelectImpl.createNewObject}
createNewItemRenderer={createNewRenderer}
{...props}
/>

View File

@ -179,6 +179,10 @@ export class StashService {
return GQL.useSceneUpdate({ variables: input });
}
public static useBulkSceneUpdate(input: GQL.BulkSceneUpdateInput) {
return GQL.useBulkSceneUpdate({ variables: input, refetchQueries: ["FindScenes"] });
}
public static useSceneDestroy(input: GQL.SceneDestroyInput) {
return GQL.useSceneDestroy({ variables: input });
}

View File

@ -15,17 +15,22 @@ export interface IListHookData {
filter: ListFilterModel;
template: JSX.Element;
options: IListHookOptions;
onSelectChange: (id: string, selected : boolean, shiftKey: boolean) => void;
}
export interface IListHookOptions {
filterMode: FilterMode;
props: IBaseProps;
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel) => JSX.Element | undefined;
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel, selectedIds: Set<string>) => JSX.Element | undefined;
renderSelectedOptions?: (result: QueryHookResult<any, any>, selectedIds: Set<string>) => JSX.Element | undefined;
}
export class ListHook {
public static useList(options: IListHookOptions): IListHookData {
const [filter, setFilter] = useState<ListFilterModel>(new ListFilterModel(options.filterMode));
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string | undefined>(undefined);
const [totalCount, setTotalCount] = useState<number>(0);
// Update the filter when the query parameters change
useEffect(() => {
@ -39,42 +44,61 @@ export class ListHook {
}, [options.props.location.search]);
let result: QueryHookResult<any, any>;
let totalCount: number;
let getData: (filter : ListFilterModel) => QueryHookResult<any, any>;
let getItems: () => any[];
let getCount: () => number;
switch (options.filterMode) {
case FilterMode.Scenes: {
result = StashService.useFindScenes(filter);
totalCount = !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0;
getData = (filter : ListFilterModel) => { return StashService.useFindScenes(filter); }
getItems = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.scenes : []; }
getCount = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0; }
break;
}
case FilterMode.SceneMarkers: {
result = StashService.useFindSceneMarkers(filter);
totalCount = !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.count : 0;
getData = (filter : ListFilterModel) => { return StashService.useFindSceneMarkers(filter); }
getItems = () => { return !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.scene_markers : []; }
getCount = () => { return !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.count : 0; }
break;
}
case FilterMode.Galleries: {
result = StashService.useFindGalleries(filter);
totalCount = !!result.data && !!result.data.findGalleries ? result.data.findGalleries.count : 0;
getData = (filter : ListFilterModel) => { return StashService.useFindGalleries(filter); }
getItems = () => { return !!result.data && !!result.data.findGalleries ? result.data.findGalleries.galleries : []; }
getCount = () => { return !!result.data && !!result.data.findGalleries ? result.data.findGalleries.count : 0; }
break;
}
case FilterMode.Studios: {
result = StashService.useFindStudios(filter);
totalCount = !!result.data && !!result.data.findStudios ? result.data.findStudios.count : 0;
getData = (filter : ListFilterModel) => { return StashService.useFindStudios(filter); }
getItems = () => { return !!result.data && !!result.data.findStudios ? result.data.findStudios.studios : []; }
getCount = () => { return !!result.data && !!result.data.findStudios ? result.data.findStudios.count : 0; }
break;
}
case FilterMode.Performers: {
result = StashService.useFindPerformers(filter);
totalCount = !!result.data && !!result.data.findPerformers ? result.data.findPerformers.count : 0;
getData = (filter : ListFilterModel) => { return StashService.useFindPerformers(filter); }
getItems = () => { return !!result.data && !!result.data.findPerformers ? result.data.findPerformers.performers : []; }
getCount = () => { return !!result.data && !!result.data.findPerformers ? result.data.findPerformers.count : 0; }
break;
}
default: {
console.error("REMOVE DEFAULT IN LIST HOOK");
result = StashService.useFindScenes(filter);
totalCount = !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0;
getData = (filter : ListFilterModel) => { return StashService.useFindScenes(filter); }
getItems = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.scenes : []; }
getCount = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0; }
break;
}
}
result = getData(filter);
useEffect(() => {
setTotalCount(getCount());
// select none when data changes
onSelectNone();
setLastClickedId(undefined);
}, [result.data])
// Update the query parameters when the data changes
useEffect(() => {
const location = Object.assign({}, options.props.history.location);
@ -159,6 +183,77 @@ export class ListHook {
setFilter(newFilter);
}
function onSelectChange(id: string, selected : boolean, shiftKey: boolean) {
if (shiftKey) {
multiSelect(id, selected);
} else {
singleSelect(id, selected);
}
}
function singleSelect(id: string, selected: boolean) {
setLastClickedId(id);
const newSelectedIds = _.clone(selectedIds);
if (selected) {
newSelectedIds.add(id);
} else {
newSelectedIds.delete(id);
}
setSelectedIds(newSelectedIds);
}
function multiSelect(id: string, selected : boolean) {
let startIndex = 0;
let thisIndex = -1;
if (!!lastClickedId) {
startIndex = getItems().findIndex((item) => {
return item.id === lastClickedId;
});
}
thisIndex = getItems().findIndex((item) => {
return item.id === id;
});
selectRange(startIndex, thisIndex);
}
function selectRange(startIndex : number, endIndex : number) {
if (startIndex > endIndex) {
let tmp = startIndex;
startIndex = endIndex;
endIndex = tmp;
}
const subset = getItems().slice(startIndex, endIndex + 1);
const newSelectedIds : Set<string> = new Set();
subset.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
}
function onSelectAll() {
const newSelectedIds : Set<string> = new Set();
getItems().forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
function onSelectNone() {
const newSelectedIds : Set<string> = new Set();
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
const template = (
<div>
<ListFilter
@ -169,11 +264,14 @@ export class ListHook {
onChangeDisplayMode={onChangeDisplayMode}
onAddCriterion={onAddCriterion}
onRemoveCriterion={onRemoveCriterion}
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
filter={filter}
/>
{options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined}
{result.loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
{result.error ? <h1>{result.error.message}</h1> : undefined}
{options.renderContent(result, filter)}
{options.renderContent(result, filter, selectedIds)}
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
@ -183,6 +281,6 @@ export class ListHook {
</div>
);
return { filter, template, options };
return { filter, template, options, onSelectChange };
}
}

View File

@ -87,6 +87,14 @@ code {
position: relative;
}
.grid-item label.card-select {
position: absolute;
padding-left: 15px;
margin-top: -12px;
z-index: 10;
opacity: 0.5;
}
video.preview {
// height: 225px; // slows down the page
width: 100%;
@ -95,7 +103,7 @@ video.preview {
object-fit: cover;
}
.filter-item {
.filter-item, .operation-item {
margin: 0 10px;
}
@ -116,7 +124,7 @@ video.preview {
}
}
.filter-container {
.filter-container, .operation-container {
display: flex;
justify-content: center;
margin: 10px auto;