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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 Mousetrap from "mousetrap";
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 { DisplayMode } from "src/models/list-filter/types";
import { queryFindGalleries, useFindGalleries } from "src/core/StashService";
@ -16,16 +16,13 @@ import { GalleryListTable } from "./GalleryListTable";
import { GalleryCardGrid } from "./GalleryGridCard";
import { View } from "../List/views";
const GalleryItemList = makeItemList({
filterMode: GQL.FilterMode.Galleries,
useResult: useFindGalleries,
getItems(result: GQL.FindGalleriesQueryResult) {
return result?.data?.findGalleries?.galleries ?? [];
},
getCount(result: GQL.FindGalleriesQueryResult) {
return result?.data?.findGalleries?.count ?? 0;
},
});
function getItems(result: GQL.FindGalleriesQueryResult) {
return result?.data?.findGalleries?.galleries ?? [];
}
function getCount(result: GQL.FindGalleriesQueryResult) {
return result?.data?.findGalleries?.count ?? 0;
}
interface IGalleryList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
@ -43,6 +40,8 @@ export const GalleryList: React.FC<IGalleryList> = ({
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Galleries;
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.view_random" }),
@ -185,17 +184,25 @@ export const GalleryList: React.FC<IGalleryList> = ({
}
return (
<GalleryItemList
zoomable
selectable
<ItemListContext
filterMode={filterMode}
useResult={useFindGalleries}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
selectable
>
<ItemList
zoomable
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
);
};

View File

@ -11,23 +11,20 @@ import {
useFindGroups,
useGroupsDestroy,
} from "src/core/StashService";
import { makeItemList, showWhenSelected } from "../List/ItemList";
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { ExportDialog } from "../Shared/ExportDialog";
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
import { GroupCardGrid } from "./GroupCardGrid";
import { EditGroupsDialog } from "./EditGroupsDialog";
import { View } from "../List/views";
const GroupItemList = makeItemList({
filterMode: GQL.FilterMode.Groups,
useResult: useFindGroups,
getItems(result: GQL.FindGroupsQueryResult) {
return result?.data?.findGroups?.groups ?? [];
},
getCount(result: GQL.FindGroupsQueryResult) {
return result?.data?.findGroups?.count ?? 0;
},
});
function getItems(result: GQL.FindGroupsQueryResult) {
return result?.data?.findGroups?.groups ?? [];
}
function getCount(result: GQL.FindGroupsQueryResult) {
return result?.data?.findGroups?.count ?? 0;
}
interface IGroupList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
@ -45,6 +42,8 @@ export const GroupList: React.FC<IGroupList> = ({
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Groups;
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.view_random" }),
@ -174,16 +173,24 @@ export const GroupList: React.FC<IGroupList> = ({
}
return (
<GroupItemList
selectable
<ItemListContext
filterMode={filterMode}
useResult={useFindGroups}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
selectable
>
<ItemList
view={view}
otherOperations={otherOperations}
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 * as GQL from "src/core/generated-graphql";
import { queryFindImages, useFindImages } from "src/core/StashService";
import {
makeItemList,
IItemListOperation,
showWhenSelected,
} from "../List/ItemList";
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { useLightbox } from "src/hooks/Lightbox/hooks";
import { ListFilterModel } from "src/models/list-filter/filter";
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 { ImageGridCard } from "./ImageGridCard";
import { View } from "../List/views";
import { IItemListOperation } from "../List/FilteredListToolbar";
interface IImageWallProps {
images: GQL.SlimImageDataFragment[];
@ -222,51 +219,49 @@ const ImageListImages: React.FC<IImageListImages> = ({
return <></>;
};
const ImageItemList = makeItemList({
filterMode: GQL.FilterMode.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;
function getItems(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.images ?? [];
}
if (!megapixels && !size) {
return;
}
function getCount(result: GQL.FindImagesQueryResult) {
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 (
<span className="images-stats">
&nbsp;(
{megapixels ? (
<span className="images-megapixels">
<FormattedNumber value={megapixels} /> Megapixels
</span>
) : undefined}
{separator}
{size && filesize ? (
<span className="images-size">
<FormattedNumber
value={filesize.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
filesize.unit
)}
/>
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
</span>
) : undefined}
)
</span>
);
},
});
if (!megapixels && !size) {
return;
}
const separator = megapixels && size ? " - " : "";
return (
<span className="images-stats">
&nbsp;(
{megapixels ? (
<span className="images-megapixels">
<FormattedNumber value={megapixels} /> Megapixels
</span>
) : undefined}
{separator}
{size && filesize ? (
<span className="images-size">
<FormattedNumber
value={filesize.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
filesize.unit
)}
/>
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
</span>
) : undefined}
)
</span>
);
}
interface IImageList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
@ -289,6 +284,8 @@ export const ImageList: React.FC<IImageList> = ({
const [isExportAll, setIsExportAll] = useState(false);
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
const filterMode = GQL.FilterMode.Images;
const otherOperations = [
...(extraOperations ?? []),
{
@ -415,17 +412,26 @@ export const ImageList: React.FC<IImageList> = ({
}
return (
<ImageItemList
zoomable
selectable
<ItemListContext
filterMode={filterMode}
useResult={useFindImages}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
selectable
>
<ItemList
zoomable
view={view}
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, {
PropsWithChildren,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} 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 { QueryResult } from "@apollo/client";
import {
@ -18,69 +12,30 @@ import {
CriterionValue,
} from "src/models/list-filter/criteria/criterion";
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 { ListFilter } from "./ListFilter";
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 { 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 {
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> {
interface IItemListProps<T extends QueryResult, E extends IHasID> {
view?: View;
defaultSort?: string;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
filterDialog?: (
criteria: Criterion<CriterionValue>[],
setCriteria: (v: Criterion<CriterionValue>[]) => void
) => React.ReactNode;
zoomable?: boolean;
selectable?: boolean;
alterQuery?: boolean;
defaultZoomIndex?: number;
otherOperations?: IItemListOperation<T>[];
renderContent: (
result: T,
@ -90,6 +45,7 @@ interface IItemListProps<T extends QueryResult, E extends IDataItem> {
onChangePage: (page: number) => void,
pageCount: number
) => React.ReactNode;
renderMetadataByline?: (data: T) => React.ReactNode;
renderEditDialog?: (
selected: E[],
onClose: (applied: boolean) => void
@ -105,570 +61,273 @@ interface IItemListProps<T extends QueryResult, E extends IDataItem> {
) => () => void;
}
const getSelectedData = <I extends IDataItem>(
data: I[],
selectedIds: Set<string>
) => data.filter((value) => selectedIds.has(value.id));
/**
* 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,
export const ItemList = <T extends QueryResult, E extends IHasID>(
props: IItemListProps<T, E>
) => {
const {
view,
zoomable,
selectable,
otherOperations,
renderContent,
renderEditDialog,
renderDeleteDialog,
renderMetadataByline,
addKeybinds,
}) => {
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string>();
} = props;
const [editingCriterion, setEditingCriterion] = useState<string>();
const [showEditFilter, setShowEditFilter] = useState(false);
const { filter, setFilter: updateFilter } = useFilter();
const { effectiveFilter, result, cachedResult, totalCount } =
useQueryResultContext<T, E>();
const {
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
} = useListContext<E>();
const effectiveFilter = useMemo(() => {
if (filterHook) {
return filterHook(cloneDeep(filter));
const { modal, showModal, closeModal } = useModal();
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);
const [totalCount, setTotalCount] = useState(0);
const [metadataByline, setMetadataByline] = useState<React.ReactNode>();
const items = useMemo(() => getItems(result), [result]);
showModal(
<EditFilterDialog
filter={filter}
onApply={onApplyEditFilter}
onCancel={() => closeModal()}
editingCriterion={editingCriterion}
/>
);
},
[filter, updateFilter, showModal, closeModal]
);
const [arePaging, setArePaging] = useState(false);
const hidePagination = !arePaging && result.loading;
// useLayoutEffect to set total count before paint, avoiding a 0 being displayed
useLayoutEffect(() => {
if (result.loading) return;
setArePaging(false);
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();
});
useListKeyboardShortcuts({
currentPage: filter.currentPage,
onChangePage,
onSelectAll,
onSelectNone,
pages,
showEditFilter,
});
useEffect(() => {
if (addKeybinds) {
const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds);
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) {
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);
}
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
async function onOperationClicked(o: IItemListOperation<T>) {
await o.onClick(result, effectiveFilter, selectedIds);
if (o.postRefetch) {
result.refetch();
}
}
function onDelete() {
setIsDeleteDialogOpen(true);
}
function onDeleteDialogClosed(deleted: boolean) {
if (deleted) {
onSelectNone();
}
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 operations = otherOperations?.map((o) => ({
text: o.text,
onClick: () => {
onOperationClicked(o);
},
isDisplayed: () => {
if (o.isDisplayed) {
return o.isDisplayed(result, effectiveFilter, selectedIds);
}
const pages = Math.ceil(totalCount / filter.itemsPerPage);
return (
<>
{renderContent(
result,
// #4780 - use effectiveFilter to ensure filterHook is applied
effectiveFilter,
selectedIds,
onSelectChange,
onChangePage,
pages
)}
{!!pages && (
<>
{renderPaginationIndex()}
{renderPagination()}
</>
)}
</>
);
return true;
},
icon: o.icon,
buttonVariant: o.buttonVariant,
}));
function onEdit() {
if (!renderEditDialog) {
return;
}
function onChangeDisplayMode(displayMode: DisplayMode) {
const newFilter = cloneDeep(filter);
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>
showModal(
renderEditDialog(getSelected(), (applied) => onEditDialogClosed(applied))
);
};
}
const ItemList: React.FC<IItemListProps<T, E>> = (props) => {
const {
view,
defaultSort = filterOptions.defaultSortBy,
defaultZoomIndex,
alterQuery = true,
} = props;
function onEditDialogClosed(applied: boolean) {
if (applied) {
onSelectNone();
}
closeModal();
const history = useHistory();
const location = useLocation();
const [filterInitialised, setFilterInitialised] = useState(false);
const { configuration: config } = useContext(ConfigurationContext);
// refetch
result.refetch();
}
const lastPathname = useRef(location.pathname);
const defaultDisplayMode = filterOptions.displayModeOptions[0];
const [filter, setFilter] = useState<ListFilterModel>(
() => new ListFilterModel(filterMode)
function onDelete() {
if (!renderDeleteDialog) {
return;
}
showModal(
renderDeleteDialog(getSelected(), (deleted) =>
onDeleteDialogClosed(deleted)
)
);
}
const { defaultFilter, loading: defaultFilterLoading } = useDefaultFilter(
filterMode,
view
);
function onDeleteDialogClosed(deleted: boolean) {
if (deleted) {
onSelectNone();
}
closeModal();
const updateQueryParams = useCallback(
(newFilter: ListFilterModel) => {
if (!alterQuery) return;
// refetch
result.refetch();
}
const newParams = newFilter.makeQueryParameters();
history.replace({ ...history.location, search: newParams });
},
[alterQuery, history]
);
function onRemoveCriterion(removedCriterion: Criterion<CriterionValue>) {
updateFilter(filter.removeCriterion(removedCriterion.criterionOption.type));
}
const updateFilter = useCallback(
(newFilter: ListFilterModel) => {
setFilter(newFilter);
updateQueryParams(newFilter);
},
[updateQueryParams]
);
function onClearAllCriteria() {
updateFilter(filter.clearCriteria());
}
// 'Startup' hook, initialises the filters
useEffect(() => {
// Only run once
if (filterInitialised) return;
let newFilter = new ListFilterModel(filterMode, config, defaultZoomIndex);
let loadDefault = true;
if (alterQuery && location.search) {
loadDefault = false;
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}
return (
<div className="item-list-container">
<FilteredListToolbar
showEditFilter={showEditFilter}
view={view}
operations={operations}
zoomable={zoomable}
onEdit={renderEditDialog ? onEdit : undefined}
onDelete={renderDeleteDialog ? onDelete : undefined}
/>
);
};
<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>(
result: T,
filter: ListFilterModel,

View File

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

View File

@ -16,7 +16,7 @@ import {
faTrash,
} from "@fortawesome/free-solid-svg-icons";
interface IListFilterOperation {
export interface IListFilterOperation {
text: string;
onClick: () => void;
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 * 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 { View } from "./views";
export function useDefaultFilter(mode: GQL.FilterMode, view?: View) {
const emptyFilter = useMemo(() => new ListFilterModel(mode), [mode]);
export function useFilterURL(
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 defaultFilter = useMemo(() => {
@ -30,3 +88,258 @@ export function useDefaultFilter(mode: GQL.FilterMode, view?: View) {
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,
usePerformersDestroy,
} 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 { DisplayMode } from "src/models/list-filter/types";
import { PerformerTagger } from "../Tagger/performers/PerformerTagger";
@ -23,16 +23,13 @@ import TextUtils from "src/utils/text";
import { PerformerCardGrid } from "./PerformerCardGrid";
import { View } from "../List/views";
const PerformerItemList = makeItemList({
filterMode: GQL.FilterMode.Performers,
useResult: useFindPerformers,
getItems(result: GQL.FindPerformersQueryResult) {
return result?.data?.findPerformers?.performers ?? [];
},
getCount(result: GQL.FindPerformersQueryResult) {
return result?.data?.findPerformers?.count ?? 0;
},
});
function getItems(result: GQL.FindPerformersQueryResult) {
return result?.data?.findPerformers?.performers ?? [];
}
function getCount(result: GQL.FindPerformersQueryResult) {
return result?.data?.findPerformers?.count ?? 0;
}
export const FormatHeight = (height?: number | null) => {
const intl = useIntl();
@ -175,6 +172,8 @@ export const PerformerList: React.FC<IPerformerList> = ({
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Performers;
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.open_random" }),
@ -319,16 +318,24 @@ export const PerformerList: React.FC<IPerformerList> = ({
}
return (
<PerformerItemList
selectable
<ItemListContext
filterMode={filterMode}
useResult={useFindPerformers}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
selectable
>
<ItemList
view={view}
otherOperations={otherOperations}
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 * as GQL from "src/core/generated-graphql";
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 { DisplayMode } from "src/models/list-filter/types";
import { Tagger } from "../Tagger/scenes/SceneTagger";
@ -26,51 +26,49 @@ import { objectTitle } from "src/core/files";
import TextUtils from "src/utils/text";
import { View } from "../List/views";
const SceneItemList = makeItemList({
filterMode: GQL.FilterMode.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;
function getItems(result: GQL.FindScenesQueryResult) {
return result?.data?.findScenes?.scenes ?? [];
}
if (!duration && !size) {
return;
}
function getCount(result: GQL.FindScenesQueryResult) {
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 (
<span className="scenes-stats">
&nbsp;(
{duration ? (
<span className="scenes-duration">
{TextUtils.secondsAsTimeString(duration, 3)}
</span>
) : undefined}
{separator}
{size && filesize ? (
<span className="scenes-size">
<FormattedNumber
value={filesize.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
filesize.unit
)}
/>
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
</span>
) : undefined}
)
</span>
);
},
});
if (!duration && !size) {
return;
}
const separator = duration && size ? " - " : "";
return (
<span className="scenes-stats">
&nbsp;(
{duration ? (
<span className="scenes-duration">
{TextUtils.secondsAsTimeString(duration, 3)}
</span>
) : undefined}
{separator}
{size && filesize ? (
<span className="scenes-size">
<FormattedNumber
value={filesize.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
filesize.unit
)}
/>
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
</span>
) : undefined}
)
</span>
);
}
interface ISceneList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
@ -95,6 +93,8 @@ export const SceneList: React.FC<ISceneList> = ({
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Scenes;
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.play_selected" }),
@ -350,19 +350,28 @@ export const SceneList: React.FC<ISceneList> = ({
return (
<TaggerContext>
<SceneItemList
zoomable
selectable
<ItemListContext
filterMode={filterMode}
defaultSort={defaultSort}
useResult={useFindScenes}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
defaultSort={defaultSort}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
selectable
>
<ItemList
zoomable
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderMetadataByline={renderMetadataByline}
/>
</ItemListContext>
</TaggerContext>
);
};

View File

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

View File

@ -14,10 +14,7 @@ import cx from "classnames";
import { useToast } from "src/hooks/Toast";
import { useDebounce } from "src/hooks/debounce";
interface IHasID {
id: string;
}
import { IHasID } from "src/utils/data";
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 {
align-items: center;
display: flex;
@ -6,7 +26,7 @@
width: 100%;
&:not(.card-based) {
height: 70vh;
padding-top: 2rem;
}
&-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 { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
@ -79,16 +79,18 @@ const StudioTabs: React.FC<{
abbreviateCounter: boolean;
showAllCounts?: boolean;
}> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => {
const [showAllDetails, setShowAllDetails] = useState<boolean>(showAllCounts);
const sceneCount =
(showAllCounts ? studio.scene_count_all : studio.scene_count) ?? 0;
(showAllDetails ? studio.scene_count_all : studio.scene_count) ?? 0;
const galleryCount =
(showAllCounts ? studio.gallery_count_all : studio.gallery_count) ?? 0;
(showAllDetails ? studio.gallery_count_all : studio.gallery_count) ?? 0;
const imageCount =
(showAllCounts ? studio.image_count_all : studio.image_count) ?? 0;
(showAllDetails ? studio.image_count_all : studio.image_count) ?? 0;
const performerCount =
(showAllCounts ? studio.performer_count_all : studio.performer_count) ?? 0;
(showAllDetails ? studio.performer_count_all : studio.performer_count) ?? 0;
const groupCount =
(showAllCounts ? studio.group_count_all : studio.group_count) ?? 0;
(showAllDetails ? studio.group_count_all : studio.group_count) ?? 0;
const populatedDefaultTab = useMemo(() => {
let ret: TabKey = "scenes";
@ -123,6 +125,21 @@ const StudioTabs: React.FC<{
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 (
<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
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
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
eventKey="performers"
@ -177,9 +209,11 @@ const StudioTabs: React.FC<{
/>
}
>
{contentSwitch}
<StudioPerformersPanel
active={tabKey === "performers"}
studio={studio}
showChildStudioContent={showAllDetails}
/>
</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
eventKey="childstudios"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import {
useFindStudios,
useStudiosDestroy,
} 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 { DisplayMode } from "src/models/list-filter/types";
import { ExportDialog } from "../Shared/ExportDialog";
@ -18,16 +18,13 @@ import { StudioTagger } from "../Tagger/studios/StudioTagger";
import { StudioCardGrid } from "./StudioCardGrid";
import { View } from "../List/views";
const StudioItemList = makeItemList({
filterMode: GQL.FilterMode.Studios,
useResult: useFindStudios,
getItems(result: GQL.FindStudiosQueryResult) {
return result?.data?.findStudios?.studios ?? [];
},
getCount(result: GQL.FindStudiosQueryResult) {
return result?.data?.findStudios?.count ?? 0;
},
});
function getItems(result: GQL.FindStudiosQueryResult) {
return result?.data?.findStudios?.studios ?? [];
}
function getCount(result: GQL.FindStudiosQueryResult) {
return result?.data?.findStudios?.count ?? 0;
}
interface IStudioList {
fromParent?: boolean;
@ -47,6 +44,8 @@ export const StudioList: React.FC<IStudioList> = ({
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Studios;
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.view_random" }),
@ -177,15 +176,23 @@ export const StudioList: React.FC<IStudioList> = ({
}
return (
<StudioItemList
selectable
<ItemListContext
filterMode={filterMode}
useResult={useFindStudios}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderDeleteDialog={renderDeleteDialog}
/>
selectable
>
<ItemList
view={view}
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 { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
@ -82,20 +82,22 @@ const TagTabs: React.FC<{
abbreviateCounter: boolean;
showAllCounts?: boolean;
}> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => {
const [showAllDetails, setShowAllDetails] = useState<boolean>(showAllCounts);
const sceneCount =
(showAllCounts ? tag.scene_count_all : tag.scene_count) ?? 0;
(showAllDetails ? tag.scene_count_all : tag.scene_count) ?? 0;
const imageCount =
(showAllCounts ? tag.image_count_all : tag.image_count) ?? 0;
(showAllDetails ? tag.image_count_all : tag.image_count) ?? 0;
const galleryCount =
(showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0;
(showAllDetails ? tag.gallery_count_all : tag.gallery_count) ?? 0;
const groupCount =
(showAllCounts ? tag.group_count_all : tag.group_count) ?? 0;
(showAllDetails ? tag.group_count_all : tag.group_count) ?? 0;
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 =
(showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0;
(showAllDetails ? tag.performer_count_all : tag.performer_count) ?? 0;
const studioCount =
(showAllCounts ? tag.studio_count_all : tag.studio_count) ?? 0;
(showAllDetails ? tag.studio_count_all : tag.studio_count) ?? 0;
const populatedDefaultTab = useMemo(() => {
let ret: TabKey = "scenes";
@ -133,6 +135,21 @@ const TagTabs: React.FC<{
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 (
<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
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
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
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
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
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
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>
</Tabs>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import cloneDeep from "lodash-es/cloneDeep";
import Mousetrap from "mousetrap";
import { ListFilterModel } from "src/models/list-filter/filter";
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 { Link, useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
@ -27,27 +27,27 @@ import { TagCardGrid } from "./TagCardGrid";
import { EditTagsDialog } from "./EditTagsDialog";
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 {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
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 }) => {
const Toast = useToast();
const [deletingTag, setDeletingTag] =
useState<Partial<GQL.TagDataFragment> | null>(null);
const filterMode = GQL.FilterMode.Tags;
const view = View.Tags;
function getDeleteTagInput() {
const tagInput: Partial<GQL.TagDestroyInput> = {};
if (deletingTag) {
@ -355,18 +355,25 @@ export const TagList: React.FC<ITagList> = ({ filterHook, alterQuery }) => {
}
return (
<TagItemList
selectable
zoomable
defaultZoomIndex={0}
filterHook={filterHook}
view={View.Tags}
<ItemListContext
filterMode={filterMode}
useResult={useFindTags}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderDeleteDialog={renderDeleteDialog}
renderEditDialog={renderEditDialog}
/>
filterHook={filterHook}
view={view}
selectable
>
<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 { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { ListFilterModel } from "src/models/list-filter/filter";
import React from "react";
import { ConfigurationContext } from "src/hooks/Config";
export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => {
const { configuration } = React.useContext(ConfigurationContext);
export const useStudioFilterHook = (
studio: GQL.StudioDataFragment,
showChildStudioContent?: boolean
) => {
return (filter: ListFilterModel) => {
const studioValue = { id: studio.id, label: studio.name };
// if studio is already present, then we modify it, otherwise add
@ -22,7 +22,7 @@ export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => {
studioCriterion.value = {
items: [studioValue],
excluded: [],
depth: configuration?.ui.showChildStudioContent ? -1 : 0,
depth: showChildStudioContent ? -1 : 0,
};
studioCriterion.modifier = GQL.CriterionModifier.Includes;
filter.criteria.push(studioCriterion);

View File

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

View File

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

View File

@ -89,6 +89,15 @@ export abstract class Criterion<V extends CriterionValue> {
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) {
const modifierMessageID = modifierMessageIDs[modifier];
@ -251,6 +260,19 @@ export class ILabeledIdCriterionOption extends CriterionOption {
}
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 {
return this.value.map((v) => v.label).join(", ");
}
@ -272,23 +294,33 @@ export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
return this.value.length > 0;
}
constructor(type: CriterionOption) {
super(type, []);
}
}
export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> {
constructor(type: CriterionOption) {
const value: IHierarchicalLabelValue = {
constructor(
type: CriterionOption,
value: IHierarchicalLabelValue = {
items: [],
excluded: [],
depth: 0,
};
}
) {
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 {
return this._modifier;
}
@ -501,8 +533,17 @@ export class StringCriterion extends Criterion<string> {
}
export class MultiStringCriterion extends Criterion<string[]> {
constructor(type: CriterionOption) {
super(type, []);
constructor(type: CriterionOption, value: string[] = []) {
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) {

View File

@ -67,26 +67,48 @@ export class ListFilterModel {
public constructor(
mode: FilterMode,
config?: ConfigDataFragment,
defaultZoomIndex?: number
options?: {
defaultZoomIndex?: number;
defaultSortBy?: string;
defaultSortDir?: SortDirectionEnum;
}
) {
this.mode = mode;
this.config = config;
this.options = getFilterOptions(mode);
const { defaultSortBy, displayModeOptions } = this.options;
this.sortBy = defaultSortBy;
if (this.sortBy === "date") {
this.sortDirection = SortDirectionEnum.Desc;
if (options?.defaultSortBy) {
this.sortBy = options.defaultSortBy;
if (options.defaultSortDir) {
this.sortDirection = options.defaultSortDir;
}
} else {
this.sortBy = defaultSortBy;
if (this.sortBy === "date") {
this.sortDirection = SortDirectionEnum.Desc;
}
}
this.displayMode = displayModeOptions[0];
if (defaultZoomIndex !== undefined) {
this.defaultZoomIndex = defaultZoomIndex;
this.zoomIndex = defaultZoomIndex;
if (options?.defaultZoomIndex !== undefined) {
this.defaultZoomIndex = options.defaultZoomIndex;
this.zoomIndex = options.defaultZoomIndex;
}
}
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
@ -443,4 +465,44 @@ export class ListFilterModel {
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 isEqual from "lodash-es/isEqual";
import { IHasID } from "./data";
interface IHasRating {
rating100?: GQL.Maybe<number> | undefined;
@ -21,10 +22,6 @@ export function getAggregateRating(state: IHasRating[]) {
return ret;
}
interface IHasID {
id: string;
}
interface IHasStudio {
studio?: GQL.Maybe<IHasID> | undefined;
}

View File

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