Files
stash/ui/v2.5/src/components/List/ItemList.tsx
2023-09-01 10:04:56 +10:00

743 lines
21 KiB
TypeScript

import React, {
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 {
Criterion,
CriterionValue,
} from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { useInterfaceLocalForage } from "src/hooks/LocalForage";
import { useHistory, useLocation } from "react-router-dom";
import { ConfigurationContext } from "src/hooks/Config";
import { getFilterOptions } from "src/models/list-filter/factory";
import { useFindDefaultFilter } from "src/core/StashService";
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";
export enum PersistanceLevel {
// do not load default query or persist display mode
NONE,
// load default query, don't load or persist display mode
ALL,
// load and persist display mode only
VIEW,
}
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> {
persistState?: PersistanceLevel;
persistanceKey?: string;
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,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void,
onChangePage: (page: number) => void,
pageCount: number
) => React.ReactNode;
renderEditDialog?: (
selected: E[],
onClose: (applied: boolean) => void
) => React.ReactNode;
renderDeleteDialog?: (
selected: E[],
onClose: (confirmed: boolean) => void
) => React.ReactNode;
addKeybinds?: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => () => 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,
onChangePage: _onChangePage,
updateFilter,
persistState,
zoomable,
selectable,
otherOperations,
renderContent,
renderEditDialog,
renderDeleteDialog,
addKeybinds,
}) => {
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 [showEditFilter, setShowEditFilter] = useState(false);
const result = useResult(filter);
const [totalCount, setTotalCount] = useState(0);
const [metadataByline, setMetadataByline] = useState<React.ReactNode>();
const items = useMemo(() => getItems(result), [result]);
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();
});
return () => {
Mousetrap.unbind("f");
};
}, []);
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, filter, selectedIds);
return () => {
unbindExtras();
};
}
}, [addKeybinds, result, filter, selectedIds]);
function singleSelect(id: string, selected: boolean) {
setLastClickedId(id);
const newSelectedIds = clone(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);
}
function onChangeZoom(newZoomIndex: number) {
const newFilter = cloneDeep(filter);
newFilter.zoomIndex = newZoomIndex;
updateFilter(newFilter);
}
async function onOperationClicked(o: IItemListOperation<T>) {
await o.onClick(result, filter, 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, filter, 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();
}
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 pages = Math.ceil(totalCount / filter.itemsPerPage);
return (
<>
{renderContent(
result,
filter,
selectedIds,
onSelectChange,
onChangePage,
pages
)}
{!!pages && (
<>
{renderPaginationIndex()}
{renderPagination()}
</>
)}
</>
);
}
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)}
persistState={persistState}
/>
<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) => {
const {
persistState,
persistanceKey = filterMode,
defaultSort = filterOptions.defaultSortBy,
filterHook,
defaultZoomIndex,
alterQuery = true,
} = props;
const history = useHistory();
const location = useLocation();
const [interfaceState, setInterfaceState] = useInterfaceLocalForage();
const [filterInitialised, setFilterInitialised] = useState(false);
const { configuration: config } = useContext(ConfigurationContext);
const lastPathname = useRef(location.pathname);
const defaultDisplayMode = filterOptions.displayModeOptions[0];
const [filter, setFilter] = useState<ListFilterModel>(
() => new ListFilterModel(filterMode)
);
const updateSavedFilter = useCallback(
(updatedFilter: ListFilterModel) => {
setInterfaceState((prevState) => {
if (!prevState.queryConfig) {
prevState.queryConfig = {};
}
const oldFilter = prevState.queryConfig[persistanceKey]?.filter ?? "";
const newFilter = new URLSearchParams(oldFilter);
newFilter.set("disp", String(updatedFilter.displayMode));
return {
...prevState,
queryConfig: {
...prevState.queryConfig,
[persistanceKey]: {
...prevState.queryConfig[persistanceKey],
filter: newFilter.toString(),
},
},
};
});
},
[persistanceKey, setInterfaceState]
);
const { data: defaultFilter, loading: defaultFilterLoading } =
useFindDefaultFilter(filterMode);
const updateQueryParams = useCallback(
(newFilter: ListFilterModel) => {
if (!alterQuery) return;
const newParams = newFilter.makeQueryParameters();
history.replace({ ...history.location, search: newParams });
},
[alterQuery, history]
);
const updateFilter = useCallback(
(newFilter: ListFilterModel) => {
setFilter(newFilter);
updateQueryParams(newFilter);
if (persistState === PersistanceLevel.VIEW) {
updateSavedFilter(newFilter);
}
},
[persistState, updateSavedFilter, updateQueryParams]
);
// 'Startup' hook, initialises the filters
useEffect(() => {
// Only run once
if (filterInitialised) return;
let newFilter = new ListFilterModel(
filterMode,
config,
defaultSort,
defaultDisplayMode,
defaultZoomIndex
);
let loadDefault = true;
if (alterQuery && location.search) {
loadDefault = false;
newFilter.configureFromQueryString(location.search);
}
if (persistState === PersistanceLevel.ALL) {
// only set default filter if uninitialised
if (loadDefault) {
// wait until default filter is loaded
if (defaultFilterLoading) return;
if (defaultFilter?.findDefaultFilter) {
newFilter.currentPage = 1;
try {
newFilter.configureFromSavedFilter(
defaultFilter.findDefaultFilter
);
} catch (err) {
console.log(err);
// ignore
}
// #1507 - reset random seed when loaded
newFilter.randomSeed = -1;
}
}
} else if (persistState === PersistanceLevel.VIEW) {
// wait until forage is initialised
if (interfaceState.loading) return;
const storedQuery = interfaceState.data?.queryConfig?.[persistanceKey];
if (persistState === PersistanceLevel.VIEW && storedQuery) {
const displayMode = new URLSearchParams(storedQuery.filter).get(
"disp"
);
if (displayMode) {
newFilter.displayMode = Number.parseInt(displayMode, 10);
}
}
}
setFilter(newFilter);
updateQueryParams(newFilter);
setFilterInitialised(true);
}, [
filterInitialised,
location,
config,
defaultSort,
defaultDisplayMode,
defaultZoomIndex,
alterQuery,
persistState,
updateQueryParams,
defaultFilter,
defaultFilterLoading,
interfaceState,
persistanceKey,
]);
// 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]
);
const renderFilter = useMemo(() => {
if (filterInitialised) {
return filterHook ? filterHook(cloneDeep(filter)) : filter;
}
}, [filterInitialised, filter, filterHook]);
if (!renderFilter) return null;
return (
<RenderList
filter={renderFilter}
onChangePage={onChangePage}
updateFilter={updateFilter}
{...props}
/>
);
};
return ItemList;
}
export const showWhenSelected = <T extends QueryResult>(
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => {
return selectedIds.size > 0;
};