mirror of https://github.com/stashapp/stash.git
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:
parent
540d72bc44
commit
6a5dc4e774
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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">
|
||||
(
|
||||
{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">
|
||||
(
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IListFilterOperation {
|
||||
export interface IListFilterOperation {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
isDisplayed?: () => boolean;
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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">
|
||||
(
|
||||
{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">
|
||||
(
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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 };
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue