Refactor ItemList code and re-enable viewing sub-tag/studio content (#5080)

* Refactor list filter to use contexts
* Refactor FilteredListToolbar
* Move components into separate files
* Convert ItemList hook into components
* Fix criteria clone functions
* Add toggle for sub-studio content
* Add toggle for sub-tag content
* Make LoadingIndicator height smaller and fade in.
This commit is contained in:
WithoutPants
2024-07-31 16:35:37 +10:00
committed by GitHub
parent 540d72bc44
commit 6a5dc4e774
42 changed files with 1644 additions and 929 deletions

View File

@@ -4,7 +4,7 @@ import cloneDeep from "lodash-es/cloneDeep";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { queryFindGalleries, useFindGalleries } from "src/core/StashService"; import { queryFindGalleries, useFindGalleries } from "src/core/StashService";
@@ -16,16 +16,13 @@ import { GalleryListTable } from "./GalleryListTable";
import { GalleryCardGrid } from "./GalleryGridCard"; import { GalleryCardGrid } from "./GalleryGridCard";
import { View } from "../List/views"; import { View } from "../List/views";
const GalleryItemList = makeItemList({ function getItems(result: GQL.FindGalleriesQueryResult) {
filterMode: GQL.FilterMode.Galleries, return result?.data?.findGalleries?.galleries ?? [];
useResult: useFindGalleries, }
getItems(result: GQL.FindGalleriesQueryResult) {
return result?.data?.findGalleries?.galleries ?? []; function getCount(result: GQL.FindGalleriesQueryResult) {
}, return result?.data?.findGalleries?.count ?? 0;
getCount(result: GQL.FindGalleriesQueryResult) { }
return result?.data?.findGalleries?.count ?? 0;
},
});
interface IGalleryList { interface IGalleryList {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
@@ -43,6 +40,8 @@ export const GalleryList: React.FC<IGalleryList> = ({
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Galleries;
const otherOperations = [ const otherOperations = [
{ {
text: intl.formatMessage({ id: "actions.view_random" }), text: intl.formatMessage({ id: "actions.view_random" }),
@@ -185,17 +184,25 @@ export const GalleryList: React.FC<IGalleryList> = ({
} }
return ( return (
<GalleryItemList <ItemListContext
zoomable filterMode={filterMode}
selectable useResult={useFindGalleries}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook} filterHook={filterHook}
view={view} view={view}
alterQuery={alterQuery} selectable
otherOperations={otherOperations} >
addKeybinds={addKeybinds} <ItemList
renderContent={renderContent} zoomable
renderEditDialog={renderEditDialog} view={view}
renderDeleteDialog={renderDeleteDialog} otherOperations={otherOperations}
/> addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
); );
}; };

View File

@@ -11,23 +11,20 @@ import {
useFindGroups, useFindGroups,
useGroupsDestroy, useGroupsDestroy,
} from "src/core/StashService"; } from "src/core/StashService";
import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { ExportDialog } from "../Shared/ExportDialog"; import { ExportDialog } from "../Shared/ExportDialog";
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
import { GroupCardGrid } from "./GroupCardGrid"; import { GroupCardGrid } from "./GroupCardGrid";
import { EditGroupsDialog } from "./EditGroupsDialog"; import { EditGroupsDialog } from "./EditGroupsDialog";
import { View } from "../List/views"; import { View } from "../List/views";
const GroupItemList = makeItemList({ function getItems(result: GQL.FindGroupsQueryResult) {
filterMode: GQL.FilterMode.Groups, return result?.data?.findGroups?.groups ?? [];
useResult: useFindGroups, }
getItems(result: GQL.FindGroupsQueryResult) {
return result?.data?.findGroups?.groups ?? []; function getCount(result: GQL.FindGroupsQueryResult) {
}, return result?.data?.findGroups?.count ?? 0;
getCount(result: GQL.FindGroupsQueryResult) { }
return result?.data?.findGroups?.count ?? 0;
},
});
interface IGroupList { interface IGroupList {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
@@ -45,6 +42,8 @@ export const GroupList: React.FC<IGroupList> = ({
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Groups;
const otherOperations = [ const otherOperations = [
{ {
text: intl.formatMessage({ id: "actions.view_random" }), text: intl.formatMessage({ id: "actions.view_random" }),
@@ -174,16 +173,24 @@ export const GroupList: React.FC<IGroupList> = ({
} }
return ( return (
<GroupItemList <ItemListContext
selectable filterMode={filterMode}
useResult={useFindGroups}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook} filterHook={filterHook}
view={view} view={view}
alterQuery={alterQuery} selectable
otherOperations={otherOperations} >
addKeybinds={addKeybinds} <ItemList
renderContent={renderContent} view={view}
renderEditDialog={renderEditDialog} otherOperations={otherOperations}
renderDeleteDialog={renderDeleteDialog} addKeybinds={addKeybinds}
/> renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
); );
}; };

View File

@@ -11,11 +11,7 @@ import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { queryFindImages, useFindImages } from "src/core/StashService"; import { queryFindImages, useFindImages } from "src/core/StashService";
import { import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
makeItemList,
IItemListOperation,
showWhenSelected,
} from "../List/ItemList";
import { useLightbox } from "src/hooks/Lightbox/hooks"; import { useLightbox } from "src/hooks/Lightbox/hooks";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
@@ -31,6 +27,7 @@ import TextUtils from "src/utils/text";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { ImageGridCard } from "./ImageGridCard"; import { ImageGridCard } from "./ImageGridCard";
import { View } from "../List/views"; import { View } from "../List/views";
import { IItemListOperation } from "../List/FilteredListToolbar";
interface IImageWallProps { interface IImageWallProps {
images: GQL.SlimImageDataFragment[]; images: GQL.SlimImageDataFragment[];
@@ -222,51 +219,49 @@ const ImageListImages: React.FC<IImageListImages> = ({
return <></>; return <></>;
}; };
const ImageItemList = makeItemList({ function getItems(result: GQL.FindImagesQueryResult) {
filterMode: GQL.FilterMode.Images, return result?.data?.findImages?.images ?? [];
useResult: useFindImages, }
getItems(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.images ?? [];
},
getCount(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.count ?? 0;
},
renderMetadataByline(result: GQL.FindImagesQueryResult) {
const megapixels = result?.data?.findImages?.megapixels;
const size = result?.data?.findImages?.filesize;
const filesize = size ? TextUtils.fileSize(size) : undefined;
if (!megapixels && !size) { function getCount(result: GQL.FindImagesQueryResult) {
return; return result?.data?.findImages?.count ?? 0;
} }
const separator = megapixels && size ? " - " : ""; function renderMetadataByline(result: GQL.FindImagesQueryResult) {
const megapixels = result?.data?.findImages?.megapixels;
const size = result?.data?.findImages?.filesize;
const filesize = size ? TextUtils.fileSize(size) : undefined;
return ( if (!megapixels && !size) {
<span className="images-stats"> return;
&nbsp;( }
{megapixels ? (
<span className="images-megapixels"> const separator = megapixels && size ? " - " : "";
<FormattedNumber value={megapixels} /> Megapixels
</span> return (
) : undefined} <span className="images-stats">
{separator} &nbsp;(
{size && filesize ? ( {megapixels ? (
<span className="images-size"> <span className="images-megapixels">
<FormattedNumber <FormattedNumber value={megapixels} /> Megapixels
value={filesize.size} </span>
maximumFractionDigits={TextUtils.fileSizeFractionalDigits( ) : undefined}
filesize.unit {separator}
)} {size && filesize ? (
/> <span className="images-size">
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} <FormattedNumber
</span> value={filesize.size}
) : undefined} maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
) filesize.unit
</span> )}
); />
}, {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
}); </span>
) : undefined}
)
</span>
);
}
interface IImageList { interface IImageList {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
@@ -289,6 +284,8 @@ export const ImageList: React.FC<IImageList> = ({
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false); const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
const filterMode = GQL.FilterMode.Images;
const otherOperations = [ const otherOperations = [
...(extraOperations ?? []), ...(extraOperations ?? []),
{ {
@@ -415,17 +412,26 @@ export const ImageList: React.FC<IImageList> = ({
} }
return ( return (
<ImageItemList <ItemListContext
zoomable filterMode={filterMode}
selectable useResult={useFindImages}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook} filterHook={filterHook}
view={view} view={view}
alterQuery={alterQuery} selectable
otherOperations={otherOperations} >
addKeybinds={addKeybinds} <ItemList
renderContent={renderContent} zoomable
renderEditDialog={renderEditDialog} view={view}
renderDeleteDialog={renderDeleteDialog} otherOperations={otherOperations}
/> addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderMetadataByline={renderMetadataByline}
/>
</ItemListContext>
); );
}; };

View File

@@ -0,0 +1,76 @@
import React from "react";
import { ListFilterModel } from "src/models/list-filter/filter";
import { isFunction } from "lodash-es";
import { useFilterURL } from "./util";
interface IFilterContextOptions {
filter: ListFilterModel;
setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>;
}
export interface IFilterContextState {
filter: ListFilterModel;
setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>;
}
export const FilterStateContext =
React.createContext<IFilterContextState | null>(null);
export const FilterContext = (
props: IFilterContextOptions & {
children?:
| ((props: IFilterContextState) => React.ReactNode)
| React.ReactNode;
}
) => {
const { filter, setFilter, children } = props;
const state = {
filter,
setFilter,
};
return (
<FilterStateContext.Provider value={state}>
{isFunction(children)
? (children as (props: IFilterContextState) => React.ReactNode)(state)
: children}
</FilterStateContext.Provider>
);
};
export function useFilter() {
const context = React.useContext(FilterStateContext);
if (context === null) {
throw new Error("useFilter must be used within a FilterStateContext");
}
return context;
}
// This component is used to set the filter from the URL.
// It replaces the setFilter function to set the URL instead.
// It also loads the default filter if the URL is empty.
export const SetFilterURL = (props: {
defaultFilter?: ListFilterModel;
setURL?: boolean;
children?:
| ((props: IFilterContextState) => React.ReactNode)
| React.ReactNode;
}) => {
const { defaultFilter, setURL = true, children } = props;
const { filter, setFilter: setFilterOrig } = useFilter();
const { setFilter } = useFilterURL(filter, setFilterOrig, {
defaultFilter,
setURL,
});
return (
<FilterContext filter={filter} setFilter={setFilter}>
{children}
</FilterContext>
);
};

View File

@@ -0,0 +1,87 @@
import React from "react";
import { QueryResult } from "@apollo/client";
import { ListFilterModel } from "src/models/list-filter/filter";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { ListFilter } from "./ListFilter";
import { ListViewOptions } from "./ListViewOptions";
import {
IListFilterOperation,
ListOperationButtons,
} from "./ListOperationButtons";
import { DisplayMode } from "src/models/list-filter/types";
import { ButtonToolbar } from "react-bootstrap";
import { View } from "./views";
import { useListContext } from "./ListProvider";
import { useFilter } from "./FilterProvider";
export interface IItemListOperation<T extends QueryResult> {
text: string;
onClick: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => Promise<void>;
isDisplayed?: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => boolean;
postRefetch?: boolean;
icon?: IconDefinition;
buttonVariant?: string;
}
export const FilteredListToolbar: React.FC<{
showEditFilter: (editingCriterion?: string) => void;
view?: View;
onEdit?: () => void;
onDelete?: () => void;
operations?: IListFilterOperation[];
zoomable?: boolean;
}> = ({
showEditFilter,
view,
onEdit,
onDelete,
operations,
zoomable = false,
}) => {
const { getSelected, onSelectAll, onSelectNone } = useListContext();
const { filter, setFilter } = useFilter();
const filterOptions = filter.options;
function onChangeDisplayMode(displayMode: DisplayMode) {
setFilter(filter.setDisplayMode(displayMode));
}
function onChangeZoom(newZoomIndex: number) {
setFilter(filter.setZoom(newZoomIndex));
}
return (
<ButtonToolbar className="justify-content-center">
<ListFilter
onFilterUpdate={setFilter}
filter={filter}
openFilterDialog={() => showEditFilter()}
view={view}
/>
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
otherOperations={operations}
itemsSelected={getSelected().length > 0}
onEdit={onEdit}
onDelete={onDelete}
/>
<ListViewOptions
displayMode={filter.displayMode}
displayModeOptions={filterOptions.displayModeOptions}
onSetDisplayMode={onChangeDisplayMode}
zoomIndex={zoomable ? filter.zoomIndex : undefined}
onSetZoom={zoomable ? onChangeZoom : undefined}
/>
</ButtonToolbar>
);
};

View File

@@ -1,16 +1,10 @@
import React, { import React, {
PropsWithChildren,
useCallback, useCallback,
useContext,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import clone from "lodash-es/clone";
import cloneDeep from "lodash-es/cloneDeep";
import isEqual from "lodash-es/isEqual";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { QueryResult } from "@apollo/client"; import { QueryResult } from "@apollo/client";
import { import {
@@ -18,69 +12,30 @@ import {
CriterionValue, CriterionValue,
} from "src/models/list-filter/criteria/criterion"; } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { useHistory, useLocation } from "react-router-dom";
import { ConfigurationContext } from "src/hooks/Config";
import { getFilterOptions } from "src/models/list-filter/factory";
import { Pagination, PaginationIndex } from "./Pagination";
import { EditFilterDialog } from "src/components/List/EditFilterDialog"; import { EditFilterDialog } from "src/components/List/EditFilterDialog";
import { ListFilter } from "./ListFilter";
import { FilterTags } from "./FilterTags"; import { FilterTags } from "./FilterTags";
import { ListViewOptions } from "./ListViewOptions";
import { ListOperationButtons } from "./ListOperationButtons";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { DisplayMode } from "src/models/list-filter/types";
import { ButtonToolbar } from "react-bootstrap";
import { View } from "./views"; import { View } from "./views";
import { useDefaultFilter } from "./util"; import { IHasID } from "src/utils/data";
import {
ListContext,
QueryResultContext,
useListContext,
useQueryResultContext,
} from "./ListProvider";
import { FilterContext, SetFilterURL, useFilter } from "./FilterProvider";
import { useModal } from "src/hooks/modal";
import {
useDefaultFilter,
useEnsureValidPage,
useListKeyboardShortcuts,
useScrollToTopOnPageChange,
} from "./util";
import { FilteredListToolbar, IItemListOperation } from "./FilteredListToolbar";
import { PagedList } from "./PagedList";
interface IDataItem { interface IItemListProps<T extends QueryResult, E extends IHasID> {
id: string;
}
export interface IItemListOperation<T extends QueryResult> {
text: string;
onClick: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => Promise<void>;
isDisplayed?: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => boolean;
postRefetch?: boolean;
icon?: IconDefinition;
buttonVariant?: string;
}
interface IItemListOptions<T extends QueryResult, E extends IDataItem> {
filterMode: GQL.FilterMode;
useResult: (filter: ListFilterModel) => T;
getCount: (data: T) => number;
renderMetadataByline?: (data: T) => React.ReactNode;
getItems: (data: T) => E[];
}
interface IRenderListProps {
filter: ListFilterModel;
onChangePage: (page: number) => void;
updateFilter: (filter: ListFilterModel) => void;
}
interface IItemListProps<T extends QueryResult, E extends IDataItem> {
view?: View; view?: View;
defaultSort?: string;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
filterDialog?: (
criteria: Criterion<CriterionValue>[],
setCriteria: (v: Criterion<CriterionValue>[]) => void
) => React.ReactNode;
zoomable?: boolean; zoomable?: boolean;
selectable?: boolean;
alterQuery?: boolean;
defaultZoomIndex?: number;
otherOperations?: IItemListOperation<T>[]; otherOperations?: IItemListOperation<T>[];
renderContent: ( renderContent: (
result: T, result: T,
@@ -90,6 +45,7 @@ interface IItemListProps<T extends QueryResult, E extends IDataItem> {
onChangePage: (page: number) => void, onChangePage: (page: number) => void,
pageCount: number pageCount: number
) => React.ReactNode; ) => React.ReactNode;
renderMetadataByline?: (data: T) => React.ReactNode;
renderEditDialog?: ( renderEditDialog?: (
selected: E[], selected: E[],
onClose: (applied: boolean) => void onClose: (applied: boolean) => void
@@ -105,570 +61,273 @@ interface IItemListProps<T extends QueryResult, E extends IDataItem> {
) => () => void; ) => () => void;
} }
const getSelectedData = <I extends IDataItem>( export const ItemList = <T extends QueryResult, E extends IHasID>(
data: I[], props: IItemListProps<T, E>
selectedIds: Set<string> ) => {
) => data.filter((value) => selectedIds.has(value.id)); const {
/**
* A factory function for ItemList components.
* IMPORTANT: as the component manipulates the URL query string, if there are
* ever multiple ItemLists rendered at once, all but one of them need to have
* `alterQuery` set to false to prevent conflicts.
*/
export function makeItemList<T extends QueryResult, E extends IDataItem>({
filterMode,
useResult,
getCount,
renderMetadataByline,
getItems,
}: IItemListOptions<T, E>) {
const filterOptions = getFilterOptions(filterMode);
const RenderList: React.FC<IItemListProps<T, E> & IRenderListProps> = ({
filter,
filterHook,
onChangePage: _onChangePage,
updateFilter,
view, view,
zoomable, zoomable,
selectable,
otherOperations, otherOperations,
renderContent, renderContent,
renderEditDialog, renderEditDialog,
renderDeleteDialog, renderDeleteDialog,
renderMetadataByline,
addKeybinds, addKeybinds,
}) => { } = props;
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string>();
const [editingCriterion, setEditingCriterion] = useState<string>(); const { filter, setFilter: updateFilter } = useFilter();
const [showEditFilter, setShowEditFilter] = useState(false); const { effectiveFilter, result, cachedResult, totalCount } =
useQueryResultContext<T, E>();
const {
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
} = useListContext<E>();
const effectiveFilter = useMemo(() => { const { modal, showModal, closeModal } = useModal();
if (filterHook) {
return filterHook(cloneDeep(filter)); const metadataByline = useMemo(() => {
if (cachedResult.loading) return "";
return renderMetadataByline?.(cachedResult) ?? "";
}, [renderMetadataByline, cachedResult]);
const pages = Math.ceil(totalCount / filter.itemsPerPage);
const onChangePage = useCallback(
(p: number) => {
updateFilter(filter.changePage(p));
},
[filter, updateFilter]
);
useEnsureValidPage(filter, totalCount, updateFilter);
const showEditFilter = useCallback(
(editingCriterion?: string) => {
function onApplyEditFilter(f: ListFilterModel) {
closeModal();
updateFilter(f);
} }
return filter;
}, [filter, filterHook]);
const result = useResult(effectiveFilter); showModal(
const [totalCount, setTotalCount] = useState(0); <EditFilterDialog
const [metadataByline, setMetadataByline] = useState<React.ReactNode>(); filter={filter}
const items = useMemo(() => getItems(result), [result]); onApply={onApplyEditFilter}
onCancel={() => closeModal()}
editingCriterion={editingCriterion}
/>
);
},
[filter, updateFilter, showModal, closeModal]
);
const [arePaging, setArePaging] = useState(false); useListKeyboardShortcuts({
const hidePagination = !arePaging && result.loading; currentPage: filter.currentPage,
onChangePage,
// useLayoutEffect to set total count before paint, avoiding a 0 being displayed onSelectAll,
useLayoutEffect(() => { onSelectNone,
if (result.loading) return; pages,
setArePaging(false); showEditFilter,
});
setTotalCount(getCount(result));
setMetadataByline(renderMetadataByline?.(result));
}, [result]);
const onChangePage = useCallback(
(page: number) => {
setArePaging(true);
_onChangePage(page);
},
[_onChangePage]
);
// handle case where page is more than there are pages
useEffect(() => {
const pages = Math.ceil(totalCount / filter.itemsPerPage);
if (pages > 0 && filter.currentPage > pages) {
onChangePage(pages);
}
}, [filter, onChangePage, totalCount]);
// set up hotkeys
useEffect(() => {
Mousetrap.bind("f", (e) => {
setShowEditFilter(true);
// prevent default behavior of typing f in a text field
// otherwise the filter dialog closes, the query field is focused and
// f is typed.
e.preventDefault();
});
useEffect(() => {
if (addKeybinds) {
const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds);
return () => { return () => {
Mousetrap.unbind("f"); unbindExtras();
}; };
}, []);
useEffect(() => {
const pages = Math.ceil(totalCount / filter.itemsPerPage);
Mousetrap.bind("right", () => {
if (filter.currentPage < pages) {
onChangePage(filter.currentPage + 1);
}
});
Mousetrap.bind("left", () => {
if (filter.currentPage > 1) {
onChangePage(filter.currentPage - 1);
}
});
Mousetrap.bind("shift+right", () => {
onChangePage(Math.min(pages, filter.currentPage + 10));
});
Mousetrap.bind("shift+left", () => {
onChangePage(Math.max(1, filter.currentPage - 10));
});
Mousetrap.bind("ctrl+end", () => {
onChangePage(pages);
});
Mousetrap.bind("ctrl+home", () => {
onChangePage(1);
});
return () => {
Mousetrap.unbind("right");
Mousetrap.unbind("left");
Mousetrap.unbind("shift+right");
Mousetrap.unbind("shift+left");
Mousetrap.unbind("ctrl+end");
Mousetrap.unbind("ctrl+home");
};
}, [filter, onChangePage, totalCount]);
useEffect(() => {
if (addKeybinds) {
const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds);
return () => {
unbindExtras();
};
}
}, [addKeybinds, result, effectiveFilter, selectedIds]);
function singleSelect(id: string, selected: boolean) {
setLastClickedId(id);
const newSelectedIds = clone(selectedIds);
if (selected) {
newSelectedIds.add(id);
} else {
newSelectedIds.delete(id);
}
setSelectedIds(newSelectedIds);
} }
}, [addKeybinds, result, effectiveFilter, selectedIds]);
function selectRange(startIndex: number, endIndex: number) { async function onOperationClicked(o: IItemListOperation<T>) {
let start = startIndex; await o.onClick(result, effectiveFilter, selectedIds);
let end = endIndex; if (o.postRefetch) {
if (start > end) {
const tmp = start;
start = end;
end = tmp;
}
const subset = items.slice(start, end + 1);
const newSelectedIds = new Set<string>();
subset.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
}
function multiSelect(id: string) {
let startIndex = 0;
let thisIndex = -1;
if (lastClickedId) {
startIndex = items.findIndex((item) => {
return item.id === lastClickedId;
});
}
thisIndex = items.findIndex((item) => {
return item.id === id;
});
selectRange(startIndex, thisIndex);
}
function onSelectChange(id: string, selected: boolean, shiftKey: boolean) {
if (shiftKey) {
multiSelect(id);
} else {
singleSelect(id, selected);
}
}
function onSelectAll() {
const newSelectedIds = new Set<string>();
items.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
function onSelectNone() {
const newSelectedIds = new Set<string>();
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
function onChangeZoom(newZoomIndex: number) {
const newFilter = cloneDeep(filter);
newFilter.zoomIndex = newZoomIndex;
updateFilter(newFilter);
}
async function onOperationClicked(o: IItemListOperation<T>) {
await o.onClick(result, effectiveFilter, selectedIds);
if (o.postRefetch) {
result.refetch();
}
}
const operations = otherOperations?.map((o) => ({
text: o.text,
onClick: () => {
onOperationClicked(o);
},
isDisplayed: () => {
if (o.isDisplayed) {
return o.isDisplayed(result, effectiveFilter, selectedIds);
}
return true;
},
icon: o.icon,
buttonVariant: o.buttonVariant,
}));
function onEdit() {
setIsEditDialogOpen(true);
}
function onEditDialogClosed(applied: boolean) {
if (applied) {
onSelectNone();
}
setIsEditDialogOpen(false);
// refetch
result.refetch(); result.refetch();
} }
}
function onDelete() { const operations = otherOperations?.map((o) => ({
setIsDeleteDialogOpen(true); text: o.text,
} onClick: () => {
onOperationClicked(o);
function onDeleteDialogClosed(deleted: boolean) { },
if (deleted) { isDisplayed: () => {
onSelectNone(); if (o.isDisplayed) {
} return o.isDisplayed(result, effectiveFilter, selectedIds);
setIsDeleteDialogOpen(false);
// refetch
result.refetch();
}
function renderPagination() {
if (hidePagination) return;
return (
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
onChangePage={onChangePage}
/>
);
}
function renderPaginationIndex() {
if (hidePagination) return;
return (
<PaginationIndex
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
);
}
function maybeRenderContent() {
if (result.loading) {
return <LoadingIndicator />;
}
if (result.error) {
return <h1>{result.error.message}</h1>;
} }
const pages = Math.ceil(totalCount / filter.itemsPerPage); return true;
return ( },
<> icon: o.icon,
{renderContent( buttonVariant: o.buttonVariant,
result, }));
// #4780 - use effectiveFilter to ensure filterHook is applied
effectiveFilter, function onEdit() {
selectedIds, if (!renderEditDialog) {
onSelectChange, return;
onChangePage,
pages
)}
{!!pages && (
<>
{renderPaginationIndex()}
{renderPagination()}
</>
)}
</>
);
} }
function onChangeDisplayMode(displayMode: DisplayMode) { showModal(
const newFilter = cloneDeep(filter); renderEditDialog(getSelected(), (applied) => onEditDialogClosed(applied))
newFilter.displayMode = displayMode;
updateFilter(newFilter);
}
function onRemoveCriterion(removedCriterion: Criterion<CriterionValue>) {
const newFilter = cloneDeep(filter);
newFilter.criteria = newFilter.criteria.filter(
(criterion) => criterion.getId() !== removedCriterion.getId()
);
newFilter.currentPage = 1;
updateFilter(newFilter);
}
function onClearAllCriteria() {
const newFilter = cloneDeep(filter);
newFilter.criteria = [];
newFilter.currentPage = 1;
updateFilter(newFilter);
}
function onApplyEditFilter(f: ListFilterModel) {
setShowEditFilter(false);
setEditingCriterion(undefined);
updateFilter(f);
}
function onCancelEditFilter() {
setShowEditFilter(false);
setEditingCriterion(undefined);
}
return (
<div className="item-list-container">
<ButtonToolbar className="justify-content-center">
<ListFilter
onFilterUpdate={updateFilter}
filter={filter}
filterOptions={filterOptions}
openFilterDialog={() => setShowEditFilter(true)}
view={view}
/>
<ListOperationButtons
onSelectAll={selectable ? onSelectAll : undefined}
onSelectNone={selectable ? onSelectNone : undefined}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={renderEditDialog ? onEdit : undefined}
onDelete={renderDeleteDialog ? onDelete : undefined}
/>
<ListViewOptions
displayMode={filter.displayMode}
displayModeOptions={filterOptions.displayModeOptions}
onSetDisplayMode={onChangeDisplayMode}
zoomIndex={zoomable ? filter.zoomIndex : undefined}
onSetZoom={zoomable ? onChangeZoom : undefined}
/>
</ButtonToolbar>
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => setEditingCriterion(c.criterionOption.type)}
onRemoveCriterion={onRemoveCriterion}
onRemoveAll={() => onClearAllCriteria()}
/>
{(showEditFilter || editingCriterion) && (
<EditFilterDialog
filter={filter}
onApply={onApplyEditFilter}
onCancel={onCancelEditFilter}
editingCriterion={editingCriterion}
/>
)}
{isEditDialogOpen &&
renderEditDialog &&
renderEditDialog(getSelectedData(items, selectedIds), (applied) =>
onEditDialogClosed(applied)
)}
{isDeleteDialogOpen &&
renderDeleteDialog &&
renderDeleteDialog(getSelectedData(items, selectedIds), (deleted) =>
onDeleteDialogClosed(deleted)
)}
{renderPagination()}
{renderPaginationIndex()}
{maybeRenderContent()}
</div>
); );
}; }
const ItemList: React.FC<IItemListProps<T, E>> = (props) => { function onEditDialogClosed(applied: boolean) {
const { if (applied) {
view, onSelectNone();
defaultSort = filterOptions.defaultSortBy, }
defaultZoomIndex, closeModal();
alterQuery = true,
} = props;
const history = useHistory(); // refetch
const location = useLocation(); result.refetch();
const [filterInitialised, setFilterInitialised] = useState(false); }
const { configuration: config } = useContext(ConfigurationContext);
const lastPathname = useRef(location.pathname); function onDelete() {
const defaultDisplayMode = filterOptions.displayModeOptions[0]; if (!renderDeleteDialog) {
const [filter, setFilter] = useState<ListFilterModel>( return;
() => new ListFilterModel(filterMode) }
showModal(
renderDeleteDialog(getSelected(), (deleted) =>
onDeleteDialogClosed(deleted)
)
); );
}
const { defaultFilter, loading: defaultFilterLoading } = useDefaultFilter( function onDeleteDialogClosed(deleted: boolean) {
filterMode, if (deleted) {
view onSelectNone();
); }
closeModal();
const updateQueryParams = useCallback( // refetch
(newFilter: ListFilterModel) => { result.refetch();
if (!alterQuery) return; }
const newParams = newFilter.makeQueryParameters(); function onRemoveCriterion(removedCriterion: Criterion<CriterionValue>) {
history.replace({ ...history.location, search: newParams }); updateFilter(filter.removeCriterion(removedCriterion.criterionOption.type));
}, }
[alterQuery, history]
);
const updateFilter = useCallback( function onClearAllCriteria() {
(newFilter: ListFilterModel) => { updateFilter(filter.clearCriteria());
setFilter(newFilter); }
updateQueryParams(newFilter);
},
[updateQueryParams]
);
// 'Startup' hook, initialises the filters return (
useEffect(() => { <div className="item-list-container">
// Only run once <FilteredListToolbar
if (filterInitialised) return; showEditFilter={showEditFilter}
view={view}
let newFilter = new ListFilterModel(filterMode, config, defaultZoomIndex); operations={operations}
let loadDefault = true; zoomable={zoomable}
if (alterQuery && location.search) { onEdit={renderEditDialog ? onEdit : undefined}
loadDefault = false; onDelete={renderDeleteDialog ? onDelete : undefined}
newFilter.configureFromQueryString(location.search);
}
if (view) {
// only set default filter if uninitialised
if (loadDefault) {
// wait until default filter is loaded
if (defaultFilterLoading) return;
if (defaultFilter) {
newFilter = defaultFilter.clone();
// #1507 - reset random seed when loaded
newFilter.randomSeed = -1;
}
}
}
setFilter(newFilter);
updateQueryParams(newFilter);
setFilterInitialised(true);
}, [
filterInitialised,
location,
config,
defaultSort,
defaultDisplayMode,
defaultZoomIndex,
alterQuery,
view,
updateQueryParams,
defaultFilter,
defaultFilterLoading,
]);
// This hook runs on every page location change (ie navigation),
// and updates the filter accordingly.
useEffect(() => {
if (!filterInitialised || !alterQuery) return;
// re-init if the pathname has changed
if (location.pathname !== lastPathname.current) {
lastPathname.current = location.pathname;
setFilterInitialised(false);
return;
}
// re-init to load default filter on empty new query params
if (!location.search) {
setFilterInitialised(false);
return;
}
// the query has changed, update filter if necessary
setFilter((prevFilter) => {
let newFilter = prevFilter.clone();
newFilter.configureFromQueryString(location.search);
if (!isEqual(newFilter, prevFilter)) {
return newFilter;
} else {
return prevFilter;
}
});
}, [filterInitialised, alterQuery, location]);
const onChangePage = useCallback(
(page: number) => {
const newFilter = cloneDeep(filter);
newFilter.currentPage = page;
updateFilter(newFilter);
// if the current page has a detail-header, then
// scroll up relative to that rather than 0, 0
const detailHeader = document.querySelector(".detail-header");
if (detailHeader) {
window.scrollTo(0, detailHeader.scrollHeight - 50);
} else {
window.scrollTo(0, 0);
}
},
[filter, updateFilter]
);
if (!filterInitialised) return null;
return (
<RenderList
filter={filter}
onChangePage={onChangePage}
updateFilter={updateFilter}
{...props}
/> />
); <FilterTags
}; criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={onRemoveCriterion}
onRemoveAll={() => onClearAllCriteria()}
/>
{modal}
return ItemList; <PagedList
result={result}
cachedResult={cachedResult}
filter={filter}
totalCount={totalCount}
onChangePage={onChangePage}
metadataByline={metadataByline}
>
{renderContent(
result,
// #4780 - use effectiveFilter to ensure filterHook is applied
effectiveFilter,
selectedIds,
onSelectChange,
onChangePage,
pages
)}
</PagedList>
</div>
);
};
interface IItemListContextProps<T extends QueryResult, E extends IHasID> {
filterMode: GQL.FilterMode;
defaultSort?: string;
useResult: (filter: ListFilterModel) => T;
getCount: (data: T) => number;
getItems: (data: T) => E[];
filterHook?: (filter: ListFilterModel) => ListFilterModel;
view?: View;
alterQuery?: boolean;
selectable?: boolean;
} }
// Provides the contexts for the ItemList component. Includes functionality to scroll
// to top on page change.
export const ItemListContext = <T extends QueryResult, E extends IHasID>(
props: PropsWithChildren<IItemListContextProps<T, E>>
) => {
const {
filterMode,
defaultSort,
useResult,
getCount,
getItems,
view,
filterHook,
alterQuery = true,
selectable,
children,
} = props;
const emptyFilter = useMemo(
() =>
new ListFilterModel(filterMode, undefined, {
defaultSortBy: defaultSort,
}),
[filterMode, defaultSort]
);
const [filter, setFilterState] = useState<ListFilterModel>(
() =>
new ListFilterModel(filterMode, undefined, { defaultSortBy: defaultSort })
);
const { defaultFilter, loading: defaultFilterLoading } = useDefaultFilter(
emptyFilter,
view
);
// scroll to the top of the page when the page changes
useScrollToTopOnPageChange(filter.currentPage);
if (defaultFilterLoading) return null;
return (
<FilterContext filter={filter} setFilter={setFilterState}>
<SetFilterURL defaultFilter={defaultFilter} setURL={alterQuery}>
<QueryResultContext
filterHook={filterHook}
useResult={useResult}
getCount={getCount}
getItems={getItems}
>
{({ items }) => (
<ListContext selectable={selectable} items={items}>
{children}
</ListContext>
)}
</QueryResultContext>
</SetFilterURL>
</FilterContext>
);
};
export const showWhenSelected = <T extends QueryResult>( export const showWhenSelected = <T extends QueryResult>(
result: T, result: T,
filter: ListFilterModel, filter: ListFilterModel,

View File

@@ -19,7 +19,6 @@ import {
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import useFocus from "src/utils/focus"; import useFocus from "src/utils/focus";
import { ListFilterOptions } from "src/models/list-filter/filter-options";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { SavedFilterDropdown } from "./SavedFilterList"; import { SavedFilterDropdown } from "./SavedFilterList";
import { import {
@@ -36,7 +35,6 @@ import { View } from "./views";
interface IListFilterProps { interface IListFilterProps {
onFilterUpdate: (newFilter: ListFilterModel) => void; onFilterUpdate: (newFilter: ListFilterModel) => void;
filter: ListFilterModel; filter: ListFilterModel;
filterOptions: ListFilterOptions;
view?: View; view?: View;
openFilterDialog: () => void; openFilterDialog: () => void;
} }
@@ -46,7 +44,6 @@ const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"];
export const ListFilter: React.FC<IListFilterProps> = ({ export const ListFilter: React.FC<IListFilterProps> = ({
onFilterUpdate, onFilterUpdate,
filter, filter,
filterOptions,
openFilterDialog, openFilterDialog,
view, view,
}) => { }) => {
@@ -58,6 +55,8 @@ export const ListFilter: React.FC<IListFilterProps> = ({
const perPageSelect = useRef(null); const perPageSelect = useRef(null);
const [perPageInput, perPageFocus] = useFocus(); const [perPageInput, perPageFocus] = useFocus();
const filterOptions = filter.options;
const searchQueryUpdated = useCallback( const searchQueryUpdated = useCallback(
(value: string) => { (value: string) => {
const newFilter = cloneDeep(filter); const newFilter = cloneDeep(filter);

View File

@@ -16,7 +16,7 @@ import {
faTrash, faTrash,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
interface IListFilterOperation { export interface IListFilterOperation {
text: string; text: string;
onClick: () => void; onClick: () => void;
isDisplayed?: () => boolean; isDisplayed?: () => boolean;

View File

@@ -0,0 +1,156 @@
import React, { useMemo } from "react";
import { IListSelect, useCachedQueryResult, useListSelect } from "./util";
import { isFunction } from "lodash-es";
import { IHasID } from "src/utils/data";
import { useFilter } from "./FilterProvider";
import { ListFilterModel } from "src/models/list-filter/filter";
import { QueryResult } from "@apollo/client";
interface IListContextOptions<T extends IHasID> {
selectable?: boolean;
items: T[];
}
export type IListContextState<T extends IHasID = IHasID> = IListSelect<T> & {
selectable: boolean;
items: T[];
};
export const ListStateContext = React.createContext<IListContextState | null>(
null
);
export const ListContext = <T extends IHasID = IHasID>(
props: IListContextOptions<T> & {
children?:
| ((props: IListContextState) => React.ReactNode)
| React.ReactNode;
}
) => {
const { selectable = false, items, children } = props;
const {
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
} = useListSelect(items);
const state: IListContextState<T> = {
selectable,
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
items,
};
return (
<ListStateContext.Provider value={state}>
{isFunction(children)
? (children as (props: IListContextState) => React.ReactNode)(state)
: children}
</ListStateContext.Provider>
);
};
export function useListContext<T extends IHasID = IHasID>() {
const context = React.useContext(ListStateContext);
if (context === null) {
throw new Error("useListContext must be used within a ListStateContext");
}
return context as IListContextState<T>;
}
interface IQueryResultContextOptions<
T extends QueryResult,
E extends IHasID = IHasID
> {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
useResult: (filter: ListFilterModel) => T;
getCount: (data: T) => number;
getItems: (data: T) => E[];
}
export interface IQueryResultContextState<
T extends QueryResult = QueryResult,
E extends IHasID = IHasID
> {
effectiveFilter: ListFilterModel;
result: T;
cachedResult: T;
items: E[];
totalCount: number;
}
export const QueryResultStateContext =
React.createContext<IQueryResultContextState | null>(null);
export const QueryResultContext = <
T extends QueryResult,
E extends IHasID = IHasID
>(
props: IQueryResultContextOptions<T, E> & {
children?:
| ((props: IQueryResultContextState<T, E>) => React.ReactNode)
| React.ReactNode;
}
) => {
const { filterHook, useResult, getItems, getCount, children } = props;
const { filter } = useFilter();
const effectiveFilter = useMemo(() => {
if (filterHook) {
return filterHook(filter.clone());
}
return filter;
}, [filter, filterHook]);
const result = useResult(effectiveFilter);
// use cached query result for pagination and metadata rendering
const cachedResult = useCachedQueryResult(effectiveFilter, result);
const items = useMemo(() => getItems(result), [getItems, result]);
const totalCount = useMemo(
() => getCount(cachedResult),
[getCount, cachedResult]
);
const state: IQueryResultContextState<T, E> = {
effectiveFilter,
result,
cachedResult,
items,
totalCount,
};
return (
<QueryResultStateContext.Provider value={state}>
{isFunction(children)
? (children as (props: IQueryResultContextState) => React.ReactNode)(
state
)
: children}
</QueryResultStateContext.Provider>
);
};
export function useQueryResultContext<
T extends QueryResult,
E extends IHasID = IHasID
>() {
const context = React.useContext(QueryResultStateContext);
if (context === null) {
throw new Error(
"useQueryResultContext must be used within a ListStateContext"
);
}
return context as IQueryResultContextState<T, E>;
}

View File

@@ -0,0 +1,98 @@
import React, { PropsWithChildren, useMemo } from "react";
import { QueryResult } from "@apollo/client";
import { ListFilterModel } from "src/models/list-filter/filter";
import { Pagination, PaginationIndex } from "./Pagination";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
export const PagedList: React.FC<
PropsWithChildren<{
result: QueryResult;
cachedResult: QueryResult;
filter: ListFilterModel;
totalCount: number;
onChangePage: (page: number) => void;
metadataByline?: React.ReactNode;
}>
> = ({
result,
cachedResult,
filter,
totalCount,
onChangePage,
metadataByline,
children,
}) => {
const pages = Math.ceil(totalCount / filter.itemsPerPage);
const pagination = useMemo(() => {
return (
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
onChangePage={onChangePage}
/>
);
}, [
filter.itemsPerPage,
filter.currentPage,
totalCount,
metadataByline,
onChangePage,
]);
const paginationIndex = useMemo(() => {
if (cachedResult.loading) return;
return (
<PaginationIndex
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
);
}, [
cachedResult.loading,
filter.itemsPerPage,
filter.currentPage,
totalCount,
metadataByline,
]);
const content = useMemo(() => {
if (result.loading) {
return <LoadingIndicator />;
}
if (result.error) {
return <h1>{result.error.message}</h1>;
}
return (
<>
{children}
{!!pages && (
<>
{paginationIndex}
{pagination}
</>
)}
</>
);
}, [
result.loading,
result.error,
pages,
children,
pagination,
paginationIndex,
]);
return (
<>
{pagination}
{paginationIndex}
{content}
</>
);
};

View File

@@ -1,11 +1,69 @@
import { useContext, useMemo } from "react"; import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import Mousetrap from "mousetrap";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import * as GQL from "src/core/generated-graphql"; import { useHistory, useLocation } from "react-router-dom";
import { isEqual, isFunction } from "lodash-es";
import { QueryResult } from "@apollo/client";
import { IHasID } from "src/utils/data";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { View } from "./views"; import { View } from "./views";
export function useDefaultFilter(mode: GQL.FilterMode, view?: View) { export function useFilterURL(
const emptyFilter = useMemo(() => new ListFilterModel(mode), [mode]); filter: ListFilterModel,
setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>,
options?: {
defaultFilter?: ListFilterModel;
setURL?: boolean;
}
) {
const { defaultFilter, setURL = true } = options ?? {};
const history = useHistory();
const location = useLocation();
// when the filter changes, update the URL
const updateFilter = useCallback(
(
value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel)
) => {
const newFilter = isFunction(value) ? value(filter) : value;
if (setURL) {
const newParams = newFilter.makeQueryParameters();
history.replace({ ...history.location, search: newParams });
} else {
// set the filter without updating the URL
setFilter(newFilter);
}
},
[history, setURL, setFilter, filter]
);
// This hook runs on every page location change (ie navigation),
// and updates the filter accordingly.
useEffect(() => {
// re-init to load default filter on empty new query params
if (!location.search) {
if (defaultFilter) updateFilter(defaultFilter.clone());
return;
}
// the query has changed, update filter if necessary
setFilter((prevFilter) => {
let newFilter = prevFilter.empty();
newFilter.configureFromQueryString(location.search);
if (!isEqual(newFilter, prevFilter)) {
return newFilter;
} else {
return prevFilter;
}
});
}, [location.search, defaultFilter, setFilter, updateFilter]);
return { setFilter: updateFilter };
}
export function useDefaultFilter(emptyFilter: ListFilterModel, view?: View) {
const { configuration: config, loading } = useContext(ConfigurationContext); const { configuration: config, loading } = useContext(ConfigurationContext);
const defaultFilter = useMemo(() => { const defaultFilter = useMemo(() => {
@@ -30,3 +88,258 @@ export function useDefaultFilter(mode: GQL.FilterMode, view?: View) {
return { defaultFilter: retFilter, loading }; return { defaultFilter: retFilter, loading };
} }
export function useListKeyboardShortcuts(props: {
currentPage?: number;
onChangePage?: (page: number) => void;
showEditFilter?: () => void;
pages?: number;
onSelectAll?: () => void;
onSelectNone?: () => void;
}) {
const {
currentPage,
onChangePage,
showEditFilter,
pages = 0,
onSelectAll,
onSelectNone,
} = props;
// set up hotkeys
useEffect(() => {
if (showEditFilter) {
Mousetrap.bind("f", (e) => {
showEditFilter();
// prevent default behavior of typing f in a text field
// otherwise the filter dialog closes, the query field is focused and
// f is typed.
e.preventDefault();
});
return () => {
Mousetrap.unbind("f");
};
}
}, [showEditFilter]);
useEffect(() => {
if (!currentPage || !changePage || !pages) return;
function changePage(page: number) {
if (!currentPage || !onChangePage || !pages) return;
if (page >= 1 && page <= pages) {
onChangePage(page);
}
}
Mousetrap.bind("right", () => {
changePage(currentPage + 1);
});
Mousetrap.bind("left", () => {
changePage(currentPage - 1);
});
Mousetrap.bind("shift+right", () => {
changePage(Math.min(pages, currentPage + 10));
});
Mousetrap.bind("shift+left", () => {
changePage(Math.max(1, currentPage - 10));
});
Mousetrap.bind("ctrl+end", () => {
changePage(pages);
});
Mousetrap.bind("ctrl+home", () => {
changePage(1);
});
return () => {
Mousetrap.unbind("right");
Mousetrap.unbind("left");
Mousetrap.unbind("shift+right");
Mousetrap.unbind("shift+left");
Mousetrap.unbind("ctrl+end");
Mousetrap.unbind("ctrl+home");
};
}, [currentPage, onChangePage, pages]);
useEffect(() => {
Mousetrap.bind("s a", () => onSelectAll?.());
Mousetrap.bind("s n", () => onSelectNone?.());
return () => {
Mousetrap.unbind("s a");
Mousetrap.unbind("s n");
};
}, [onSelectAll, onSelectNone]);
}
export function useListSelect<T extends { id: string }>(items: T[]) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string>();
function singleSelect(id: string, selected: boolean) {
setLastClickedId(id);
const newSelectedIds = new Set(selectedIds);
if (selected) {
newSelectedIds.add(id);
} else {
newSelectedIds.delete(id);
}
setSelectedIds(newSelectedIds);
}
function selectRange(startIndex: number, endIndex: number) {
let start = startIndex;
let end = endIndex;
if (start > end) {
const tmp = start;
start = end;
end = tmp;
}
const subset = items.slice(start, end + 1);
const newSelectedIds = new Set<string>();
subset.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
}
function multiSelect(id: string) {
let startIndex = 0;
let thisIndex = -1;
if (lastClickedId) {
startIndex = items.findIndex((item) => {
return item.id === lastClickedId;
});
}
thisIndex = items.findIndex((item) => {
return item.id === id;
});
selectRange(startIndex, thisIndex);
}
function onSelectChange(id: string, selected: boolean, shiftKey: boolean) {
if (shiftKey) {
multiSelect(id);
} else {
singleSelect(id, selected);
}
}
function onSelectAll() {
const newSelectedIds = new Set<string>();
items.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
function onSelectNone() {
const newSelectedIds = new Set<string>();
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
const getSelected = useMemo(() => {
let cached: T[] | undefined;
return () => {
if (cached) {
return cached;
}
cached = items.filter((value) => selectedIds.has(value.id));
return cached;
};
}, [items, selectedIds]);
return {
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
};
}
export type IListSelect<T extends IHasID> = ReturnType<typeof useListSelect<T>>;
// returns true if the filter has changed in a way that impacts the total count
function totalCountImpacted(
oldFilter: ListFilterModel,
newFilter: ListFilterModel
) {
return (
oldFilter.criteria.length !== newFilter.criteria.length ||
oldFilter.criteria.some((c) => {
const newCriterion = newFilter.criteria.find(
(nc) => nc.getId() === c.getId()
);
return !newCriterion || !isEqual(c, newCriterion);
})
);
}
// this hook caches a query result and count, and only updates it when the filter changes
// in a way that would impact the result count
// it is used to prevent the result count/pagination from flickering when changing pages or sorting
export function useCachedQueryResult<T extends QueryResult>(
filter: ListFilterModel,
result: T
) {
const [cachedResult, setCachedResult] = useState(result);
const [lastFilter, setLastFilter] = useState(filter);
// if we are only changing the page or sort, don't update the result count
useEffect(() => {
if (!result.loading) {
setCachedResult(result);
} else {
if (totalCountImpacted(lastFilter, filter)) {
setCachedResult(result);
}
}
setLastFilter(filter);
}, [filter, result, lastFilter]);
return cachedResult;
}
export function useScrollToTopOnPageChange(currentPage: number) {
// scroll to the top of the page when the page changes
useEffect(() => {
// if the current page has a detail-header, then
// scroll up relative to that rather than 0, 0
const detailHeader = document.querySelector(".detail-header");
if (detailHeader) {
window.scrollTo(0, detailHeader.scrollHeight - 50);
} else {
window.scrollTo(0, 0);
}
}, [currentPage]);
}
// handle case where page is more than there are pages
export function useEnsureValidPage(
filter: ListFilterModel,
totalCount: number,
setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>
) {
useEffect(() => {
const totalPages = Math.ceil(totalCount / filter.itemsPerPage);
if (totalPages > 0 && filter.currentPage > totalPages) {
setFilter((prevFilter) => prevFilter.changePage(1));
}
}, [filter, totalCount, setFilter]);
}

View File

@@ -9,7 +9,7 @@ import {
useFindPerformers, useFindPerformers,
usePerformersDestroy, usePerformersDestroy,
} from "src/core/StashService"; } from "src/core/StashService";
import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { PerformerTagger } from "../Tagger/performers/PerformerTagger"; import { PerformerTagger } from "../Tagger/performers/PerformerTagger";
@@ -23,16 +23,13 @@ import TextUtils from "src/utils/text";
import { PerformerCardGrid } from "./PerformerCardGrid"; import { PerformerCardGrid } from "./PerformerCardGrid";
import { View } from "../List/views"; import { View } from "../List/views";
const PerformerItemList = makeItemList({ function getItems(result: GQL.FindPerformersQueryResult) {
filterMode: GQL.FilterMode.Performers, return result?.data?.findPerformers?.performers ?? [];
useResult: useFindPerformers, }
getItems(result: GQL.FindPerformersQueryResult) {
return result?.data?.findPerformers?.performers ?? []; function getCount(result: GQL.FindPerformersQueryResult) {
}, return result?.data?.findPerformers?.count ?? 0;
getCount(result: GQL.FindPerformersQueryResult) { }
return result?.data?.findPerformers?.count ?? 0;
},
});
export const FormatHeight = (height?: number | null) => { export const FormatHeight = (height?: number | null) => {
const intl = useIntl(); const intl = useIntl();
@@ -175,6 +172,8 @@ export const PerformerList: React.FC<IPerformerList> = ({
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Performers;
const otherOperations = [ const otherOperations = [
{ {
text: intl.formatMessage({ id: "actions.open_random" }), text: intl.formatMessage({ id: "actions.open_random" }),
@@ -319,16 +318,24 @@ export const PerformerList: React.FC<IPerformerList> = ({
} }
return ( return (
<PerformerItemList <ItemListContext
selectable filterMode={filterMode}
useResult={useFindPerformers}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook} filterHook={filterHook}
view={view} view={view}
alterQuery={alterQuery} selectable
otherOperations={otherOperations} >
addKeybinds={addKeybinds} <ItemList
renderContent={renderContent} view={view}
renderEditDialog={renderEditDialog} otherOperations={otherOperations}
renderDeleteDialog={renderDeleteDialog} addKeybinds={addKeybinds}
/> renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
); );
}; };

View File

@@ -5,7 +5,7 @@ import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { queryFindScenes, useFindScenes } from "src/core/StashService"; import { queryFindScenes, useFindScenes } from "src/core/StashService";
import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { Tagger } from "../Tagger/scenes/SceneTagger"; import { Tagger } from "../Tagger/scenes/SceneTagger";
@@ -26,51 +26,49 @@ import { objectTitle } from "src/core/files";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { View } from "../List/views"; import { View } from "../List/views";
const SceneItemList = makeItemList({ function getItems(result: GQL.FindScenesQueryResult) {
filterMode: GQL.FilterMode.Scenes, return result?.data?.findScenes?.scenes ?? [];
useResult: useFindScenes, }
getItems(result: GQL.FindScenesQueryResult) {
return result?.data?.findScenes?.scenes ?? [];
},
getCount(result: GQL.FindScenesQueryResult) {
return result?.data?.findScenes?.count ?? 0;
},
renderMetadataByline(result: GQL.FindScenesQueryResult) {
const duration = result?.data?.findScenes?.duration;
const size = result?.data?.findScenes?.filesize;
const filesize = size ? TextUtils.fileSize(size) : undefined;
if (!duration && !size) { function getCount(result: GQL.FindScenesQueryResult) {
return; return result?.data?.findScenes?.count ?? 0;
} }
const separator = duration && size ? " - " : ""; function renderMetadataByline(result: GQL.FindScenesQueryResult) {
const duration = result?.data?.findScenes?.duration;
const size = result?.data?.findScenes?.filesize;
const filesize = size ? TextUtils.fileSize(size) : undefined;
return ( if (!duration && !size) {
<span className="scenes-stats"> return;
&nbsp;( }
{duration ? (
<span className="scenes-duration"> const separator = duration && size ? " - " : "";
{TextUtils.secondsAsTimeString(duration, 3)}
</span> return (
) : undefined} <span className="scenes-stats">
{separator} &nbsp;(
{size && filesize ? ( {duration ? (
<span className="scenes-size"> <span className="scenes-duration">
<FormattedNumber {TextUtils.secondsAsTimeString(duration, 3)}
value={filesize.size} </span>
maximumFractionDigits={TextUtils.fileSizeFractionalDigits( ) : undefined}
filesize.unit {separator}
)} {size && filesize ? (
/> <span className="scenes-size">
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} <FormattedNumber
</span> value={filesize.size}
) : undefined} maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
) filesize.unit
</span> )}
); />
}, {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
}); </span>
) : undefined}
)
</span>
);
}
interface ISceneList { interface ISceneList {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
@@ -95,6 +93,8 @@ export const SceneList: React.FC<ISceneList> = ({
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Scenes;
const otherOperations = [ const otherOperations = [
{ {
text: intl.formatMessage({ id: "actions.play_selected" }), text: intl.formatMessage({ id: "actions.play_selected" }),
@@ -350,19 +350,28 @@ export const SceneList: React.FC<ISceneList> = ({
return ( return (
<TaggerContext> <TaggerContext>
<SceneItemList <ItemListContext
zoomable filterMode={filterMode}
selectable defaultSort={defaultSort}
useResult={useFindScenes}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook} filterHook={filterHook}
view={view} view={view}
alterQuery={alterQuery} selectable
otherOperations={otherOperations} >
addKeybinds={addKeybinds} <ItemList
defaultSort={defaultSort} zoomable
renderContent={renderContent} view={view}
renderEditDialog={renderEditDialog} otherOperations={otherOperations}
renderDeleteDialog={renderDeleteDialog} addKeybinds={addKeybinds}
/> renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderMetadataByline={renderMetadataByline}
/>
</ItemListContext>
</TaggerContext> </TaggerContext>
); );
}; };

View File

@@ -9,22 +9,19 @@ import {
useFindSceneMarkers, useFindSceneMarkers,
} from "src/core/StashService"; } from "src/core/StashService";
import NavUtils from "src/utils/navigation"; import NavUtils from "src/utils/navigation";
import { makeItemList } from "../List/ItemList"; import { ItemList, ItemListContext } from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { MarkerWallPanel } from "../Wall/WallPanel"; import { MarkerWallPanel } from "../Wall/WallPanel";
import { View } from "../List/views"; import { View } from "../List/views";
const SceneMarkerItemList = makeItemList({ function getItems(result: GQL.FindSceneMarkersQueryResult) {
filterMode: GQL.FilterMode.SceneMarkers, return result?.data?.findSceneMarkers?.scene_markers ?? [];
useResult: useFindSceneMarkers, }
getItems(result: GQL.FindSceneMarkersQueryResult) {
return result?.data?.findSceneMarkers?.scene_markers ?? []; function getCount(result: GQL.FindSceneMarkersQueryResult) {
}, return result?.data?.findSceneMarkers?.count ?? 0;
getCount(result: GQL.FindSceneMarkersQueryResult) { }
return result?.data?.findSceneMarkers?.count ?? 0;
},
});
interface ISceneMarkerList { interface ISceneMarkerList {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
@@ -40,6 +37,8 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = ({
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const filterMode = GQL.FilterMode.SceneMarkers;
const otherOperations = [ const otherOperations = [
{ {
text: intl.formatMessage({ id: "actions.play_random" }), text: intl.formatMessage({ id: "actions.play_random" }),
@@ -97,14 +96,22 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = ({
} }
return ( return (
<SceneMarkerItemList <ItemListContext
filterMode={filterMode}
useResult={useFindSceneMarkers}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook} filterHook={filterHook}
view={view} view={view}
alterQuery={alterQuery} >
otherOperations={otherOperations} <ItemList
addKeybinds={addKeybinds} view={view}
renderContent={renderContent} otherOperations={otherOperations}
/> addKeybinds={addKeybinds}
renderContent={renderContent}
/>
</ItemListContext>
); );
}; };

View File

@@ -14,10 +14,7 @@ import cx from "classnames";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { useDebounce } from "src/hooks/debounce"; import { useDebounce } from "src/hooks/debounce";
import { IHasID } from "src/utils/data";
interface IHasID {
id: string;
}
export type Option<T> = { value: string; object: T }; export type Option<T> = { value: string; object: T };

View File

@@ -1,3 +1,23 @@
.LoadingIndicator {
// fade in animation - delay showing
animation: fadeInAnimation ease 200ms;
animation-delay: 200ms;
animation-fill-mode: forwards;
animation-iteration-count: 1;
opacity: 0;
}
@keyframes fadeInAnimation {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.LoadingIndicator { .LoadingIndicator {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -6,7 +26,7 @@
width: 100%; width: 100%;
&:not(.card-based) { &:not(.card-based) {
height: 70vh; padding-top: 2rem;
} }
&-message { &-message {

View File

@@ -1,4 +1,4 @@
import { Tabs, Tab } from "react-bootstrap"; import { Tabs, Tab, Form } from "react-bootstrap";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@@ -79,16 +79,18 @@ const StudioTabs: React.FC<{
abbreviateCounter: boolean; abbreviateCounter: boolean;
showAllCounts?: boolean; showAllCounts?: boolean;
}> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => { }> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => {
const [showAllDetails, setShowAllDetails] = useState<boolean>(showAllCounts);
const sceneCount = const sceneCount =
(showAllCounts ? studio.scene_count_all : studio.scene_count) ?? 0; (showAllDetails ? studio.scene_count_all : studio.scene_count) ?? 0;
const galleryCount = const galleryCount =
(showAllCounts ? studio.gallery_count_all : studio.gallery_count) ?? 0; (showAllDetails ? studio.gallery_count_all : studio.gallery_count) ?? 0;
const imageCount = const imageCount =
(showAllCounts ? studio.image_count_all : studio.image_count) ?? 0; (showAllDetails ? studio.image_count_all : studio.image_count) ?? 0;
const performerCount = const performerCount =
(showAllCounts ? studio.performer_count_all : studio.performer_count) ?? 0; (showAllDetails ? studio.performer_count_all : studio.performer_count) ?? 0;
const groupCount = const groupCount =
(showAllCounts ? studio.group_count_all : studio.group_count) ?? 0; (showAllDetails ? studio.group_count_all : studio.group_count) ?? 0;
const populatedDefaultTab = useMemo(() => { const populatedDefaultTab = useMemo(() => {
let ret: TabKey = "scenes"; let ret: TabKey = "scenes";
@@ -123,6 +125,21 @@ const StudioTabs: React.FC<{
baseURL: `/studios/${studio.id}`, baseURL: `/studios/${studio.id}`,
}); });
const contentSwitch = useMemo(
() => (
<div className="item-list-header">
<Form.Check
id="showSubContent"
checked={showAllDetails}
onChange={() => setShowAllDetails(!showAllDetails)}
type="switch"
label={<FormattedMessage id="include_sub_studio_content" />}
/>
</div>
),
[showAllDetails]
);
return ( return (
<Tabs <Tabs
id="studio-tabs" id="studio-tabs"
@@ -141,7 +158,12 @@ const StudioTabs: React.FC<{
/> />
} }
> >
<StudioScenesPanel active={tabKey === "scenes"} studio={studio} /> {contentSwitch}
<StudioScenesPanel
active={tabKey === "scenes"}
studio={studio}
showChildStudioContent={showAllDetails}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="galleries" eventKey="galleries"
@@ -153,7 +175,12 @@ const StudioTabs: React.FC<{
/> />
} }
> >
<StudioGalleriesPanel active={tabKey === "galleries"} studio={studio} /> {contentSwitch}
<StudioGalleriesPanel
active={tabKey === "galleries"}
studio={studio}
showChildStudioContent={showAllDetails}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="images" eventKey="images"
@@ -165,7 +192,12 @@ const StudioTabs: React.FC<{
/> />
} }
> >
<StudioImagesPanel active={tabKey === "images"} studio={studio} /> {contentSwitch}
<StudioImagesPanel
active={tabKey === "images"}
studio={studio}
showChildStudioContent={showAllDetails}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="performers" eventKey="performers"
@@ -177,9 +209,11 @@ const StudioTabs: React.FC<{
/> />
} }
> >
{contentSwitch}
<StudioPerformersPanel <StudioPerformersPanel
active={tabKey === "performers"} active={tabKey === "performers"}
studio={studio} studio={studio}
showChildStudioContent={showAllDetails}
/> />
</Tab> </Tab>
<Tab <Tab
@@ -192,7 +226,12 @@ const StudioTabs: React.FC<{
/> />
} }
> >
<StudioGroupsPanel active={tabKey === "groups"} studio={studio} /> {contentSwitch}
<StudioGroupsPanel
active={tabKey === "groups"}
studio={studio}
showChildStudioContent={showAllDetails}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="childstudios" eventKey="childstudios"

View File

@@ -5,16 +5,8 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { StudioList } from "../StudioList"; import { StudioList } from "../StudioList";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
interface IStudioChildrenPanel { function useFilterHook(studio: GQL.StudioDataFragment) {
active: boolean; return (filter: ListFilterModel) => {
studio: GQL.StudioDataFragment;
}
export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({
active,
studio,
}) => {
function filterHook(filter: ListFilterModel) {
const studioValue = { id: studio.id!, label: studio.name! }; const studioValue = { id: studio.id!, label: studio.name! };
// if studio is already present, then we modify it, otherwise add // if studio is already present, then we modify it, otherwise add
let parentStudioCriterion = filter.criteria.find((c) => { let parentStudioCriterion = filter.criteria.find((c) => {
@@ -44,7 +36,19 @@ export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({
} }
return filter; return filter;
} };
}
interface IStudioChildrenPanel {
active: boolean;
studio: GQL.StudioDataFragment;
}
export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({
active,
studio,
}) => {
const filterHook = useFilterHook(studio);
return ( return (
<StudioList <StudioList

View File

@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
interface IStudioGalleriesPanel { interface IStudioGalleriesPanel {
active: boolean; active: boolean;
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
showChildStudioContent?: boolean;
} }
export const StudioGalleriesPanel: React.FC<IStudioGalleriesPanel> = ({ export const StudioGalleriesPanel: React.FC<IStudioGalleriesPanel> = ({
active, active,
studio, studio,
showChildStudioContent,
}) => { }) => {
const filterHook = useStudioFilterHook(studio); const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return ( return (
<GalleryList <GalleryList
filterHook={filterHook} filterHook={filterHook}

View File

@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
interface IStudioGroupsPanel { interface IStudioGroupsPanel {
active: boolean; active: boolean;
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
showChildStudioContent?: boolean;
} }
export const StudioGroupsPanel: React.FC<IStudioGroupsPanel> = ({ export const StudioGroupsPanel: React.FC<IStudioGroupsPanel> = ({
active, active,
studio, studio,
showChildStudioContent,
}) => { }) => {
const filterHook = useStudioFilterHook(studio); const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return ( return (
<GroupList <GroupList
filterHook={filterHook} filterHook={filterHook}

View File

@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
interface IStudioImagesPanel { interface IStudioImagesPanel {
active: boolean; active: boolean;
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
showChildStudioContent?: boolean;
} }
export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({ export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({
active, active,
studio, studio,
showChildStudioContent,
}) => { }) => {
const filterHook = useStudioFilterHook(studio); const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return ( return (
<ImageList <ImageList
filterHook={filterHook} filterHook={filterHook}

View File

@@ -8,11 +8,13 @@ import { View } from "src/components/List/views";
interface IStudioPerformersPanel { interface IStudioPerformersPanel {
active: boolean; active: boolean;
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
showChildStudioContent?: boolean;
} }
export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({ export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
active, active,
studio, studio,
showChildStudioContent,
}) => { }) => {
const studioCriterion = new StudiosCriterion(); const studioCriterion = new StudiosCriterion();
studioCriterion.value = { studioCriterion.value = {
@@ -28,7 +30,7 @@ export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
groups: [studioCriterion], groups: [studioCriterion],
}; };
const filterHook = useStudioFilterHook(studio); const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return ( return (
<PerformerList <PerformerList

View File

@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
interface IStudioScenesPanel { interface IStudioScenesPanel {
active: boolean; active: boolean;
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
showChildStudioContent?: boolean;
} }
export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({ export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({
active, active,
studio, studio,
showChildStudioContent,
}) => { }) => {
const filterHook = useStudioFilterHook(studio); const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return ( return (
<SceneList <SceneList
filterHook={filterHook} filterHook={filterHook}

View File

@@ -9,7 +9,7 @@ import {
useFindStudios, useFindStudios,
useStudiosDestroy, useStudiosDestroy,
} from "src/core/StashService"; } from "src/core/StashService";
import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { ExportDialog } from "../Shared/ExportDialog"; import { ExportDialog } from "../Shared/ExportDialog";
@@ -18,16 +18,13 @@ import { StudioTagger } from "../Tagger/studios/StudioTagger";
import { StudioCardGrid } from "./StudioCardGrid"; import { StudioCardGrid } from "./StudioCardGrid";
import { View } from "../List/views"; import { View } from "../List/views";
const StudioItemList = makeItemList({ function getItems(result: GQL.FindStudiosQueryResult) {
filterMode: GQL.FilterMode.Studios, return result?.data?.findStudios?.studios ?? [];
useResult: useFindStudios, }
getItems(result: GQL.FindStudiosQueryResult) {
return result?.data?.findStudios?.studios ?? []; function getCount(result: GQL.FindStudiosQueryResult) {
}, return result?.data?.findStudios?.count ?? 0;
getCount(result: GQL.FindStudiosQueryResult) { }
return result?.data?.findStudios?.count ?? 0;
},
});
interface IStudioList { interface IStudioList {
fromParent?: boolean; fromParent?: boolean;
@@ -47,6 +44,8 @@ export const StudioList: React.FC<IStudioList> = ({
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Studios;
const otherOperations = [ const otherOperations = [
{ {
text: intl.formatMessage({ id: "actions.view_random" }), text: intl.formatMessage({ id: "actions.view_random" }),
@@ -177,15 +176,23 @@ export const StudioList: React.FC<IStudioList> = ({
} }
return ( return (
<StudioItemList <ItemListContext
selectable filterMode={filterMode}
useResult={useFindStudios}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook} filterHook={filterHook}
view={view} view={view}
alterQuery={alterQuery} selectable
otherOperations={otherOperations} >
addKeybinds={addKeybinds} <ItemList
renderContent={renderContent} view={view}
renderDeleteDialog={renderDeleteDialog} otherOperations={otherOperations}
/> addKeybinds={addKeybinds}
renderContent={renderContent}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { Tabs, Tab, Dropdown } from "react-bootstrap"; import { Tabs, Tab, Dropdown, Form } from "react-bootstrap";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@@ -82,20 +82,22 @@ const TagTabs: React.FC<{
abbreviateCounter: boolean; abbreviateCounter: boolean;
showAllCounts?: boolean; showAllCounts?: boolean;
}> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => { }> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => {
const [showAllDetails, setShowAllDetails] = useState<boolean>(showAllCounts);
const sceneCount = const sceneCount =
(showAllCounts ? tag.scene_count_all : tag.scene_count) ?? 0; (showAllDetails ? tag.scene_count_all : tag.scene_count) ?? 0;
const imageCount = const imageCount =
(showAllCounts ? tag.image_count_all : tag.image_count) ?? 0; (showAllDetails ? tag.image_count_all : tag.image_count) ?? 0;
const galleryCount = const galleryCount =
(showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0; (showAllDetails ? tag.gallery_count_all : tag.gallery_count) ?? 0;
const groupCount = const groupCount =
(showAllCounts ? tag.group_count_all : tag.group_count) ?? 0; (showAllDetails ? tag.group_count_all : tag.group_count) ?? 0;
const sceneMarkerCount = const sceneMarkerCount =
(showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; (showAllDetails ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0;
const performerCount = const performerCount =
(showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0; (showAllDetails ? tag.performer_count_all : tag.performer_count) ?? 0;
const studioCount = const studioCount =
(showAllCounts ? tag.studio_count_all : tag.studio_count) ?? 0; (showAllDetails ? tag.studio_count_all : tag.studio_count) ?? 0;
const populatedDefaultTab = useMemo(() => { const populatedDefaultTab = useMemo(() => {
let ret: TabKey = "scenes"; let ret: TabKey = "scenes";
@@ -133,6 +135,21 @@ const TagTabs: React.FC<{
baseURL: `/tags/${tag.id}`, baseURL: `/tags/${tag.id}`,
}); });
const contentSwitch = useMemo(
() => (
<div className="item-list-header">
<Form.Check
id="showSubContent"
checked={showAllDetails}
onChange={() => setShowAllDetails(!showAllDetails)}
type="switch"
label={<FormattedMessage id="include_sub_tag_content" />}
/>
</div>
),
[showAllDetails]
);
return ( return (
<Tabs <Tabs
id="tag-tabs" id="tag-tabs"
@@ -151,7 +168,12 @@ const TagTabs: React.FC<{
/> />
} }
> >
<TagScenesPanel active={tabKey === "scenes"} tag={tag} /> {contentSwitch}
<TagScenesPanel
active={tabKey === "scenes"}
tag={tag}
showSubTagContent={showAllDetails}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="images" eventKey="images"
@@ -163,7 +185,12 @@ const TagTabs: React.FC<{
/> />
} }
> >
<TagImagesPanel active={tabKey === "images"} tag={tag} /> {contentSwitch}
<TagImagesPanel
active={tabKey === "images"}
tag={tag}
showSubTagContent={showAllDetails}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="galleries" eventKey="galleries"
@@ -175,7 +202,12 @@ const TagTabs: React.FC<{
/> />
} }
> >
<TagGalleriesPanel active={tabKey === "galleries"} tag={tag} /> {contentSwitch}
<TagGalleriesPanel
active={tabKey === "galleries"}
tag={tag}
showSubTagContent={showAllDetails}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="groups" eventKey="groups"
@@ -187,7 +219,12 @@ const TagTabs: React.FC<{
/> />
} }
> >
<TagGroupsPanel active={tabKey === "groups"} tag={tag} /> {contentSwitch}
<TagGroupsPanel
active={tabKey === "groups"}
tag={tag}
showSubTagContent={showAllDetails}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="markers" eventKey="markers"
@@ -199,7 +236,12 @@ const TagTabs: React.FC<{
/> />
} }
> >
<TagMarkersPanel active={tabKey === "markers"} tag={tag} /> {contentSwitch}
<TagMarkersPanel
active={tabKey === "markers"}
tag={tag}
showSubTagContent={showAllDetails}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="performers" eventKey="performers"
@@ -211,7 +253,12 @@ const TagTabs: React.FC<{
/> />
} }
> >
<TagPerformersPanel active={tabKey === "performers"} tag={tag} /> {contentSwitch}
<TagPerformersPanel
active={tabKey === "performers"}
tag={tag}
showSubTagContent={showAllDetails}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="studios" eventKey="studios"
@@ -223,7 +270,12 @@ const TagTabs: React.FC<{
/> />
} }
> >
<TagStudiosPanel active={tabKey === "studios"} tag={tag} /> {contentSwitch}
<TagStudiosPanel
active={tabKey === "studios"}
tag={tag}
showSubTagContent={showAllDetails}
/>
</Tab> </Tab>
</Tabs> </Tabs>
); );

View File

@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
interface ITagGalleriesPanel { interface ITagGalleriesPanel {
active: boolean; active: boolean;
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
showSubTagContent?: boolean;
} }
export const TagGalleriesPanel: React.FC<ITagGalleriesPanel> = ({ export const TagGalleriesPanel: React.FC<ITagGalleriesPanel> = ({
active, active,
tag, tag,
showSubTagContent,
}) => { }) => {
const filterHook = useTagFilterHook(tag); const filterHook = useTagFilterHook(tag, showSubTagContent);
return ( return (
<GalleryList <GalleryList
filterHook={filterHook} filterHook={filterHook}

View File

@@ -6,7 +6,8 @@ import { GroupList } from "src/components/Groups/GroupList";
export const TagGroupsPanel: React.FC<{ export const TagGroupsPanel: React.FC<{
active: boolean; active: boolean;
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
}> = ({ active, tag }) => { showSubTagContent?: boolean;
const filterHook = useTagFilterHook(tag); }> = ({ active, tag, showSubTagContent }) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
return <GroupList filterHook={filterHook} alterQuery={active} />; return <GroupList filterHook={filterHook} alterQuery={active} />;
}; };

View File

@@ -7,10 +7,15 @@ import { View } from "src/components/List/views";
interface ITagImagesPanel { interface ITagImagesPanel {
active: boolean; active: boolean;
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
showSubTagContent?: boolean;
} }
export const TagImagesPanel: React.FC<ITagImagesPanel> = ({ active, tag }) => { export const TagImagesPanel: React.FC<ITagImagesPanel> = ({
const filterHook = useTagFilterHook(tag); active,
tag,
showSubTagContent,
}) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
return ( return (
<ImageList <ImageList
filterHook={filterHook} filterHook={filterHook}

View File

@@ -8,16 +8,8 @@ import {
import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList"; import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
interface ITagMarkersPanel { function useFilterHook(tag: GQL.TagDataFragment, showSubTagContent?: boolean) {
active: boolean; return (filter: ListFilterModel) => {
tag: GQL.TagDataFragment;
}
export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({
active,
tag,
}) => {
function filterHook(filter: ListFilterModel) {
const tagValue = { id: tag.id, label: tag.name }; const tagValue = { id: tag.id, label: tag.name };
// if tag is already present, then we modify it, otherwise add // if tag is already present, then we modify it, otherwise add
let tagCriterion = filter.criteria.find((c) => { let tagCriterion = filter.criteria.find((c) => {
@@ -45,13 +37,27 @@ export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({
tagCriterion.value = { tagCriterion.value = {
items: [tagValue], items: [tagValue],
excluded: [], excluded: [],
depth: 0, depth: showSubTagContent ? -1 : 0,
}; };
filter.criteria.push(tagCriterion); filter.criteria.push(tagCriterion);
} }
return filter; return filter;
} };
}
interface ITagMarkersPanel {
active: boolean;
tag: GQL.TagDataFragment;
showSubTagContent?: boolean;
}
export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({
active,
tag,
showSubTagContent,
}) => {
const filterHook = useFilterHook(tag, showSubTagContent);
return ( return (
<SceneMarkerList <SceneMarkerList

View File

@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
interface ITagPerformersPanel { interface ITagPerformersPanel {
active: boolean; active: boolean;
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
showSubTagContent?: boolean;
} }
export const TagPerformersPanel: React.FC<ITagPerformersPanel> = ({ export const TagPerformersPanel: React.FC<ITagPerformersPanel> = ({
active, active,
tag, tag,
showSubTagContent,
}) => { }) => {
const filterHook = useTagFilterHook(tag); const filterHook = useTagFilterHook(tag, showSubTagContent);
return ( return (
<PerformerList <PerformerList
filterHook={filterHook} filterHook={filterHook}

View File

@@ -7,10 +7,15 @@ import { View } from "src/components/List/views";
interface ITagScenesPanel { interface ITagScenesPanel {
active: boolean; active: boolean;
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
showSubTagContent?: boolean;
} }
export const TagScenesPanel: React.FC<ITagScenesPanel> = ({ active, tag }) => { export const TagScenesPanel: React.FC<ITagScenesPanel> = ({
const filterHook = useTagFilterHook(tag); active,
tag,
showSubTagContent,
}) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
return ( return (
<SceneList <SceneList
filterHook={filterHook} filterHook={filterHook}

View File

@@ -6,12 +6,14 @@ import { StudioList } from "src/components/Studios/StudioList";
interface ITagStudiosPanel { interface ITagStudiosPanel {
active: boolean; active: boolean;
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
showSubTagContent?: boolean;
} }
export const TagStudiosPanel: React.FC<ITagStudiosPanel> = ({ export const TagStudiosPanel: React.FC<ITagStudiosPanel> = ({
active, active,
tag, tag,
showSubTagContent,
}) => { }) => {
const filterHook = useTagFilterHook(tag); const filterHook = useTagFilterHook(tag, showSubTagContent);
return <StudioList filterHook={filterHook} alterQuery={active} />; return <StudioList filterHook={filterHook} alterQuery={active} />;
}; };

View File

@@ -3,7 +3,7 @@ import cloneDeep from "lodash-es/cloneDeep";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { Link, useHistory } from "react-router-dom"; import { Link, useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -27,27 +27,27 @@ import { TagCardGrid } from "./TagCardGrid";
import { EditTagsDialog } from "./EditTagsDialog"; import { EditTagsDialog } from "./EditTagsDialog";
import { View } from "../List/views"; import { View } from "../List/views";
function getItems(result: GQL.FindTagsQueryResult) {
return result?.data?.findTags?.tags ?? [];
}
function getCount(result: GQL.FindTagsQueryResult) {
return result?.data?.findTags?.count ?? 0;
}
interface ITagList { interface ITagList {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
alterQuery?: boolean; alterQuery?: boolean;
} }
const TagItemList = makeItemList({
filterMode: GQL.FilterMode.Tags,
useResult: useFindTags,
getItems(result: GQL.FindTagsQueryResult) {
return result?.data?.findTags?.tags ?? [];
},
getCount(result: GQL.FindTagsQueryResult) {
return result?.data?.findTags?.count ?? 0;
},
});
export const TagList: React.FC<ITagList> = ({ filterHook, alterQuery }) => { export const TagList: React.FC<ITagList> = ({ filterHook, alterQuery }) => {
const Toast = useToast(); const Toast = useToast();
const [deletingTag, setDeletingTag] = const [deletingTag, setDeletingTag] =
useState<Partial<GQL.TagDataFragment> | null>(null); useState<Partial<GQL.TagDataFragment> | null>(null);
const filterMode = GQL.FilterMode.Tags;
const view = View.Tags;
function getDeleteTagInput() { function getDeleteTagInput() {
const tagInput: Partial<GQL.TagDestroyInput> = {}; const tagInput: Partial<GQL.TagDestroyInput> = {};
if (deletingTag) { if (deletingTag) {
@@ -355,18 +355,25 @@ export const TagList: React.FC<ITagList> = ({ filterHook, alterQuery }) => {
} }
return ( return (
<TagItemList <ItemListContext
selectable filterMode={filterMode}
zoomable useResult={useFindTags}
defaultZoomIndex={0} getItems={getItems}
filterHook={filterHook} getCount={getCount}
view={View.Tags}
alterQuery={alterQuery} alterQuery={alterQuery}
otherOperations={otherOperations} filterHook={filterHook}
addKeybinds={addKeybinds} view={view}
renderContent={renderContent} selectable
renderDeleteDialog={renderDeleteDialog} >
renderEditDialog={renderEditDialog} <ItemList
/> view={view}
zoomable
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
); );
}; };

View File

@@ -1,11 +1,11 @@
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import React from "react";
import { ConfigurationContext } from "src/hooks/Config";
export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => { export const useStudioFilterHook = (
const { configuration } = React.useContext(ConfigurationContext); studio: GQL.StudioDataFragment,
showChildStudioContent?: boolean
) => {
return (filter: ListFilterModel) => { return (filter: ListFilterModel) => {
const studioValue = { id: studio.id, label: studio.name }; const studioValue = { id: studio.id, label: studio.name };
// if studio is already present, then we modify it, otherwise add // if studio is already present, then we modify it, otherwise add
@@ -22,7 +22,7 @@ export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => {
studioCriterion.value = { studioCriterion.value = {
items: [studioValue], items: [studioValue],
excluded: [], excluded: [],
depth: configuration?.ui.showChildStudioContent ? -1 : 0, depth: showChildStudioContent ? -1 : 0,
}; };
studioCriterion.modifier = GQL.CriterionModifier.Includes; studioCriterion.modifier = GQL.CriterionModifier.Includes;
filter.criteria.push(studioCriterion); filter.criteria.push(studioCriterion);

View File

@@ -6,11 +6,11 @@ import {
TagsCriterionOption, TagsCriterionOption,
} from "src/models/list-filter/criteria/tags"; } from "src/models/list-filter/criteria/tags";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import React from "react";
import { ConfigurationContext } from "src/hooks/Config";
export const useTagFilterHook = (tag: GQL.TagDataFragment) => { export const useTagFilterHook = (
const { configuration } = React.useContext(ConfigurationContext); tag: GQL.TagDataFragment,
showSubTagContent?: boolean
) => {
return (filter: ListFilterModel) => { return (filter: ListFilterModel) => {
const tagValue = { id: tag.id, label: tag.name }; const tagValue = { id: tag.id, label: tag.name };
// if tag is already present, then we modify it, otherwise add // if tag is already present, then we modify it, otherwise add
@@ -42,7 +42,7 @@ export const useTagFilterHook = (tag: GQL.TagDataFragment) => {
tagCriterion.value = { tagCriterion.value = {
items: [tagValue], items: [tagValue],
excluded: [], excluded: [],
depth: configuration?.ui.showChildTagContent ? -1 : 0, depth: showSubTagContent ? -1 : 0,
}; };
tagCriterion.modifier = GQL.CriterionModifier.IncludesAll; tagCriterion.modifier = GQL.CriterionModifier.IncludesAll;
filter.criteria.push(tagCriterion); filter.criteria.push(tagCriterion);

View File

@@ -0,0 +1,10 @@
import React from "react";
export function useModal() {
const [modal, setModal] = React.useState<React.ReactNode>();
const closeModal = () => setModal(undefined);
const showModal = (m: React.ReactNode) => setModal(m);
return { modal, closeModal, showModal };
}

View File

@@ -258,6 +258,15 @@ dd {
padding: 5px 0; padding: 5px 0;
} }
.item-list-header {
align-content: center;
// border-bottom: solid 2px #192127;
display: flex;
justify-content: center;
margin: 0;
padding: 5px 0 0 0;
}
.item-list-container { .item-list-container {
padding-top: 15px; padding-top: 15px;

View File

@@ -1086,7 +1086,9 @@
"image_index": "Image #", "image_index": "Image #",
"images": "Images", "images": "Images",
"include_parent_tags": "Include parent tags", "include_parent_tags": "Include parent tags",
"include_sub_studio_content": "Include sub-studio content",
"include_sub_studios": "Include subsidiary studios", "include_sub_studios": "Include subsidiary studios",
"include_sub_tag_content": "Include sub-tag content",
"include_sub_tags": "Include sub-tags", "include_sub_tags": "Include sub-tags",
"index_of_total": "{index} of {total}", "index_of_total": "{index} of {total}",
"instagram": "Instagram", "instagram": "Instagram",

View File

@@ -89,6 +89,15 @@ export abstract class Criterion<V extends CriterionValue> {
this.value = value; this.value = value;
} }
public clone(): Criterion<V> {
const newCriterion = new (this.constructor as new (
type: CriterionOption,
value: V
) => Criterion<V>)(this.criterionOption, this.value);
newCriterion.modifier = this.modifier;
return newCriterion;
}
public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) { public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) {
const modifierMessageID = modifierMessageIDs[modifier]; const modifierMessageID = modifierMessageIDs[modifier];
@@ -251,6 +260,19 @@ export class ILabeledIdCriterionOption extends CriterionOption {
} }
export class ILabeledIdCriterion extends Criterion<ILabeledId[]> { export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
constructor(type: CriterionOption, value: ILabeledId[] = []) {
super(type, value);
}
public clone(): Criterion<ILabeledId[]> {
const newCriterion = new ILabeledIdCriterion(
this.criterionOption,
this.value.map((v) => ({ ...v }))
);
newCriterion.modifier = this.modifier;
return newCriterion;
}
protected getLabelValue(_intl: IntlShape): string { protected getLabelValue(_intl: IntlShape): string {
return this.value.map((v) => v.label).join(", "); return this.value.map((v) => v.label).join(", ");
} }
@@ -272,23 +294,33 @@ export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
return this.value.length > 0; return this.value.length > 0;
} }
constructor(type: CriterionOption) {
super(type, []);
}
} }
export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> { export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> {
constructor(type: CriterionOption) { constructor(
const value: IHierarchicalLabelValue = { type: CriterionOption,
value: IHierarchicalLabelValue = {
items: [], items: [],
excluded: [], excluded: [],
depth: 0, depth: 0,
}; }
) {
super(type, value); super(type, value);
} }
public clone(): Criterion<IHierarchicalLabelValue> {
const newCriterion = new IHierarchicalLabeledIdCriterion(
this.criterionOption,
{
...this.value,
items: this.value.items.map((v) => ({ ...v })),
excluded: this.value.excluded.map((v) => ({ ...v })),
}
);
newCriterion.modifier = this.modifier;
return newCriterion;
}
override get modifier(): CriterionModifier { override get modifier(): CriterionModifier {
return this._modifier; return this._modifier;
} }
@@ -501,8 +533,17 @@ export class StringCriterion extends Criterion<string> {
} }
export class MultiStringCriterion extends Criterion<string[]> { export class MultiStringCriterion extends Criterion<string[]> {
constructor(type: CriterionOption) { constructor(type: CriterionOption, value: string[] = []) {
super(type, []); super(type, value);
}
public clone(): Criterion<string[]> {
const newCriterion = new MultiStringCriterion(
this.criterionOption,
this.value.slice()
);
newCriterion.modifier = this.modifier;
return newCriterion;
} }
protected getLabelValue(_intl: IntlShape) { protected getLabelValue(_intl: IntlShape) {

View File

@@ -67,26 +67,48 @@ export class ListFilterModel {
public constructor( public constructor(
mode: FilterMode, mode: FilterMode,
config?: ConfigDataFragment, config?: ConfigDataFragment,
defaultZoomIndex?: number options?: {
defaultZoomIndex?: number;
defaultSortBy?: string;
defaultSortDir?: SortDirectionEnum;
}
) { ) {
this.mode = mode; this.mode = mode;
this.config = config; this.config = config;
this.options = getFilterOptions(mode); this.options = getFilterOptions(mode);
const { defaultSortBy, displayModeOptions } = this.options; const { defaultSortBy, displayModeOptions } = this.options;
this.sortBy = defaultSortBy; if (options?.defaultSortBy) {
if (this.sortBy === "date") { this.sortBy = options.defaultSortBy;
this.sortDirection = SortDirectionEnum.Desc; if (options.defaultSortDir) {
this.sortDirection = options.defaultSortDir;
}
} else {
this.sortBy = defaultSortBy;
if (this.sortBy === "date") {
this.sortDirection = SortDirectionEnum.Desc;
}
} }
this.displayMode = displayModeOptions[0]; this.displayMode = displayModeOptions[0];
if (defaultZoomIndex !== undefined) { if (options?.defaultZoomIndex !== undefined) {
this.defaultZoomIndex = defaultZoomIndex; this.defaultZoomIndex = options.defaultZoomIndex;
this.zoomIndex = defaultZoomIndex; this.zoomIndex = options.defaultZoomIndex;
} }
} }
public clone() { public clone() {
return Object.assign(new ListFilterModel(this.mode, this.config), this); const ret = Object.assign(
new ListFilterModel(this.mode, this.config),
this
);
ret.criteria = this.criteria.map((c) => c.clone());
return ret;
}
public empty() {
return new ListFilterModel(this.mode, this.config, {
defaultZoomIndex: this.defaultZoomIndex,
});
} }
// returns the number of filters applied // returns the number of filters applied
@@ -443,4 +465,44 @@ export class ListFilterModel {
zoom_index: this.zoomIndex, zoom_index: this.zoomIndex,
}; };
} }
public clearCriteria() {
const ret = this.clone();
ret.criteria = [];
ret.currentPage = 1;
return ret;
}
public removeCriterion(type: CriterionType) {
const ret = this.clone();
const c = ret.criteria.find((cc) => cc.criterionOption.type === type);
if (!c) return ret;
const newCriteria = ret.criteria.filter((cc) => {
return cc.getId() !== c.getId();
});
ret.criteria = newCriteria;
ret.currentPage = 1;
return ret;
}
public changePage(page: number) {
const ret = this.clone();
ret.currentPage = page;
return ret;
}
public setZoom(zoomIndex: number) {
const ret = this.clone();
ret.zoomIndex = zoomIndex;
return ret;
}
public setDisplayMode(displayMode: DisplayMode) {
const ret = this.clone();
ret.displayMode = displayMode;
return ret;
}
} }

View File

@@ -1,5 +1,6 @@
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import isEqual from "lodash-es/isEqual"; import isEqual from "lodash-es/isEqual";
import { IHasID } from "./data";
interface IHasRating { interface IHasRating {
rating100?: GQL.Maybe<number> | undefined; rating100?: GQL.Maybe<number> | undefined;
@@ -21,10 +22,6 @@ export function getAggregateRating(state: IHasRating[]) {
return ret; return ret;
} }
interface IHasID {
id: string;
}
interface IHasStudio { interface IHasStudio {
studio?: GQL.Maybe<IHasID> | undefined; studio?: GQL.Maybe<IHasID> | undefined;
} }

View File

@@ -1,6 +1,10 @@
export const filterData = <T>(data?: (T | null | undefined)[] | null) => export const filterData = <T>(data?: (T | null | undefined)[] | null) =>
data ? (data.filter((item) => item) as T[]) : []; data ? (data.filter((item) => item) as T[]) : [];
export interface IHasID {
id: string;
}
export interface ITypename { export interface ITypename {
__typename?: string; __typename?: string;
} }