Files
stash/ui/v2.5/src/hooks/ListHook.tsx
InfiniteTF 794ea00d37 Upgrade create-react-app and assorted libraries (#914)
* Update create-react-app, react, typescript and eslint versions
* Various library updates
2020-11-25 13:20:48 +11:00

642 lines
17 KiB
TypeScript

import _ from "lodash";
import queryString from "query-string";
import React, { useCallback, useRef, useState, useEffect } from "react";
import { ApolloError } from "@apollo/client";
import { useHistory, useLocation } from "react-router-dom";
import Mousetrap from "mousetrap";
import {
SlimSceneDataFragment,
SceneMarkerDataFragment,
GallerySlimDataFragment,
StudioDataFragment,
PerformerDataFragment,
FindScenesQueryResult,
FindSceneMarkersQueryResult,
FindGalleriesQueryResult,
FindStudiosQueryResult,
FindPerformersQueryResult,
FindMoviesQueryResult,
MovieDataFragment,
FindTagsQueryResult,
TagDataFragment,
FindImagesQueryResult,
SlimImageDataFragment,
} from "src/core/generated-graphql";
import {
useInterfaceLocalForage,
IInterfaceConfig,
} from "src/hooks/LocalForage";
import { LoadingIndicator } from "src/components/Shared";
import { ListFilter } from "src/components/List/ListFilter";
import { Pagination, PaginationIndex } from "src/components/List/Pagination";
import {
useFindScenes,
useFindSceneMarkers,
useFindImages,
useFindMovies,
useFindStudios,
useFindGalleries,
useFindPerformers,
useFindTags,
} from "src/core/StashService";
import { ListFilterModel } from "src/models/list-filter/filter";
import { FilterMode } from "src/models/list-filter/types";
const getSelectedData = <I extends IDataItem>(
result: I[],
selectedIds: Set<string>
) => {
// find the selected items from the ids
const selectedResults: I[] = [];
selectedIds.forEach((id) => {
const item = result.find((s) => s.id === id);
if (item) {
selectedResults.push(item);
}
});
return selectedResults;
};
interface IListHookData {
filter: ListFilterModel;
template: React.ReactElement;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
export interface IListHookOperation<T> {
text: string;
onClick: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => void;
isDisplayed?: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => boolean;
postRefetch?: boolean;
}
interface IListHookOptions<T, E> {
persistState?: boolean;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
zoomable?: boolean;
selectable?: boolean;
defaultZoomIndex?: number;
otherOperations?: IListHookOperation<T>[];
renderContent: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>,
zoomIndex: 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;
}
interface IDataItem {
id: string;
}
interface IQueryResult {
error?: ApolloError;
loading: boolean;
refetch: () => void;
}
interface IQuery<T extends IQueryResult, T2 extends IDataItem> {
filterMode: FilterMode;
useData: (filter: ListFilterModel) => T;
getData: (data: T) => T2[];
getCount: (data: T) => number;
}
interface IRenderListProps {
filter: ListFilterModel;
onChangePage: (page: number) => void;
updateQueryParams: (filter: ListFilterModel) => void;
}
const RenderList = <
QueryResult extends IQueryResult,
QueryData extends IDataItem
>({
defaultZoomIndex,
filter,
onChangePage,
addKeybinds,
useData,
getCount,
getData,
otherOperations,
renderContent,
zoomable,
selectable,
renderEditDialog,
renderDeleteDialog,
updateQueryParams,
}: IListHookOptions<QueryResult, QueryData> &
IQuery<QueryResult, QueryData> &
IRenderListProps) => {
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string | undefined>();
const [zoomIndex, setZoomIndex] = useState<number>(defaultZoomIndex ?? 1);
const result = useData(filter);
const totalCount = getCount(result);
const items = getData(result);
useEffect(() => {
Mousetrap.bind("right", () => {
const maxPage = totalCount / filter.itemsPerPage;
if (filter.currentPage < maxPage) {
onChangePage(filter.currentPage + 1);
}
});
Mousetrap.bind("left", () => {
if (filter.currentPage > 1) {
onChangePage(filter.currentPage - 1);
}
});
Mousetrap.bind("shift+right", () => {
const maxPage = totalCount / filter.itemsPerPage + 1;
onChangePage(Math.min(maxPage, filter.currentPage + 10));
});
Mousetrap.bind("shift+left", () => {
onChangePage(Math.max(1, filter.currentPage - 10));
});
Mousetrap.bind("ctrl+end", () => {
const maxPage = totalCount / filter.itemsPerPage + 1;
onChangePage(maxPage);
});
Mousetrap.bind("ctrl+home", () => {
onChangePage(1);
});
let unbindExtras: () => void;
if (addKeybinds) {
unbindExtras = addKeybinds(result, filter, selectedIds);
}
return () => {
Mousetrap.unbind("right");
Mousetrap.unbind("left");
Mousetrap.unbind("shift+right");
Mousetrap.unbind("shift+left");
Mousetrap.unbind("ctrl+end");
Mousetrap.unbind("ctrl+home");
if (unbindExtras) {
unbindExtras();
}
};
});
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: Set<string> = new Set();
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: Set<string> = new Set();
items.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
function onSelectNone() {
const newSelectedIds: Set<string> = new Set();
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
function onChangeZoom(newZoomIndex: number) {
setZoomIndex(newZoomIndex);
}
async function onOperationClicked(o: IListHookOperation<QueryResult>) {
await o.onClick(result, filter, selectedIds);
if (o.postRefetch) {
result.refetch();
}
}
const operations =
otherOperations &&
otherOperations.map((o) => ({
text: o.text,
onClick: () => {
onOperationClicked(o);
},
isDisplayed: () => {
if (o.isDisplayed) {
return o.isDisplayed(result, filter, selectedIds);
}
return true;
},
}));
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();
}
const renderPagination = () => (
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
onChangePage={onChangePage}
/>
);
function maybeRenderContent() {
if (result.loading || result.error) {
return;
}
return (
<>
{renderPagination()}
{renderContent(result, filter, selectedIds, zoomIndex)}
<PaginationIndex
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
/>
{renderPagination()}
</>
);
}
const content = (
<div>
<ListFilter
onFilterUpdate={updateQueryParams}
onSelectAll={selectable ? onSelectAll : undefined}
onSelectNone={selectable ? onSelectNone : undefined}
zoomIndex={zoomable ? zoomIndex : undefined}
onChangeZoom={zoomable ? onChangeZoom : undefined}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={renderEditDialog ? onEdit : undefined}
onDelete={renderDeleteDialog ? onDelete : undefined}
filter={filter}
/>
{isEditDialogOpen &&
renderEditDialog &&
renderEditDialog(
getSelectedData(getData(result), selectedIds),
(applied) => onEditDialogClosed(applied)
)}
{isDeleteDialogOpen &&
renderDeleteDialog &&
renderDeleteDialog(
getSelectedData(getData(result), selectedIds),
(deleted) => onDeleteDialogClosed(deleted)
)}
{result.loading ? <LoadingIndicator /> : undefined}
{result.error ? <h1>{result.error.message}</h1> : undefined}
{maybeRenderContent()}
</div>
);
return { contentTemplate: content, onSelectChange };
};
const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
options: IListHookOptions<QueryResult, QueryData> &
IQuery<QueryResult, QueryData>
): IListHookData => {
const history = useHistory();
const location = useLocation();
const [interfaceState, setInterfaceState] = useInterfaceLocalForage();
// If persistState is false we don't care about forage and consider it initialised
const [forageInitialised, setForageInitialised] = useState(
!options.persistState
);
// Store initial pathname to prevent hooks from operating outside this page
const originalPathName = useRef(location.pathname);
const [filter, setFilter] = useState<ListFilterModel>(
new ListFilterModel(options.filterMode, queryString.parse(location.search))
);
const updateInterfaceConfig = useCallback(
(updatedFilter: ListFilterModel) => {
setInterfaceState((config) => {
const data = { ...config } as IInterfaceConfig;
data.queries = {
[options.filterMode]: {
filter: updatedFilter.makeQueryParameters(),
itemsPerPage: updatedFilter.itemsPerPage,
currentPage: updatedFilter.currentPage,
},
};
return data;
});
},
[options.filterMode, setInterfaceState]
);
useEffect(() => {
if (
interfaceState.loading ||
// Only update query params on page the hook was mounted on
history.location.pathname !== originalPathName.current
)
return;
if (!forageInitialised) setForageInitialised(true);
if (!options.persistState) return;
const storedQuery = interfaceState.data?.queries?.[options.filterMode];
if (!storedQuery) return;
const queryFilter = queryString.parse(history.location.search);
const storedFilter = queryString.parse(storedQuery.filter);
const query = history.location.search
? {
sortby: storedFilter.sortby,
sortdir: storedFilter.sortdir,
disp: storedFilter.disp,
perPage: storedFilter.perPage,
...queryFilter,
}
: storedFilter;
const newFilter = new ListFilterModel(options.filterMode, query);
// Compare constructed filter with current filter.
// If different it's the result of navigation, and we update the filter.
const newLocation = { ...history.location };
newLocation.search = newFilter.makeQueryParameters();
if (newLocation.search !== filter.makeQueryParameters()) {
setFilter(newFilter);
updateInterfaceConfig(newFilter);
}
// If constructed search is different from current, update it as well
if (newLocation.search !== location.search) {
newLocation.search = newFilter.makeQueryParameters();
history.replace(newLocation);
}
}, [
filter,
interfaceState.data,
interfaceState.loading,
history,
location.search,
options.filterMode,
forageInitialised,
updateInterfaceConfig,
options.persistState,
]);
function updateQueryParams(listFilter: ListFilterModel) {
setFilter(listFilter);
const newLocation = { ...location };
newLocation.search = listFilter.makeQueryParameters();
history.replace(newLocation);
if (options.persistState) {
updateInterfaceConfig(listFilter);
}
}
const onChangePage = (page: number) => {
const newFilter = _.cloneDeep(filter);
newFilter.currentPage = page;
updateQueryParams(newFilter);
};
const renderFilter = !options.filterHook
? filter
: options.filterHook(_.cloneDeep(filter));
const { contentTemplate, onSelectChange } = RenderList({
...options,
filter: renderFilter,
onChangePage,
updateQueryParams,
});
const template = !forageInitialised ? (
<LoadingIndicator />
) : (
<>{contentTemplate}</>
);
return {
filter,
template,
onSelectChange,
};
};
export const useScenesList = (
props: IListHookOptions<FindScenesQueryResult, SlimSceneDataFragment>
) =>
useList<FindScenesQueryResult, SlimSceneDataFragment>({
...props,
filterMode: FilterMode.Scenes,
useData: useFindScenes,
getData: (result: FindScenesQueryResult) =>
result?.data?.findScenes?.scenes ?? [],
getCount: (result: FindScenesQueryResult) =>
result?.data?.findScenes?.count ?? 0,
});
export const useSceneMarkersList = (
props: IListHookOptions<FindSceneMarkersQueryResult, SceneMarkerDataFragment>
) =>
useList<FindSceneMarkersQueryResult, SceneMarkerDataFragment>({
...props,
filterMode: FilterMode.SceneMarkers,
useData: useFindSceneMarkers,
getData: (result: FindSceneMarkersQueryResult) =>
result?.data?.findSceneMarkers?.scene_markers ?? [],
getCount: (result: FindSceneMarkersQueryResult) =>
result?.data?.findSceneMarkers?.count ?? 0,
});
export const useImagesList = (
props: IListHookOptions<FindImagesQueryResult, SlimImageDataFragment>
) =>
useList<FindImagesQueryResult, SlimImageDataFragment>({
...props,
filterMode: FilterMode.Images,
useData: useFindImages,
getData: (result: FindImagesQueryResult) =>
result?.data?.findImages?.images ?? [],
getCount: (result: FindImagesQueryResult) =>
result?.data?.findImages?.count ?? 0,
});
export const useGalleriesList = (
props: IListHookOptions<FindGalleriesQueryResult, GallerySlimDataFragment>
) =>
useList<FindGalleriesQueryResult, GallerySlimDataFragment>({
...props,
filterMode: FilterMode.Galleries,
useData: useFindGalleries,
getData: (result: FindGalleriesQueryResult) =>
result?.data?.findGalleries?.galleries ?? [],
getCount: (result: FindGalleriesQueryResult) =>
result?.data?.findGalleries?.count ?? 0,
});
export const useStudiosList = (
props: IListHookOptions<FindStudiosQueryResult, StudioDataFragment>
) =>
useList<FindStudiosQueryResult, StudioDataFragment>({
...props,
filterMode: FilterMode.Studios,
useData: useFindStudios,
getData: (result: FindStudiosQueryResult) =>
result?.data?.findStudios?.studios ?? [],
getCount: (result: FindStudiosQueryResult) =>
result?.data?.findStudios?.count ?? 0,
});
export const usePerformersList = (
props: IListHookOptions<FindPerformersQueryResult, PerformerDataFragment>
) =>
useList<FindPerformersQueryResult, PerformerDataFragment>({
...props,
filterMode: FilterMode.Performers,
useData: useFindPerformers,
getData: (result: FindPerformersQueryResult) =>
result?.data?.findPerformers?.performers ?? [],
getCount: (result: FindPerformersQueryResult) =>
result?.data?.findPerformers?.count ?? 0,
});
export const useMoviesList = (
props: IListHookOptions<FindMoviesQueryResult, MovieDataFragment>
) =>
useList<FindMoviesQueryResult, MovieDataFragment>({
...props,
filterMode: FilterMode.Movies,
useData: useFindMovies,
getData: (result: FindMoviesQueryResult) =>
result?.data?.findMovies?.movies ?? [],
getCount: (result: FindMoviesQueryResult) =>
result?.data?.findMovies?.count ?? 0,
});
export const useTagsList = (
props: IListHookOptions<FindTagsQueryResult, TagDataFragment>
) =>
useList<FindTagsQueryResult, TagDataFragment>({
...props,
filterMode: FilterMode.Tags,
useData: useFindTags,
getData: (result: FindTagsQueryResult) =>
result?.data?.findTags?.tags ?? [],
getCount: (result: FindTagsQueryResult) =>
result?.data?.findTags?.count ?? 0,
});
export const showWhenSelected = <T extends IQueryResult>(
_result: T,
_filter: ListFilterModel,
selectedIds: Set<string>
) => {
return selectedIds.size > 0;
};