stash/ui/v2.5/src/hooks/ListHook.tsx

431 lines
13 KiB
TypeScript

import _ from "lodash";
import queryString from "query-string";
import React, { useState, useEffect, useRef } from "react";
import { ApolloError } from "apollo-client";
import { useHistory, useLocation } from "react-router-dom";
import {
SortDirectionEnum,
SlimSceneDataFragment,
SceneMarkerDataFragment,
GalleryDataFragment,
StudioDataFragment,
PerformerDataFragment,
FindScenesQueryResult,
FindSceneMarkersQueryResult,
FindGalleriesQueryResult,
FindStudiosQueryResult,
FindPerformersQueryResult
} 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 } from "src/components/List/Pagination";
import { StashService } from "src/core/StashService";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode, FilterMode } from "src/models/list-filter/types";
interface IListHookData {
filter: ListFilterModel;
template: JSX.Element;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
interface IListHookOperation<T> {
text: string;
onClick: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => void;
}
interface IListHookOptions<T> {
subComponent?: boolean;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
zoomable?: boolean;
otherOperations?: IListHookOperation<T>[];
renderContent: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>,
zoomIndex: number
) => JSX.Element | undefined;
renderSelectedOptions?: (
result: T,
selectedIds: Set<string>
) => JSX.Element | undefined;
}
interface IDataItem {
id: string;
}
interface IQueryResult {
error?: ApolloError;
loading: boolean;
}
interface IQuery<T extends IQueryResult, T2 extends IDataItem> {
filterMode: FilterMode;
useData: (filter: ListFilterModel) => T;
getData: (data: T) => T2[];
getCount: (data: T) => number;
}
const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
options: IListHookOptions<QueryResult> & IQuery<QueryResult, QueryData>
): IListHookData => {
const [interfaceForage, setInterfaceForage] = useInterfaceLocalForage();
const forageInitialised = useRef(false);
const history = useHistory();
const location = useLocation();
const [filter, setFilter] = useState<ListFilterModel>(
new ListFilterModel(
options.filterMode,
options.subComponent ? "" : queryString.parse(location.search)
)
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string | undefined>();
const [zoomIndex, setZoomIndex] = useState<number>(1);
const result = options.useData(getFilter());
const totalCount = options.getCount(result);
const items = options.getData(result);
useEffect(() => {
if (!forageInitialised.current && !interfaceForage.loading) {
forageInitialised.current = true;
// Don't use query parameters for sub-components
if (options.subComponent) return;
// Don't read localForage if page already had query parameters
if (history.location.search) return;
const queryData = interfaceForage.data?.queries[options.filterMode];
if (!queryData) return;
const newFilter = new ListFilterModel(
options.filterMode,
queryString.parse(queryData.filter)
);
newFilter.currentPage = queryData.currentPage;
newFilter.itemsPerPage = queryData.itemsPerPage;
const newLocation = { ...history.location };
newLocation.search = queryData.filter;
history.replace(newLocation);
}
}, [
interfaceForage.data,
interfaceForage.loading,
history,
options.subComponent,
options.filterMode
]);
useEffect(() => {
if (options.subComponent) return;
const newFilter = new ListFilterModel(
options.filterMode,
options.subComponent ? "" : queryString.parse(location.search)
);
setFilter(newFilter);
if (forageInitialised.current) {
setInterfaceForage(config => {
const data = { ...config } as IInterfaceConfig;
data.queries = {
...config?.queries,
[options.filterMode]: {
filter: location.search,
itemsPerPage: newFilter.itemsPerPage,
currentPage: newFilter.currentPage
}
}
return data;
});
}
}, [location, options.filterMode, options.subComponent, setInterfaceForage]);
function getFilter() {
if (!options.filterHook) {
return filter;
}
// make a copy of the filter and call the hook
const newFilter = _.cloneDeep(filter);
return options.filterHook(newFilter);
}
function updateQueryParams(listfilter: ListFilterModel) {
const newLocation = { ...history.location };
newLocation.search = listfilter.makeQueryParameters();
history.replace(newLocation);
}
function onChangePageSize(pageSize: number) {
const newFilter = _.cloneDeep(filter);
newFilter.itemsPerPage = pageSize;
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function onChangeQuery(query: string) {
const newFilter = _.cloneDeep(filter);
newFilter.searchTerm = query;
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function onChangeSortDirection(sortDirection: SortDirectionEnum) {
const newFilter = _.cloneDeep(filter);
newFilter.sortDirection = sortDirection;
updateQueryParams(newFilter);
}
function onChangeSortBy(sortBy: string) {
const newFilter = _.cloneDeep(filter);
newFilter.sortBy = sortBy;
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function onChangeDisplayMode(displayMode: DisplayMode) {
const newFilter = _.cloneDeep(filter);
newFilter.displayMode = displayMode;
updateQueryParams(newFilter);
}
function onAddCriterion(criterion: Criterion, oldId?: string) {
const newFilter = _.cloneDeep(filter);
// Find if we are editing an existing criteria, then modify that. Or create a new one.
const existingIndex = newFilter.criteria.findIndex(c => {
// If we modified an existing criterion, then look for the old id.
const id = oldId || criterion.getId();
return c.getId() === id;
});
if (existingIndex === -1) {
newFilter.criteria.push(criterion);
} else {
newFilter.criteria[existingIndex] = criterion;
}
// Remove duplicate modifiers
newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => {
return (
arr.map((mapObj: any) => mapObj.getId()).indexOf(obj.getId()) === pos
);
});
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function onRemoveCriterion(removedCriterion: Criterion) {
const newFilter = _.cloneDeep(filter);
newFilter.criteria = newFilter.criteria.filter(
criterion => criterion.getId() !== removedCriterion.getId()
);
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function onChangePage(page: number) {
const newFilter = _.cloneDeep(filter);
newFilter.currentPage = page;
updateQueryParams(newFilter);
}
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);
}
const otherOperations = options.otherOperations
? options.otherOperations.map(o => {
return {
text: o.text,
onClick: () => {
o.onClick(result, filter, selectedIds);
}
};
})
: undefined;
let template;
if (result.loading || !forageInitialised.current) {
template = <LoadingIndicator />;
} else if (result.error) {
template = <h1>{result.error.message}</h1>;
} else {
template = (
<div>
<ListFilter
onChangePageSize={onChangePageSize}
onChangeQuery={onChangeQuery}
onChangeSortDirection={onChangeSortDirection}
onChangeSortBy={onChangeSortBy}
onChangeDisplayMode={onChangeDisplayMode}
onAddCriterion={onAddCriterion}
onRemoveCriterion={onRemoveCriterion}
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
zoomIndex={options.zoomable ? zoomIndex : undefined}
onChangeZoom={options.zoomable ? onChangeZoom : undefined}
otherOperations={otherOperations}
filter={filter}
/>
{options.renderSelectedOptions && selectedIds.size > 0
? options.renderSelectedOptions(result, selectedIds)
: undefined}
{options.renderContent(result, filter, selectedIds, zoomIndex)}
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
onChangePage={onChangePage}
/>
</div>
);
}
return { filter, template, onSelectChange };
};
export const useScenesList = (props: IListHookOptions<FindScenesQueryResult>) =>
useList<FindScenesQueryResult, SlimSceneDataFragment>({
...props,
filterMode: FilterMode.Scenes,
useData: StashService.useFindScenes,
getData: (result: FindScenesQueryResult) =>
result?.data?.findScenes?.scenes ?? [],
getCount: (result: FindScenesQueryResult) =>
result?.data?.findScenes?.count ?? 0
});
export const useSceneMarkersList = (
props: IListHookOptions<FindSceneMarkersQueryResult>
) =>
useList<FindSceneMarkersQueryResult, SceneMarkerDataFragment>({
...props,
filterMode: FilterMode.SceneMarkers,
useData: StashService.useFindSceneMarkers,
getData: (result: FindSceneMarkersQueryResult) =>
result?.data?.findSceneMarkers?.scene_markers ?? [],
getCount: (result: FindSceneMarkersQueryResult) =>
result?.data?.findSceneMarkers?.count ?? 0
});
export const useGalleriesList = (
props: IListHookOptions<FindGalleriesQueryResult>
) =>
useList<FindGalleriesQueryResult, GalleryDataFragment>({
...props,
filterMode: FilterMode.Galleries,
useData: StashService.useFindGalleries,
getData: (result: FindGalleriesQueryResult) =>
result?.data?.findGalleries?.galleries ?? [],
getCount: (result: FindGalleriesQueryResult) =>
result?.data?.findGalleries?.count ?? 0
});
export const useStudiosList = (
props: IListHookOptions<FindStudiosQueryResult>
) =>
useList<FindStudiosQueryResult, StudioDataFragment>({
...props,
filterMode: FilterMode.Studios,
useData: StashService.useFindStudios,
getData: (result: FindStudiosQueryResult) =>
result?.data?.findStudios?.studios ?? [],
getCount: (result: FindStudiosQueryResult) =>
result?.data?.findStudios?.count ?? 0
});
export const usePerformersList = (
props: IListHookOptions<FindPerformersQueryResult>
) =>
useList<FindPerformersQueryResult, PerformerDataFragment>({
...props,
filterMode: FilterMode.Performers,
useData: StashService.useFindPerformers,
getData: (result: FindPerformersQueryResult) =>
result?.data?.findPerformers?.performers ?? [],
getCount: (result: FindPerformersQueryResult) =>
result?.data?.findPerformers?.count ?? 0
});