mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
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:
@@ -4,7 +4,7 @@ import cloneDeep from "lodash-es/cloneDeep";
|
|||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
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 { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
import { queryFindGalleries, useFindGalleries } from "src/core/StashService";
|
import { queryFindGalleries, useFindGalleries } from "src/core/StashService";
|
||||||
@@ -16,16 +16,13 @@ import { GalleryListTable } from "./GalleryListTable";
|
|||||||
import { GalleryCardGrid } from "./GalleryGridCard";
|
import { GalleryCardGrid } from "./GalleryGridCard";
|
||||||
import { View } from "../List/views";
|
import { View } from "../List/views";
|
||||||
|
|
||||||
const GalleryItemList = makeItemList({
|
function getItems(result: GQL.FindGalleriesQueryResult) {
|
||||||
filterMode: GQL.FilterMode.Galleries,
|
return result?.data?.findGalleries?.galleries ?? [];
|
||||||
useResult: useFindGalleries,
|
}
|
||||||
getItems(result: GQL.FindGalleriesQueryResult) {
|
|
||||||
return result?.data?.findGalleries?.galleries ?? [];
|
function getCount(result: GQL.FindGalleriesQueryResult) {
|
||||||
},
|
return result?.data?.findGalleries?.count ?? 0;
|
||||||
getCount(result: GQL.FindGalleriesQueryResult) {
|
}
|
||||||
return result?.data?.findGalleries?.count ?? 0;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IGalleryList {
|
interface IGalleryList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
@@ -43,6 +40,8 @@ export const GalleryList: React.FC<IGalleryList> = ({
|
|||||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||||
const [isExportAll, setIsExportAll] = useState(false);
|
const [isExportAll, setIsExportAll] = useState(false);
|
||||||
|
|
||||||
|
const filterMode = GQL.FilterMode.Galleries;
|
||||||
|
|
||||||
const otherOperations = [
|
const otherOperations = [
|
||||||
{
|
{
|
||||||
text: intl.formatMessage({ id: "actions.view_random" }),
|
text: intl.formatMessage({ id: "actions.view_random" }),
|
||||||
@@ -185,17 +184,25 @@ export const GalleryList: React.FC<IGalleryList> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GalleryItemList
|
<ItemListContext
|
||||||
zoomable
|
filterMode={filterMode}
|
||||||
selectable
|
useResult={useFindGalleries}
|
||||||
|
getItems={getItems}
|
||||||
|
getCount={getCount}
|
||||||
|
alterQuery={alterQuery}
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
view={view}
|
view={view}
|
||||||
alterQuery={alterQuery}
|
selectable
|
||||||
otherOperations={otherOperations}
|
>
|
||||||
addKeybinds={addKeybinds}
|
<ItemList
|
||||||
renderContent={renderContent}
|
zoomable
|
||||||
renderEditDialog={renderEditDialog}
|
view={view}
|
||||||
renderDeleteDialog={renderDeleteDialog}
|
otherOperations={otherOperations}
|
||||||
/>
|
addKeybinds={addKeybinds}
|
||||||
|
renderContent={renderContent}
|
||||||
|
renderEditDialog={renderEditDialog}
|
||||||
|
renderDeleteDialog={renderDeleteDialog}
|
||||||
|
/>
|
||||||
|
</ItemListContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,23 +11,20 @@ import {
|
|||||||
useFindGroups,
|
useFindGroups,
|
||||||
useGroupsDestroy,
|
useGroupsDestroy,
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import { makeItemList, showWhenSelected } from "../List/ItemList";
|
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
|
||||||
import { ExportDialog } from "../Shared/ExportDialog";
|
import { ExportDialog } from "../Shared/ExportDialog";
|
||||||
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
|
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
|
||||||
import { GroupCardGrid } from "./GroupCardGrid";
|
import { GroupCardGrid } from "./GroupCardGrid";
|
||||||
import { EditGroupsDialog } from "./EditGroupsDialog";
|
import { EditGroupsDialog } from "./EditGroupsDialog";
|
||||||
import { View } from "../List/views";
|
import { View } from "../List/views";
|
||||||
|
|
||||||
const GroupItemList = makeItemList({
|
function getItems(result: GQL.FindGroupsQueryResult) {
|
||||||
filterMode: GQL.FilterMode.Groups,
|
return result?.data?.findGroups?.groups ?? [];
|
||||||
useResult: useFindGroups,
|
}
|
||||||
getItems(result: GQL.FindGroupsQueryResult) {
|
|
||||||
return result?.data?.findGroups?.groups ?? [];
|
function getCount(result: GQL.FindGroupsQueryResult) {
|
||||||
},
|
return result?.data?.findGroups?.count ?? 0;
|
||||||
getCount(result: GQL.FindGroupsQueryResult) {
|
}
|
||||||
return result?.data?.findGroups?.count ?? 0;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IGroupList {
|
interface IGroupList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
@@ -45,6 +42,8 @@ export const GroupList: React.FC<IGroupList> = ({
|
|||||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||||
const [isExportAll, setIsExportAll] = useState(false);
|
const [isExportAll, setIsExportAll] = useState(false);
|
||||||
|
|
||||||
|
const filterMode = GQL.FilterMode.Groups;
|
||||||
|
|
||||||
const otherOperations = [
|
const otherOperations = [
|
||||||
{
|
{
|
||||||
text: intl.formatMessage({ id: "actions.view_random" }),
|
text: intl.formatMessage({ id: "actions.view_random" }),
|
||||||
@@ -174,16 +173,24 @@ export const GroupList: React.FC<IGroupList> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GroupItemList
|
<ItemListContext
|
||||||
selectable
|
filterMode={filterMode}
|
||||||
|
useResult={useFindGroups}
|
||||||
|
getItems={getItems}
|
||||||
|
getCount={getCount}
|
||||||
|
alterQuery={alterQuery}
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
view={view}
|
view={view}
|
||||||
alterQuery={alterQuery}
|
selectable
|
||||||
otherOperations={otherOperations}
|
>
|
||||||
addKeybinds={addKeybinds}
|
<ItemList
|
||||||
renderContent={renderContent}
|
view={view}
|
||||||
renderEditDialog={renderEditDialog}
|
otherOperations={otherOperations}
|
||||||
renderDeleteDialog={renderDeleteDialog}
|
addKeybinds={addKeybinds}
|
||||||
/>
|
renderContent={renderContent}
|
||||||
|
renderEditDialog={renderEditDialog}
|
||||||
|
renderDeleteDialog={renderDeleteDialog}
|
||||||
|
/>
|
||||||
|
</ItemListContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import { useHistory } from "react-router-dom";
|
|||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { queryFindImages, useFindImages } from "src/core/StashService";
|
import { queryFindImages, useFindImages } from "src/core/StashService";
|
||||||
import {
|
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
|
||||||
makeItemList,
|
|
||||||
IItemListOperation,
|
|
||||||
showWhenSelected,
|
|
||||||
} from "../List/ItemList";
|
|
||||||
import { useLightbox } from "src/hooks/Lightbox/hooks";
|
import { useLightbox } from "src/hooks/Lightbox/hooks";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
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 { ConfigurationContext } from "src/hooks/Config";
|
||||||
import { ImageGridCard } from "./ImageGridCard";
|
import { ImageGridCard } from "./ImageGridCard";
|
||||||
import { View } from "../List/views";
|
import { View } from "../List/views";
|
||||||
|
import { IItemListOperation } from "../List/FilteredListToolbar";
|
||||||
|
|
||||||
interface IImageWallProps {
|
interface IImageWallProps {
|
||||||
images: GQL.SlimImageDataFragment[];
|
images: GQL.SlimImageDataFragment[];
|
||||||
@@ -222,51 +219,49 @@ const ImageListImages: React.FC<IImageListImages> = ({
|
|||||||
return <></>;
|
return <></>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ImageItemList = makeItemList({
|
function getItems(result: GQL.FindImagesQueryResult) {
|
||||||
filterMode: GQL.FilterMode.Images,
|
return result?.data?.findImages?.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;
|
|
||||||
|
|
||||||
if (!megapixels && !size) {
|
function getCount(result: GQL.FindImagesQueryResult) {
|
||||||
return;
|
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 (
|
if (!megapixels && !size) {
|
||||||
<span className="images-stats">
|
return;
|
||||||
(
|
}
|
||||||
{megapixels ? (
|
|
||||||
<span className="images-megapixels">
|
const separator = megapixels && size ? " - " : "";
|
||||||
<FormattedNumber value={megapixels} /> Megapixels
|
|
||||||
</span>
|
return (
|
||||||
) : undefined}
|
<span className="images-stats">
|
||||||
{separator}
|
(
|
||||||
{size && filesize ? (
|
{megapixels ? (
|
||||||
<span className="images-size">
|
<span className="images-megapixels">
|
||||||
<FormattedNumber
|
<FormattedNumber value={megapixels} /> Megapixels
|
||||||
value={filesize.size}
|
</span>
|
||||||
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
|
) : undefined}
|
||||||
filesize.unit
|
{separator}
|
||||||
)}
|
{size && filesize ? (
|
||||||
/>
|
<span className="images-size">
|
||||||
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
|
<FormattedNumber
|
||||||
</span>
|
value={filesize.size}
|
||||||
) : undefined}
|
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
|
||||||
)
|
filesize.unit
|
||||||
</span>
|
)}
|
||||||
);
|
/>
|
||||||
},
|
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
|
||||||
});
|
</span>
|
||||||
|
) : undefined}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface IImageList {
|
interface IImageList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
@@ -289,6 +284,8 @@ export const ImageList: React.FC<IImageList> = ({
|
|||||||
const [isExportAll, setIsExportAll] = useState(false);
|
const [isExportAll, setIsExportAll] = useState(false);
|
||||||
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
|
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const filterMode = GQL.FilterMode.Images;
|
||||||
|
|
||||||
const otherOperations = [
|
const otherOperations = [
|
||||||
...(extraOperations ?? []),
|
...(extraOperations ?? []),
|
||||||
{
|
{
|
||||||
@@ -415,17 +412,26 @@ export const ImageList: React.FC<IImageList> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageItemList
|
<ItemListContext
|
||||||
zoomable
|
filterMode={filterMode}
|
||||||
selectable
|
useResult={useFindImages}
|
||||||
|
getItems={getItems}
|
||||||
|
getCount={getCount}
|
||||||
|
alterQuery={alterQuery}
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
view={view}
|
view={view}
|
||||||
alterQuery={alterQuery}
|
selectable
|
||||||
otherOperations={otherOperations}
|
>
|
||||||
addKeybinds={addKeybinds}
|
<ItemList
|
||||||
renderContent={renderContent}
|
zoomable
|
||||||
renderEditDialog={renderEditDialog}
|
view={view}
|
||||||
renderDeleteDialog={renderDeleteDialog}
|
otherOperations={otherOperations}
|
||||||
/>
|
addKeybinds={addKeybinds}
|
||||||
|
renderContent={renderContent}
|
||||||
|
renderEditDialog={renderEditDialog}
|
||||||
|
renderDeleteDialog={renderDeleteDialog}
|
||||||
|
renderMetadataByline={renderMetadataByline}
|
||||||
|
/>
|
||||||
|
</ItemListContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
76
ui/v2.5/src/components/List/FilterProvider.tsx
Normal file
76
ui/v2.5/src/components/List/FilterProvider.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { isFunction } from "lodash-es";
|
||||||
|
import { useFilterURL } from "./util";
|
||||||
|
|
||||||
|
interface IFilterContextOptions {
|
||||||
|
filter: ListFilterModel;
|
||||||
|
setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFilterContextState {
|
||||||
|
filter: ListFilterModel;
|
||||||
|
setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilterStateContext =
|
||||||
|
React.createContext<IFilterContextState | null>(null);
|
||||||
|
|
||||||
|
export const FilterContext = (
|
||||||
|
props: IFilterContextOptions & {
|
||||||
|
children?:
|
||||||
|
| ((props: IFilterContextState) => React.ReactNode)
|
||||||
|
| React.ReactNode;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const { filter, setFilter, children } = props;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterStateContext.Provider value={state}>
|
||||||
|
{isFunction(children)
|
||||||
|
? (children as (props: IFilterContextState) => React.ReactNode)(state)
|
||||||
|
: children}
|
||||||
|
</FilterStateContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFilter() {
|
||||||
|
const context = React.useContext(FilterStateContext);
|
||||||
|
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("useFilter must be used within a FilterStateContext");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This component is used to set the filter from the URL.
|
||||||
|
// It replaces the setFilter function to set the URL instead.
|
||||||
|
// It also loads the default filter if the URL is empty.
|
||||||
|
export const SetFilterURL = (props: {
|
||||||
|
defaultFilter?: ListFilterModel;
|
||||||
|
setURL?: boolean;
|
||||||
|
children?:
|
||||||
|
| ((props: IFilterContextState) => React.ReactNode)
|
||||||
|
| React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const { defaultFilter, setURL = true, children } = props;
|
||||||
|
|
||||||
|
const { filter, setFilter: setFilterOrig } = useFilter();
|
||||||
|
|
||||||
|
const { setFilter } = useFilterURL(filter, setFilterOrig, {
|
||||||
|
defaultFilter,
|
||||||
|
setURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterContext filter={filter} setFilter={setFilter}>
|
||||||
|
{children}
|
||||||
|
</FilterContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
87
ui/v2.5/src/components/List/FilteredListToolbar.tsx
Normal file
87
ui/v2.5/src/components/List/FilteredListToolbar.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { QueryResult } from "@apollo/client";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||||
|
import { ListFilter } from "./ListFilter";
|
||||||
|
import { ListViewOptions } from "./ListViewOptions";
|
||||||
|
import {
|
||||||
|
IListFilterOperation,
|
||||||
|
ListOperationButtons,
|
||||||
|
} from "./ListOperationButtons";
|
||||||
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
|
import { ButtonToolbar } from "react-bootstrap";
|
||||||
|
import { View } from "./views";
|
||||||
|
import { useListContext } from "./ListProvider";
|
||||||
|
import { useFilter } from "./FilterProvider";
|
||||||
|
|
||||||
|
export interface IItemListOperation<T extends QueryResult> {
|
||||||
|
text: string;
|
||||||
|
onClick: (
|
||||||
|
result: T,
|
||||||
|
filter: ListFilterModel,
|
||||||
|
selectedIds: Set<string>
|
||||||
|
) => Promise<void>;
|
||||||
|
isDisplayed?: (
|
||||||
|
result: T,
|
||||||
|
filter: ListFilterModel,
|
||||||
|
selectedIds: Set<string>
|
||||||
|
) => boolean;
|
||||||
|
postRefetch?: boolean;
|
||||||
|
icon?: IconDefinition;
|
||||||
|
buttonVariant?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilteredListToolbar: React.FC<{
|
||||||
|
showEditFilter: (editingCriterion?: string) => void;
|
||||||
|
view?: View;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
operations?: IListFilterOperation[];
|
||||||
|
zoomable?: boolean;
|
||||||
|
}> = ({
|
||||||
|
showEditFilter,
|
||||||
|
view,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
operations,
|
||||||
|
zoomable = false,
|
||||||
|
}) => {
|
||||||
|
const { getSelected, onSelectAll, onSelectNone } = useListContext();
|
||||||
|
const { filter, setFilter } = useFilter();
|
||||||
|
|
||||||
|
const filterOptions = filter.options;
|
||||||
|
|
||||||
|
function onChangeDisplayMode(displayMode: DisplayMode) {
|
||||||
|
setFilter(filter.setDisplayMode(displayMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeZoom(newZoomIndex: number) {
|
||||||
|
setFilter(filter.setZoom(newZoomIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonToolbar className="justify-content-center">
|
||||||
|
<ListFilter
|
||||||
|
onFilterUpdate={setFilter}
|
||||||
|
filter={filter}
|
||||||
|
openFilterDialog={() => showEditFilter()}
|
||||||
|
view={view}
|
||||||
|
/>
|
||||||
|
<ListOperationButtons
|
||||||
|
onSelectAll={onSelectAll}
|
||||||
|
onSelectNone={onSelectNone}
|
||||||
|
otherOperations={operations}
|
||||||
|
itemsSelected={getSelected().length > 0}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
<ListViewOptions
|
||||||
|
displayMode={filter.displayMode}
|
||||||
|
displayModeOptions={filterOptions.displayModeOptions}
|
||||||
|
onSetDisplayMode={onChangeDisplayMode}
|
||||||
|
zoomIndex={zoomable ? filter.zoomIndex : undefined}
|
||||||
|
onSetZoom={zoomable ? onChangeZoom : undefined}
|
||||||
|
/>
|
||||||
|
</ButtonToolbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
import React, {
|
import React, {
|
||||||
|
PropsWithChildren,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} 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 * as GQL from "src/core/generated-graphql";
|
||||||
import { QueryResult } from "@apollo/client";
|
import { QueryResult } from "@apollo/client";
|
||||||
import {
|
import {
|
||||||
@@ -18,69 +12,30 @@ import {
|
|||||||
CriterionValue,
|
CriterionValue,
|
||||||
} from "src/models/list-filter/criteria/criterion";
|
} from "src/models/list-filter/criteria/criterion";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
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 { EditFilterDialog } from "src/components/List/EditFilterDialog";
|
||||||
import { ListFilter } from "./ListFilter";
|
|
||||||
import { FilterTags } from "./FilterTags";
|
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 { 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 {
|
interface IItemListProps<T extends QueryResult, E extends IHasID> {
|
||||||
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> {
|
|
||||||
view?: View;
|
view?: View;
|
||||||
defaultSort?: string;
|
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
|
||||||
filterDialog?: (
|
|
||||||
criteria: Criterion<CriterionValue>[],
|
|
||||||
setCriteria: (v: Criterion<CriterionValue>[]) => void
|
|
||||||
) => React.ReactNode;
|
|
||||||
zoomable?: boolean;
|
zoomable?: boolean;
|
||||||
selectable?: boolean;
|
|
||||||
alterQuery?: boolean;
|
|
||||||
defaultZoomIndex?: number;
|
|
||||||
otherOperations?: IItemListOperation<T>[];
|
otherOperations?: IItemListOperation<T>[];
|
||||||
renderContent: (
|
renderContent: (
|
||||||
result: T,
|
result: T,
|
||||||
@@ -90,6 +45,7 @@ interface IItemListProps<T extends QueryResult, E extends IDataItem> {
|
|||||||
onChangePage: (page: number) => void,
|
onChangePage: (page: number) => void,
|
||||||
pageCount: number
|
pageCount: number
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
|
renderMetadataByline?: (data: T) => React.ReactNode;
|
||||||
renderEditDialog?: (
|
renderEditDialog?: (
|
||||||
selected: E[],
|
selected: E[],
|
||||||
onClose: (applied: boolean) => void
|
onClose: (applied: boolean) => void
|
||||||
@@ -105,570 +61,273 @@ interface IItemListProps<T extends QueryResult, E extends IDataItem> {
|
|||||||
) => () => void;
|
) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSelectedData = <I extends IDataItem>(
|
export const ItemList = <T extends QueryResult, E extends IHasID>(
|
||||||
data: I[],
|
props: IItemListProps<T, E>
|
||||||
selectedIds: Set<string>
|
) => {
|
||||||
) => data.filter((value) => selectedIds.has(value.id));
|
const {
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
view,
|
view,
|
||||||
zoomable,
|
zoomable,
|
||||||
selectable,
|
|
||||||
otherOperations,
|
otherOperations,
|
||||||
renderContent,
|
renderContent,
|
||||||
renderEditDialog,
|
renderEditDialog,
|
||||||
renderDeleteDialog,
|
renderDeleteDialog,
|
||||||
|
renderMetadataByline,
|
||||||
addKeybinds,
|
addKeybinds,
|
||||||
}) => {
|
} = props;
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
||||||
const [lastClickedId, setLastClickedId] = useState<string>();
|
|
||||||
|
|
||||||
const [editingCriterion, setEditingCriterion] = useState<string>();
|
const { filter, setFilter: updateFilter } = useFilter();
|
||||||
const [showEditFilter, setShowEditFilter] = useState(false);
|
const { effectiveFilter, result, cachedResult, totalCount } =
|
||||||
|
useQueryResultContext<T, E>();
|
||||||
|
const {
|
||||||
|
selectedIds,
|
||||||
|
getSelected,
|
||||||
|
onSelectChange,
|
||||||
|
onSelectAll,
|
||||||
|
onSelectNone,
|
||||||
|
} = useListContext<E>();
|
||||||
|
|
||||||
const effectiveFilter = useMemo(() => {
|
const { modal, showModal, closeModal } = useModal();
|
||||||
if (filterHook) {
|
|
||||||
return filterHook(cloneDeep(filter));
|
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);
|
showModal(
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
<EditFilterDialog
|
||||||
const [metadataByline, setMetadataByline] = useState<React.ReactNode>();
|
filter={filter}
|
||||||
const items = useMemo(() => getItems(result), [result]);
|
onApply={onApplyEditFilter}
|
||||||
|
onCancel={() => closeModal()}
|
||||||
|
editingCriterion={editingCriterion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[filter, updateFilter, showModal, closeModal]
|
||||||
|
);
|
||||||
|
|
||||||
const [arePaging, setArePaging] = useState(false);
|
useListKeyboardShortcuts({
|
||||||
const hidePagination = !arePaging && result.loading;
|
currentPage: filter.currentPage,
|
||||||
|
onChangePage,
|
||||||
// useLayoutEffect to set total count before paint, avoiding a 0 being displayed
|
onSelectAll,
|
||||||
useLayoutEffect(() => {
|
onSelectNone,
|
||||||
if (result.loading) return;
|
pages,
|
||||||
setArePaging(false);
|
showEditFilter,
|
||||||
|
});
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (addKeybinds) {
|
||||||
|
const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds);
|
||||||
return () => {
|
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) {
|
async function onOperationClicked(o: IItemListOperation<T>) {
|
||||||
let start = startIndex;
|
await o.onClick(result, effectiveFilter, selectedIds);
|
||||||
let end = endIndex;
|
if (o.postRefetch) {
|
||||||
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
|
|
||||||
result.refetch();
|
result.refetch();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onDelete() {
|
const operations = otherOperations?.map((o) => ({
|
||||||
setIsDeleteDialogOpen(true);
|
text: o.text,
|
||||||
}
|
onClick: () => {
|
||||||
|
onOperationClicked(o);
|
||||||
function onDeleteDialogClosed(deleted: boolean) {
|
},
|
||||||
if (deleted) {
|
isDisplayed: () => {
|
||||||
onSelectNone();
|
if (o.isDisplayed) {
|
||||||
}
|
return o.isDisplayed(result, effectiveFilter, selectedIds);
|
||||||
setIsDeleteDialogOpen(false);
|
|
||||||
|
|
||||||
// refetch
|
|
||||||
result.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPagination() {
|
|
||||||
if (hidePagination) return;
|
|
||||||
return (
|
|
||||||
<Pagination
|
|
||||||
itemsPerPage={filter.itemsPerPage}
|
|
||||||
currentPage={filter.currentPage}
|
|
||||||
totalItems={totalCount}
|
|
||||||
metadataByline={metadataByline}
|
|
||||||
onChangePage={onChangePage}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPaginationIndex() {
|
|
||||||
if (hidePagination) return;
|
|
||||||
return (
|
|
||||||
<PaginationIndex
|
|
||||||
itemsPerPage={filter.itemsPerPage}
|
|
||||||
currentPage={filter.currentPage}
|
|
||||||
totalItems={totalCount}
|
|
||||||
metadataByline={metadataByline}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeRenderContent() {
|
|
||||||
if (result.loading) {
|
|
||||||
return <LoadingIndicator />;
|
|
||||||
}
|
|
||||||
if (result.error) {
|
|
||||||
return <h1>{result.error.message}</h1>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pages = Math.ceil(totalCount / filter.itemsPerPage);
|
return true;
|
||||||
return (
|
},
|
||||||
<>
|
icon: o.icon,
|
||||||
{renderContent(
|
buttonVariant: o.buttonVariant,
|
||||||
result,
|
}));
|
||||||
// #4780 - use effectiveFilter to ensure filterHook is applied
|
|
||||||
effectiveFilter,
|
function onEdit() {
|
||||||
selectedIds,
|
if (!renderEditDialog) {
|
||||||
onSelectChange,
|
return;
|
||||||
onChangePage,
|
|
||||||
pages
|
|
||||||
)}
|
|
||||||
{!!pages && (
|
|
||||||
<>
|
|
||||||
{renderPaginationIndex()}
|
|
||||||
{renderPagination()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChangeDisplayMode(displayMode: DisplayMode) {
|
showModal(
|
||||||
const newFilter = cloneDeep(filter);
|
renderEditDialog(getSelected(), (applied) => onEditDialogClosed(applied))
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const ItemList: React.FC<IItemListProps<T, E>> = (props) => {
|
function onEditDialogClosed(applied: boolean) {
|
||||||
const {
|
if (applied) {
|
||||||
view,
|
onSelectNone();
|
||||||
defaultSort = filterOptions.defaultSortBy,
|
}
|
||||||
defaultZoomIndex,
|
closeModal();
|
||||||
alterQuery = true,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const history = useHistory();
|
// refetch
|
||||||
const location = useLocation();
|
result.refetch();
|
||||||
const [filterInitialised, setFilterInitialised] = useState(false);
|
}
|
||||||
const { configuration: config } = useContext(ConfigurationContext);
|
|
||||||
|
|
||||||
const lastPathname = useRef(location.pathname);
|
function onDelete() {
|
||||||
const defaultDisplayMode = filterOptions.displayModeOptions[0];
|
if (!renderDeleteDialog) {
|
||||||
const [filter, setFilter] = useState<ListFilterModel>(
|
return;
|
||||||
() => new ListFilterModel(filterMode)
|
}
|
||||||
|
|
||||||
|
showModal(
|
||||||
|
renderDeleteDialog(getSelected(), (deleted) =>
|
||||||
|
onDeleteDialogClosed(deleted)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { defaultFilter, loading: defaultFilterLoading } = useDefaultFilter(
|
function onDeleteDialogClosed(deleted: boolean) {
|
||||||
filterMode,
|
if (deleted) {
|
||||||
view
|
onSelectNone();
|
||||||
);
|
}
|
||||||
|
closeModal();
|
||||||
|
|
||||||
const updateQueryParams = useCallback(
|
// refetch
|
||||||
(newFilter: ListFilterModel) => {
|
result.refetch();
|
||||||
if (!alterQuery) return;
|
}
|
||||||
|
|
||||||
const newParams = newFilter.makeQueryParameters();
|
function onRemoveCriterion(removedCriterion: Criterion<CriterionValue>) {
|
||||||
history.replace({ ...history.location, search: newParams });
|
updateFilter(filter.removeCriterion(removedCriterion.criterionOption.type));
|
||||||
},
|
}
|
||||||
[alterQuery, history]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateFilter = useCallback(
|
function onClearAllCriteria() {
|
||||||
(newFilter: ListFilterModel) => {
|
updateFilter(filter.clearCriteria());
|
||||||
setFilter(newFilter);
|
}
|
||||||
updateQueryParams(newFilter);
|
|
||||||
},
|
|
||||||
[updateQueryParams]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 'Startup' hook, initialises the filters
|
return (
|
||||||
useEffect(() => {
|
<div className="item-list-container">
|
||||||
// Only run once
|
<FilteredListToolbar
|
||||||
if (filterInitialised) return;
|
showEditFilter={showEditFilter}
|
||||||
|
view={view}
|
||||||
let newFilter = new ListFilterModel(filterMode, config, defaultZoomIndex);
|
operations={operations}
|
||||||
let loadDefault = true;
|
zoomable={zoomable}
|
||||||
if (alterQuery && location.search) {
|
onEdit={renderEditDialog ? onEdit : undefined}
|
||||||
loadDefault = false;
|
onDelete={renderDeleteDialog ? onDelete : undefined}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
);
|
<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>(
|
export const showWhenSelected = <T extends QueryResult>(
|
||||||
result: T,
|
result: T,
|
||||||
filter: ListFilterModel,
|
filter: ListFilterModel,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
import { Icon } from "../Shared/Icon";
|
import { Icon } from "../Shared/Icon";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import useFocus from "src/utils/focus";
|
import useFocus from "src/utils/focus";
|
||||||
import { ListFilterOptions } from "src/models/list-filter/filter-options";
|
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { SavedFilterDropdown } from "./SavedFilterList";
|
import { SavedFilterDropdown } from "./SavedFilterList";
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +35,6 @@ import { View } from "./views";
|
|||||||
interface IListFilterProps {
|
interface IListFilterProps {
|
||||||
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
||||||
filter: ListFilterModel;
|
filter: ListFilterModel;
|
||||||
filterOptions: ListFilterOptions;
|
|
||||||
view?: View;
|
view?: View;
|
||||||
openFilterDialog: () => void;
|
openFilterDialog: () => void;
|
||||||
}
|
}
|
||||||
@@ -46,7 +44,6 @@ const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"];
|
|||||||
export const ListFilter: React.FC<IListFilterProps> = ({
|
export const ListFilter: React.FC<IListFilterProps> = ({
|
||||||
onFilterUpdate,
|
onFilterUpdate,
|
||||||
filter,
|
filter,
|
||||||
filterOptions,
|
|
||||||
openFilterDialog,
|
openFilterDialog,
|
||||||
view,
|
view,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -58,6 +55,8 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||||||
const perPageSelect = useRef(null);
|
const perPageSelect = useRef(null);
|
||||||
const [perPageInput, perPageFocus] = useFocus();
|
const [perPageInput, perPageFocus] = useFocus();
|
||||||
|
|
||||||
|
const filterOptions = filter.options;
|
||||||
|
|
||||||
const searchQueryUpdated = useCallback(
|
const searchQueryUpdated = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const newFilter = cloneDeep(filter);
|
const newFilter = cloneDeep(filter);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
faTrash,
|
faTrash,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
interface IListFilterOperation {
|
export interface IListFilterOperation {
|
||||||
text: string;
|
text: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
isDisplayed?: () => boolean;
|
isDisplayed?: () => boolean;
|
||||||
|
|||||||
156
ui/v2.5/src/components/List/ListProvider.tsx
Normal file
156
ui/v2.5/src/components/List/ListProvider.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { IListSelect, useCachedQueryResult, useListSelect } from "./util";
|
||||||
|
import { isFunction } from "lodash-es";
|
||||||
|
import { IHasID } from "src/utils/data";
|
||||||
|
import { useFilter } from "./FilterProvider";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { QueryResult } from "@apollo/client";
|
||||||
|
|
||||||
|
interface IListContextOptions<T extends IHasID> {
|
||||||
|
selectable?: boolean;
|
||||||
|
items: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IListContextState<T extends IHasID = IHasID> = IListSelect<T> & {
|
||||||
|
selectable: boolean;
|
||||||
|
items: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ListStateContext = React.createContext<IListContextState | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ListContext = <T extends IHasID = IHasID>(
|
||||||
|
props: IListContextOptions<T> & {
|
||||||
|
children?:
|
||||||
|
| ((props: IListContextState) => React.ReactNode)
|
||||||
|
| React.ReactNode;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const { selectable = false, items, children } = props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedIds,
|
||||||
|
getSelected,
|
||||||
|
onSelectChange,
|
||||||
|
onSelectAll,
|
||||||
|
onSelectNone,
|
||||||
|
} = useListSelect(items);
|
||||||
|
|
||||||
|
const state: IListContextState<T> = {
|
||||||
|
selectable,
|
||||||
|
selectedIds,
|
||||||
|
getSelected,
|
||||||
|
onSelectChange,
|
||||||
|
onSelectAll,
|
||||||
|
onSelectNone,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListStateContext.Provider value={state}>
|
||||||
|
{isFunction(children)
|
||||||
|
? (children as (props: IListContextState) => React.ReactNode)(state)
|
||||||
|
: children}
|
||||||
|
</ListStateContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useListContext<T extends IHasID = IHasID>() {
|
||||||
|
const context = React.useContext(ListStateContext);
|
||||||
|
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("useListContext must be used within a ListStateContext");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context as IListContextState<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IQueryResultContextOptions<
|
||||||
|
T extends QueryResult,
|
||||||
|
E extends IHasID = IHasID
|
||||||
|
> {
|
||||||
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
|
useResult: (filter: ListFilterModel) => T;
|
||||||
|
getCount: (data: T) => number;
|
||||||
|
getItems: (data: T) => E[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IQueryResultContextState<
|
||||||
|
T extends QueryResult = QueryResult,
|
||||||
|
E extends IHasID = IHasID
|
||||||
|
> {
|
||||||
|
effectiveFilter: ListFilterModel;
|
||||||
|
result: T;
|
||||||
|
cachedResult: T;
|
||||||
|
items: E[];
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryResultStateContext =
|
||||||
|
React.createContext<IQueryResultContextState | null>(null);
|
||||||
|
|
||||||
|
export const QueryResultContext = <
|
||||||
|
T extends QueryResult,
|
||||||
|
E extends IHasID = IHasID
|
||||||
|
>(
|
||||||
|
props: IQueryResultContextOptions<T, E> & {
|
||||||
|
children?:
|
||||||
|
| ((props: IQueryResultContextState<T, E>) => React.ReactNode)
|
||||||
|
| React.ReactNode;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const { filterHook, useResult, getItems, getCount, children } = props;
|
||||||
|
|
||||||
|
const { filter } = useFilter();
|
||||||
|
const effectiveFilter = useMemo(() => {
|
||||||
|
if (filterHook) {
|
||||||
|
return filterHook(filter.clone());
|
||||||
|
}
|
||||||
|
return filter;
|
||||||
|
}, [filter, filterHook]);
|
||||||
|
|
||||||
|
const result = useResult(effectiveFilter);
|
||||||
|
|
||||||
|
// use cached query result for pagination and metadata rendering
|
||||||
|
const cachedResult = useCachedQueryResult(effectiveFilter, result);
|
||||||
|
|
||||||
|
const items = useMemo(() => getItems(result), [getItems, result]);
|
||||||
|
const totalCount = useMemo(
|
||||||
|
() => getCount(cachedResult),
|
||||||
|
[getCount, cachedResult]
|
||||||
|
);
|
||||||
|
|
||||||
|
const state: IQueryResultContextState<T, E> = {
|
||||||
|
effectiveFilter,
|
||||||
|
result,
|
||||||
|
cachedResult,
|
||||||
|
items,
|
||||||
|
totalCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryResultStateContext.Provider value={state}>
|
||||||
|
{isFunction(children)
|
||||||
|
? (children as (props: IQueryResultContextState) => React.ReactNode)(
|
||||||
|
state
|
||||||
|
)
|
||||||
|
: children}
|
||||||
|
</QueryResultStateContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useQueryResultContext<
|
||||||
|
T extends QueryResult,
|
||||||
|
E extends IHasID = IHasID
|
||||||
|
>() {
|
||||||
|
const context = React.useContext(QueryResultStateContext);
|
||||||
|
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error(
|
||||||
|
"useQueryResultContext must be used within a ListStateContext"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context as IQueryResultContextState<T, E>;
|
||||||
|
}
|
||||||
98
ui/v2.5/src/components/List/PagedList.tsx
Normal file
98
ui/v2.5/src/components/List/PagedList.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { PropsWithChildren, useMemo } from "react";
|
||||||
|
import { QueryResult } from "@apollo/client";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { Pagination, PaginationIndex } from "./Pagination";
|
||||||
|
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||||
|
|
||||||
|
export const PagedList: React.FC<
|
||||||
|
PropsWithChildren<{
|
||||||
|
result: QueryResult;
|
||||||
|
cachedResult: QueryResult;
|
||||||
|
filter: ListFilterModel;
|
||||||
|
totalCount: number;
|
||||||
|
onChangePage: (page: number) => void;
|
||||||
|
metadataByline?: React.ReactNode;
|
||||||
|
}>
|
||||||
|
> = ({
|
||||||
|
result,
|
||||||
|
cachedResult,
|
||||||
|
filter,
|
||||||
|
totalCount,
|
||||||
|
onChangePage,
|
||||||
|
metadataByline,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const pages = Math.ceil(totalCount / filter.itemsPerPage);
|
||||||
|
|
||||||
|
const pagination = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<Pagination
|
||||||
|
itemsPerPage={filter.itemsPerPage}
|
||||||
|
currentPage={filter.currentPage}
|
||||||
|
totalItems={totalCount}
|
||||||
|
metadataByline={metadataByline}
|
||||||
|
onChangePage={onChangePage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
filter.itemsPerPage,
|
||||||
|
filter.currentPage,
|
||||||
|
totalCount,
|
||||||
|
metadataByline,
|
||||||
|
onChangePage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const paginationIndex = useMemo(() => {
|
||||||
|
if (cachedResult.loading) return;
|
||||||
|
return (
|
||||||
|
<PaginationIndex
|
||||||
|
itemsPerPage={filter.itemsPerPage}
|
||||||
|
currentPage={filter.currentPage}
|
||||||
|
totalItems={totalCount}
|
||||||
|
metadataByline={metadataByline}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
cachedResult.loading,
|
||||||
|
filter.itemsPerPage,
|
||||||
|
filter.currentPage,
|
||||||
|
totalCount,
|
||||||
|
metadataByline,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (result.loading) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
if (result.error) {
|
||||||
|
return <h1>{result.error.message}</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
{!!pages && (
|
||||||
|
<>
|
||||||
|
{paginationIndex}
|
||||||
|
{pagination}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
result.loading,
|
||||||
|
result.error,
|
||||||
|
pages,
|
||||||
|
children,
|
||||||
|
pagination,
|
||||||
|
paginationIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pagination}
|
||||||
|
{paginationIndex}
|
||||||
|
{content}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 { 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 { ConfigurationContext } from "src/hooks/Config";
|
||||||
import { View } from "./views";
|
import { View } from "./views";
|
||||||
|
|
||||||
export function useDefaultFilter(mode: GQL.FilterMode, view?: View) {
|
export function useFilterURL(
|
||||||
const emptyFilter = useMemo(() => new ListFilterModel(mode), [mode]);
|
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 { configuration: config, loading } = useContext(ConfigurationContext);
|
||||||
|
|
||||||
const defaultFilter = useMemo(() => {
|
const defaultFilter = useMemo(() => {
|
||||||
@@ -30,3 +88,258 @@ export function useDefaultFilter(mode: GQL.FilterMode, view?: View) {
|
|||||||
|
|
||||||
return { defaultFilter: retFilter, loading };
|
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,
|
useFindPerformers,
|
||||||
usePerformersDestroy,
|
usePerformersDestroy,
|
||||||
} from "src/core/StashService";
|
} 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 { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
import { PerformerTagger } from "../Tagger/performers/PerformerTagger";
|
import { PerformerTagger } from "../Tagger/performers/PerformerTagger";
|
||||||
@@ -23,16 +23,13 @@ import TextUtils from "src/utils/text";
|
|||||||
import { PerformerCardGrid } from "./PerformerCardGrid";
|
import { PerformerCardGrid } from "./PerformerCardGrid";
|
||||||
import { View } from "../List/views";
|
import { View } from "../List/views";
|
||||||
|
|
||||||
const PerformerItemList = makeItemList({
|
function getItems(result: GQL.FindPerformersQueryResult) {
|
||||||
filterMode: GQL.FilterMode.Performers,
|
return result?.data?.findPerformers?.performers ?? [];
|
||||||
useResult: useFindPerformers,
|
}
|
||||||
getItems(result: GQL.FindPerformersQueryResult) {
|
|
||||||
return result?.data?.findPerformers?.performers ?? [];
|
function getCount(result: GQL.FindPerformersQueryResult) {
|
||||||
},
|
return result?.data?.findPerformers?.count ?? 0;
|
||||||
getCount(result: GQL.FindPerformersQueryResult) {
|
}
|
||||||
return result?.data?.findPerformers?.count ?? 0;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const FormatHeight = (height?: number | null) => {
|
export const FormatHeight = (height?: number | null) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -175,6 +172,8 @@ export const PerformerList: React.FC<IPerformerList> = ({
|
|||||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||||
const [isExportAll, setIsExportAll] = useState(false);
|
const [isExportAll, setIsExportAll] = useState(false);
|
||||||
|
|
||||||
|
const filterMode = GQL.FilterMode.Performers;
|
||||||
|
|
||||||
const otherOperations = [
|
const otherOperations = [
|
||||||
{
|
{
|
||||||
text: intl.formatMessage({ id: "actions.open_random" }),
|
text: intl.formatMessage({ id: "actions.open_random" }),
|
||||||
@@ -319,16 +318,24 @@ export const PerformerList: React.FC<IPerformerList> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PerformerItemList
|
<ItemListContext
|
||||||
selectable
|
filterMode={filterMode}
|
||||||
|
useResult={useFindPerformers}
|
||||||
|
getItems={getItems}
|
||||||
|
getCount={getCount}
|
||||||
|
alterQuery={alterQuery}
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
view={view}
|
view={view}
|
||||||
alterQuery={alterQuery}
|
selectable
|
||||||
otherOperations={otherOperations}
|
>
|
||||||
addKeybinds={addKeybinds}
|
<ItemList
|
||||||
renderContent={renderContent}
|
view={view}
|
||||||
renderEditDialog={renderEditDialog}
|
otherOperations={otherOperations}
|
||||||
renderDeleteDialog={renderDeleteDialog}
|
addKeybinds={addKeybinds}
|
||||||
/>
|
renderContent={renderContent}
|
||||||
|
renderEditDialog={renderEditDialog}
|
||||||
|
renderDeleteDialog={renderDeleteDialog}
|
||||||
|
/>
|
||||||
|
</ItemListContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useHistory } from "react-router-dom";
|
|||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { queryFindScenes, useFindScenes } from "src/core/StashService";
|
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 { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
import { Tagger } from "../Tagger/scenes/SceneTagger";
|
import { Tagger } from "../Tagger/scenes/SceneTagger";
|
||||||
@@ -26,51 +26,49 @@ import { objectTitle } from "src/core/files";
|
|||||||
import TextUtils from "src/utils/text";
|
import TextUtils from "src/utils/text";
|
||||||
import { View } from "../List/views";
|
import { View } from "../List/views";
|
||||||
|
|
||||||
const SceneItemList = makeItemList({
|
function getItems(result: GQL.FindScenesQueryResult) {
|
||||||
filterMode: GQL.FilterMode.Scenes,
|
return result?.data?.findScenes?.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;
|
|
||||||
|
|
||||||
if (!duration && !size) {
|
function getCount(result: GQL.FindScenesQueryResult) {
|
||||||
return;
|
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 (
|
if (!duration && !size) {
|
||||||
<span className="scenes-stats">
|
return;
|
||||||
(
|
}
|
||||||
{duration ? (
|
|
||||||
<span className="scenes-duration">
|
const separator = duration && size ? " - " : "";
|
||||||
{TextUtils.secondsAsTimeString(duration, 3)}
|
|
||||||
</span>
|
return (
|
||||||
) : undefined}
|
<span className="scenes-stats">
|
||||||
{separator}
|
(
|
||||||
{size && filesize ? (
|
{duration ? (
|
||||||
<span className="scenes-size">
|
<span className="scenes-duration">
|
||||||
<FormattedNumber
|
{TextUtils.secondsAsTimeString(duration, 3)}
|
||||||
value={filesize.size}
|
</span>
|
||||||
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
|
) : undefined}
|
||||||
filesize.unit
|
{separator}
|
||||||
)}
|
{size && filesize ? (
|
||||||
/>
|
<span className="scenes-size">
|
||||||
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
|
<FormattedNumber
|
||||||
</span>
|
value={filesize.size}
|
||||||
) : undefined}
|
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
|
||||||
)
|
filesize.unit
|
||||||
</span>
|
)}
|
||||||
);
|
/>
|
||||||
},
|
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
|
||||||
});
|
</span>
|
||||||
|
) : undefined}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ISceneList {
|
interface ISceneList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
@@ -95,6 +93,8 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||||
const [isExportAll, setIsExportAll] = useState(false);
|
const [isExportAll, setIsExportAll] = useState(false);
|
||||||
|
|
||||||
|
const filterMode = GQL.FilterMode.Scenes;
|
||||||
|
|
||||||
const otherOperations = [
|
const otherOperations = [
|
||||||
{
|
{
|
||||||
text: intl.formatMessage({ id: "actions.play_selected" }),
|
text: intl.formatMessage({ id: "actions.play_selected" }),
|
||||||
@@ -350,19 +350,28 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TaggerContext>
|
<TaggerContext>
|
||||||
<SceneItemList
|
<ItemListContext
|
||||||
zoomable
|
filterMode={filterMode}
|
||||||
selectable
|
defaultSort={defaultSort}
|
||||||
|
useResult={useFindScenes}
|
||||||
|
getItems={getItems}
|
||||||
|
getCount={getCount}
|
||||||
|
alterQuery={alterQuery}
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
view={view}
|
view={view}
|
||||||
alterQuery={alterQuery}
|
selectable
|
||||||
otherOperations={otherOperations}
|
>
|
||||||
addKeybinds={addKeybinds}
|
<ItemList
|
||||||
defaultSort={defaultSort}
|
zoomable
|
||||||
renderContent={renderContent}
|
view={view}
|
||||||
renderEditDialog={renderEditDialog}
|
otherOperations={otherOperations}
|
||||||
renderDeleteDialog={renderDeleteDialog}
|
addKeybinds={addKeybinds}
|
||||||
/>
|
renderContent={renderContent}
|
||||||
|
renderEditDialog={renderEditDialog}
|
||||||
|
renderDeleteDialog={renderDeleteDialog}
|
||||||
|
renderMetadataByline={renderMetadataByline}
|
||||||
|
/>
|
||||||
|
</ItemListContext>
|
||||||
</TaggerContext>
|
</TaggerContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,22 +9,19 @@ import {
|
|||||||
useFindSceneMarkers,
|
useFindSceneMarkers,
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import NavUtils from "src/utils/navigation";
|
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 { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
import { MarkerWallPanel } from "../Wall/WallPanel";
|
import { MarkerWallPanel } from "../Wall/WallPanel";
|
||||||
import { View } from "../List/views";
|
import { View } from "../List/views";
|
||||||
|
|
||||||
const SceneMarkerItemList = makeItemList({
|
function getItems(result: GQL.FindSceneMarkersQueryResult) {
|
||||||
filterMode: GQL.FilterMode.SceneMarkers,
|
return result?.data?.findSceneMarkers?.scene_markers ?? [];
|
||||||
useResult: useFindSceneMarkers,
|
}
|
||||||
getItems(result: GQL.FindSceneMarkersQueryResult) {
|
|
||||||
return result?.data?.findSceneMarkers?.scene_markers ?? [];
|
function getCount(result: GQL.FindSceneMarkersQueryResult) {
|
||||||
},
|
return result?.data?.findSceneMarkers?.count ?? 0;
|
||||||
getCount(result: GQL.FindSceneMarkersQueryResult) {
|
}
|
||||||
return result?.data?.findSceneMarkers?.count ?? 0;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ISceneMarkerList {
|
interface ISceneMarkerList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
@@ -40,6 +37,8 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = ({
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
|
const filterMode = GQL.FilterMode.SceneMarkers;
|
||||||
|
|
||||||
const otherOperations = [
|
const otherOperations = [
|
||||||
{
|
{
|
||||||
text: intl.formatMessage({ id: "actions.play_random" }),
|
text: intl.formatMessage({ id: "actions.play_random" }),
|
||||||
@@ -97,14 +96,22 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SceneMarkerItemList
|
<ItemListContext
|
||||||
|
filterMode={filterMode}
|
||||||
|
useResult={useFindSceneMarkers}
|
||||||
|
getItems={getItems}
|
||||||
|
getCount={getCount}
|
||||||
|
alterQuery={alterQuery}
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
view={view}
|
view={view}
|
||||||
alterQuery={alterQuery}
|
>
|
||||||
otherOperations={otherOperations}
|
<ItemList
|
||||||
addKeybinds={addKeybinds}
|
view={view}
|
||||||
renderContent={renderContent}
|
otherOperations={otherOperations}
|
||||||
/>
|
addKeybinds={addKeybinds}
|
||||||
|
renderContent={renderContent}
|
||||||
|
/>
|
||||||
|
</ItemListContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import cx from "classnames";
|
|||||||
|
|
||||||
import { useToast } from "src/hooks/Toast";
|
import { useToast } from "src/hooks/Toast";
|
||||||
import { useDebounce } from "src/hooks/debounce";
|
import { useDebounce } from "src/hooks/debounce";
|
||||||
|
import { IHasID } from "src/utils/data";
|
||||||
interface IHasID {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Option<T> = { value: string; object: T };
|
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 {
|
.LoadingIndicator {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -6,7 +26,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&:not(.card-based) {
|
&:not(.card-based) {
|
||||||
height: 70vh;
|
padding-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-message {
|
&-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 React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
|
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
@@ -79,16 +79,18 @@ const StudioTabs: React.FC<{
|
|||||||
abbreviateCounter: boolean;
|
abbreviateCounter: boolean;
|
||||||
showAllCounts?: boolean;
|
showAllCounts?: boolean;
|
||||||
}> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => {
|
}> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => {
|
||||||
|
const [showAllDetails, setShowAllDetails] = useState<boolean>(showAllCounts);
|
||||||
|
|
||||||
const sceneCount =
|
const sceneCount =
|
||||||
(showAllCounts ? studio.scene_count_all : studio.scene_count) ?? 0;
|
(showAllDetails ? studio.scene_count_all : studio.scene_count) ?? 0;
|
||||||
const galleryCount =
|
const galleryCount =
|
||||||
(showAllCounts ? studio.gallery_count_all : studio.gallery_count) ?? 0;
|
(showAllDetails ? studio.gallery_count_all : studio.gallery_count) ?? 0;
|
||||||
const imageCount =
|
const imageCount =
|
||||||
(showAllCounts ? studio.image_count_all : studio.image_count) ?? 0;
|
(showAllDetails ? studio.image_count_all : studio.image_count) ?? 0;
|
||||||
const performerCount =
|
const performerCount =
|
||||||
(showAllCounts ? studio.performer_count_all : studio.performer_count) ?? 0;
|
(showAllDetails ? studio.performer_count_all : studio.performer_count) ?? 0;
|
||||||
const groupCount =
|
const groupCount =
|
||||||
(showAllCounts ? studio.group_count_all : studio.group_count) ?? 0;
|
(showAllDetails ? studio.group_count_all : studio.group_count) ?? 0;
|
||||||
|
|
||||||
const populatedDefaultTab = useMemo(() => {
|
const populatedDefaultTab = useMemo(() => {
|
||||||
let ret: TabKey = "scenes";
|
let ret: TabKey = "scenes";
|
||||||
@@ -123,6 +125,21 @@ const StudioTabs: React.FC<{
|
|||||||
baseURL: `/studios/${studio.id}`,
|
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 (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
id="studio-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>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="galleries"
|
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>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="images"
|
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>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="performers"
|
eventKey="performers"
|
||||||
@@ -177,9 +209,11 @@ const StudioTabs: React.FC<{
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{contentSwitch}
|
||||||
<StudioPerformersPanel
|
<StudioPerformersPanel
|
||||||
active={tabKey === "performers"}
|
active={tabKey === "performers"}
|
||||||
studio={studio}
|
studio={studio}
|
||||||
|
showChildStudioContent={showAllDetails}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
<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>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="childstudios"
|
eventKey="childstudios"
|
||||||
|
|||||||
@@ -5,16 +5,8 @@ import { ListFilterModel } from "src/models/list-filter/filter";
|
|||||||
import { StudioList } from "../StudioList";
|
import { StudioList } from "../StudioList";
|
||||||
import { View } from "src/components/List/views";
|
import { View } from "src/components/List/views";
|
||||||
|
|
||||||
interface IStudioChildrenPanel {
|
function useFilterHook(studio: GQL.StudioDataFragment) {
|
||||||
active: boolean;
|
return (filter: ListFilterModel) => {
|
||||||
studio: GQL.StudioDataFragment;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({
|
|
||||||
active,
|
|
||||||
studio,
|
|
||||||
}) => {
|
|
||||||
function filterHook(filter: ListFilterModel) {
|
|
||||||
const studioValue = { id: studio.id!, label: studio.name! };
|
const studioValue = { id: studio.id!, label: studio.name! };
|
||||||
// if studio is already present, then we modify it, otherwise add
|
// if studio is already present, then we modify it, otherwise add
|
||||||
let parentStudioCriterion = filter.criteria.find((c) => {
|
let parentStudioCriterion = filter.criteria.find((c) => {
|
||||||
@@ -44,7 +36,19 @@ export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return filter;
|
return filter;
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IStudioChildrenPanel {
|
||||||
|
active: boolean;
|
||||||
|
studio: GQL.StudioDataFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({
|
||||||
|
active,
|
||||||
|
studio,
|
||||||
|
}) => {
|
||||||
|
const filterHook = useFilterHook(studio);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StudioList
|
<StudioList
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
|
|||||||
interface IStudioGalleriesPanel {
|
interface IStudioGalleriesPanel {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
studio: GQL.StudioDataFragment;
|
studio: GQL.StudioDataFragment;
|
||||||
|
showChildStudioContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StudioGalleriesPanel: React.FC<IStudioGalleriesPanel> = ({
|
export const StudioGalleriesPanel: React.FC<IStudioGalleriesPanel> = ({
|
||||||
active,
|
active,
|
||||||
studio,
|
studio,
|
||||||
|
showChildStudioContent,
|
||||||
}) => {
|
}) => {
|
||||||
const filterHook = useStudioFilterHook(studio);
|
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
|
||||||
return (
|
return (
|
||||||
<GalleryList
|
<GalleryList
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
|
|||||||
interface IStudioGroupsPanel {
|
interface IStudioGroupsPanel {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
studio: GQL.StudioDataFragment;
|
studio: GQL.StudioDataFragment;
|
||||||
|
showChildStudioContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StudioGroupsPanel: React.FC<IStudioGroupsPanel> = ({
|
export const StudioGroupsPanel: React.FC<IStudioGroupsPanel> = ({
|
||||||
active,
|
active,
|
||||||
studio,
|
studio,
|
||||||
|
showChildStudioContent,
|
||||||
}) => {
|
}) => {
|
||||||
const filterHook = useStudioFilterHook(studio);
|
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
|
||||||
return (
|
return (
|
||||||
<GroupList
|
<GroupList
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
|
|||||||
interface IStudioImagesPanel {
|
interface IStudioImagesPanel {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
studio: GQL.StudioDataFragment;
|
studio: GQL.StudioDataFragment;
|
||||||
|
showChildStudioContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({
|
export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({
|
||||||
active,
|
active,
|
||||||
studio,
|
studio,
|
||||||
|
showChildStudioContent,
|
||||||
}) => {
|
}) => {
|
||||||
const filterHook = useStudioFilterHook(studio);
|
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
|
||||||
return (
|
return (
|
||||||
<ImageList
|
<ImageList
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { View } from "src/components/List/views";
|
|||||||
interface IStudioPerformersPanel {
|
interface IStudioPerformersPanel {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
studio: GQL.StudioDataFragment;
|
studio: GQL.StudioDataFragment;
|
||||||
|
showChildStudioContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
|
export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
|
||||||
active,
|
active,
|
||||||
studio,
|
studio,
|
||||||
|
showChildStudioContent,
|
||||||
}) => {
|
}) => {
|
||||||
const studioCriterion = new StudiosCriterion();
|
const studioCriterion = new StudiosCriterion();
|
||||||
studioCriterion.value = {
|
studioCriterion.value = {
|
||||||
@@ -28,7 +30,7 @@ export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
|
|||||||
groups: [studioCriterion],
|
groups: [studioCriterion],
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterHook = useStudioFilterHook(studio);
|
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PerformerList
|
<PerformerList
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
|
|||||||
interface IStudioScenesPanel {
|
interface IStudioScenesPanel {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
studio: GQL.StudioDataFragment;
|
studio: GQL.StudioDataFragment;
|
||||||
|
showChildStudioContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({
|
export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({
|
||||||
active,
|
active,
|
||||||
studio,
|
studio,
|
||||||
|
showChildStudioContent,
|
||||||
}) => {
|
}) => {
|
||||||
const filterHook = useStudioFilterHook(studio);
|
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
|
||||||
return (
|
return (
|
||||||
<SceneList
|
<SceneList
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useFindStudios,
|
useFindStudios,
|
||||||
useStudiosDestroy,
|
useStudiosDestroy,
|
||||||
} from "src/core/StashService";
|
} 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 { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
import { ExportDialog } from "../Shared/ExportDialog";
|
import { ExportDialog } from "../Shared/ExportDialog";
|
||||||
@@ -18,16 +18,13 @@ import { StudioTagger } from "../Tagger/studios/StudioTagger";
|
|||||||
import { StudioCardGrid } from "./StudioCardGrid";
|
import { StudioCardGrid } from "./StudioCardGrid";
|
||||||
import { View } from "../List/views";
|
import { View } from "../List/views";
|
||||||
|
|
||||||
const StudioItemList = makeItemList({
|
function getItems(result: GQL.FindStudiosQueryResult) {
|
||||||
filterMode: GQL.FilterMode.Studios,
|
return result?.data?.findStudios?.studios ?? [];
|
||||||
useResult: useFindStudios,
|
}
|
||||||
getItems(result: GQL.FindStudiosQueryResult) {
|
|
||||||
return result?.data?.findStudios?.studios ?? [];
|
function getCount(result: GQL.FindStudiosQueryResult) {
|
||||||
},
|
return result?.data?.findStudios?.count ?? 0;
|
||||||
getCount(result: GQL.FindStudiosQueryResult) {
|
}
|
||||||
return result?.data?.findStudios?.count ?? 0;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IStudioList {
|
interface IStudioList {
|
||||||
fromParent?: boolean;
|
fromParent?: boolean;
|
||||||
@@ -47,6 +44,8 @@ export const StudioList: React.FC<IStudioList> = ({
|
|||||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||||
const [isExportAll, setIsExportAll] = useState(false);
|
const [isExportAll, setIsExportAll] = useState(false);
|
||||||
|
|
||||||
|
const filterMode = GQL.FilterMode.Studios;
|
||||||
|
|
||||||
const otherOperations = [
|
const otherOperations = [
|
||||||
{
|
{
|
||||||
text: intl.formatMessage({ id: "actions.view_random" }),
|
text: intl.formatMessage({ id: "actions.view_random" }),
|
||||||
@@ -177,15 +176,23 @@ export const StudioList: React.FC<IStudioList> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StudioItemList
|
<ItemListContext
|
||||||
selectable
|
filterMode={filterMode}
|
||||||
|
useResult={useFindStudios}
|
||||||
|
getItems={getItems}
|
||||||
|
getCount={getCount}
|
||||||
|
alterQuery={alterQuery}
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
view={view}
|
view={view}
|
||||||
alterQuery={alterQuery}
|
selectable
|
||||||
otherOperations={otherOperations}
|
>
|
||||||
addKeybinds={addKeybinds}
|
<ItemList
|
||||||
renderContent={renderContent}
|
view={view}
|
||||||
renderDeleteDialog={renderDeleteDialog}
|
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 React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
|
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
@@ -82,20 +82,22 @@ const TagTabs: React.FC<{
|
|||||||
abbreviateCounter: boolean;
|
abbreviateCounter: boolean;
|
||||||
showAllCounts?: boolean;
|
showAllCounts?: boolean;
|
||||||
}> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => {
|
}> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => {
|
||||||
|
const [showAllDetails, setShowAllDetails] = useState<boolean>(showAllCounts);
|
||||||
|
|
||||||
const sceneCount =
|
const sceneCount =
|
||||||
(showAllCounts ? tag.scene_count_all : tag.scene_count) ?? 0;
|
(showAllDetails ? tag.scene_count_all : tag.scene_count) ?? 0;
|
||||||
const imageCount =
|
const imageCount =
|
||||||
(showAllCounts ? tag.image_count_all : tag.image_count) ?? 0;
|
(showAllDetails ? tag.image_count_all : tag.image_count) ?? 0;
|
||||||
const galleryCount =
|
const galleryCount =
|
||||||
(showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0;
|
(showAllDetails ? tag.gallery_count_all : tag.gallery_count) ?? 0;
|
||||||
const groupCount =
|
const groupCount =
|
||||||
(showAllCounts ? tag.group_count_all : tag.group_count) ?? 0;
|
(showAllDetails ? tag.group_count_all : tag.group_count) ?? 0;
|
||||||
const sceneMarkerCount =
|
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 =
|
const performerCount =
|
||||||
(showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0;
|
(showAllDetails ? tag.performer_count_all : tag.performer_count) ?? 0;
|
||||||
const studioCount =
|
const studioCount =
|
||||||
(showAllCounts ? tag.studio_count_all : tag.studio_count) ?? 0;
|
(showAllDetails ? tag.studio_count_all : tag.studio_count) ?? 0;
|
||||||
|
|
||||||
const populatedDefaultTab = useMemo(() => {
|
const populatedDefaultTab = useMemo(() => {
|
||||||
let ret: TabKey = "scenes";
|
let ret: TabKey = "scenes";
|
||||||
@@ -133,6 +135,21 @@ const TagTabs: React.FC<{
|
|||||||
baseURL: `/tags/${tag.id}`,
|
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 (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
id="tag-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>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="images"
|
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>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="galleries"
|
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>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="groups"
|
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>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="markers"
|
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>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="performers"
|
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>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="studios"
|
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>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
|
|||||||
interface ITagGalleriesPanel {
|
interface ITagGalleriesPanel {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
tag: GQL.TagDataFragment;
|
tag: GQL.TagDataFragment;
|
||||||
|
showSubTagContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TagGalleriesPanel: React.FC<ITagGalleriesPanel> = ({
|
export const TagGalleriesPanel: React.FC<ITagGalleriesPanel> = ({
|
||||||
active,
|
active,
|
||||||
tag,
|
tag,
|
||||||
|
showSubTagContent,
|
||||||
}) => {
|
}) => {
|
||||||
const filterHook = useTagFilterHook(tag);
|
const filterHook = useTagFilterHook(tag, showSubTagContent);
|
||||||
return (
|
return (
|
||||||
<GalleryList
|
<GalleryList
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { GroupList } from "src/components/Groups/GroupList";
|
|||||||
export const TagGroupsPanel: React.FC<{
|
export const TagGroupsPanel: React.FC<{
|
||||||
active: boolean;
|
active: boolean;
|
||||||
tag: GQL.TagDataFragment;
|
tag: GQL.TagDataFragment;
|
||||||
}> = ({ active, tag }) => {
|
showSubTagContent?: boolean;
|
||||||
const filterHook = useTagFilterHook(tag);
|
}> = ({ active, tag, showSubTagContent }) => {
|
||||||
|
const filterHook = useTagFilterHook(tag, showSubTagContent);
|
||||||
return <GroupList filterHook={filterHook} alterQuery={active} />;
|
return <GroupList filterHook={filterHook} alterQuery={active} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ import { View } from "src/components/List/views";
|
|||||||
interface ITagImagesPanel {
|
interface ITagImagesPanel {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
tag: GQL.TagDataFragment;
|
tag: GQL.TagDataFragment;
|
||||||
|
showSubTagContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TagImagesPanel: React.FC<ITagImagesPanel> = ({ active, tag }) => {
|
export const TagImagesPanel: React.FC<ITagImagesPanel> = ({
|
||||||
const filterHook = useTagFilterHook(tag);
|
active,
|
||||||
|
tag,
|
||||||
|
showSubTagContent,
|
||||||
|
}) => {
|
||||||
|
const filterHook = useTagFilterHook(tag, showSubTagContent);
|
||||||
return (
|
return (
|
||||||
<ImageList
|
<ImageList
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
|
|||||||
@@ -8,16 +8,8 @@ import {
|
|||||||
import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList";
|
import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList";
|
||||||
import { View } from "src/components/List/views";
|
import { View } from "src/components/List/views";
|
||||||
|
|
||||||
interface ITagMarkersPanel {
|
function useFilterHook(tag: GQL.TagDataFragment, showSubTagContent?: boolean) {
|
||||||
active: boolean;
|
return (filter: ListFilterModel) => {
|
||||||
tag: GQL.TagDataFragment;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({
|
|
||||||
active,
|
|
||||||
tag,
|
|
||||||
}) => {
|
|
||||||
function filterHook(filter: ListFilterModel) {
|
|
||||||
const tagValue = { id: tag.id, label: tag.name };
|
const tagValue = { id: tag.id, label: tag.name };
|
||||||
// if tag is already present, then we modify it, otherwise add
|
// if tag is already present, then we modify it, otherwise add
|
||||||
let tagCriterion = filter.criteria.find((c) => {
|
let tagCriterion = filter.criteria.find((c) => {
|
||||||
@@ -45,13 +37,27 @@ export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({
|
|||||||
tagCriterion.value = {
|
tagCriterion.value = {
|
||||||
items: [tagValue],
|
items: [tagValue],
|
||||||
excluded: [],
|
excluded: [],
|
||||||
depth: 0,
|
depth: showSubTagContent ? -1 : 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(tagCriterion);
|
filter.criteria.push(tagCriterion);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filter;
|
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 (
|
return (
|
||||||
<SceneMarkerList
|
<SceneMarkerList
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { View } from "src/components/List/views";
|
|||||||
interface ITagPerformersPanel {
|
interface ITagPerformersPanel {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
tag: GQL.TagDataFragment;
|
tag: GQL.TagDataFragment;
|
||||||
|
showSubTagContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TagPerformersPanel: React.FC<ITagPerformersPanel> = ({
|
export const TagPerformersPanel: React.FC<ITagPerformersPanel> = ({
|
||||||
active,
|
active,
|
||||||
tag,
|
tag,
|
||||||
|
showSubTagContent,
|
||||||
}) => {
|
}) => {
|
||||||
const filterHook = useTagFilterHook(tag);
|
const filterHook = useTagFilterHook(tag, showSubTagContent);
|
||||||
return (
|
return (
|
||||||
<PerformerList
|
<PerformerList
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ import { View } from "src/components/List/views";
|
|||||||
interface ITagScenesPanel {
|
interface ITagScenesPanel {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
tag: GQL.TagDataFragment;
|
tag: GQL.TagDataFragment;
|
||||||
|
showSubTagContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TagScenesPanel: React.FC<ITagScenesPanel> = ({ active, tag }) => {
|
export const TagScenesPanel: React.FC<ITagScenesPanel> = ({
|
||||||
const filterHook = useTagFilterHook(tag);
|
active,
|
||||||
|
tag,
|
||||||
|
showSubTagContent,
|
||||||
|
}) => {
|
||||||
|
const filterHook = useTagFilterHook(tag, showSubTagContent);
|
||||||
return (
|
return (
|
||||||
<SceneList
|
<SceneList
|
||||||
filterHook={filterHook}
|
filterHook={filterHook}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { StudioList } from "src/components/Studios/StudioList";
|
|||||||
interface ITagStudiosPanel {
|
interface ITagStudiosPanel {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
tag: GQL.TagDataFragment;
|
tag: GQL.TagDataFragment;
|
||||||
|
showSubTagContent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TagStudiosPanel: React.FC<ITagStudiosPanel> = ({
|
export const TagStudiosPanel: React.FC<ITagStudiosPanel> = ({
|
||||||
active,
|
active,
|
||||||
tag,
|
tag,
|
||||||
|
showSubTagContent,
|
||||||
}) => {
|
}) => {
|
||||||
const filterHook = useTagFilterHook(tag);
|
const filterHook = useTagFilterHook(tag, showSubTagContent);
|
||||||
return <StudioList filterHook={filterHook} alterQuery={active} />;
|
return <StudioList filterHook={filterHook} alterQuery={active} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import cloneDeep from "lodash-es/cloneDeep";
|
|||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
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 { Button } from "react-bootstrap";
|
||||||
import { Link, useHistory } from "react-router-dom";
|
import { Link, useHistory } from "react-router-dom";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
@@ -27,27 +27,27 @@ import { TagCardGrid } from "./TagCardGrid";
|
|||||||
import { EditTagsDialog } from "./EditTagsDialog";
|
import { EditTagsDialog } from "./EditTagsDialog";
|
||||||
import { View } from "../List/views";
|
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 {
|
interface ITagList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
alterQuery?: boolean;
|
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 }) => {
|
export const TagList: React.FC<ITagList> = ({ filterHook, alterQuery }) => {
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
const [deletingTag, setDeletingTag] =
|
const [deletingTag, setDeletingTag] =
|
||||||
useState<Partial<GQL.TagDataFragment> | null>(null);
|
useState<Partial<GQL.TagDataFragment> | null>(null);
|
||||||
|
|
||||||
|
const filterMode = GQL.FilterMode.Tags;
|
||||||
|
const view = View.Tags;
|
||||||
|
|
||||||
function getDeleteTagInput() {
|
function getDeleteTagInput() {
|
||||||
const tagInput: Partial<GQL.TagDestroyInput> = {};
|
const tagInput: Partial<GQL.TagDestroyInput> = {};
|
||||||
if (deletingTag) {
|
if (deletingTag) {
|
||||||
@@ -355,18 +355,25 @@ export const TagList: React.FC<ITagList> = ({ filterHook, alterQuery }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TagItemList
|
<ItemListContext
|
||||||
selectable
|
filterMode={filterMode}
|
||||||
zoomable
|
useResult={useFindTags}
|
||||||
defaultZoomIndex={0}
|
getItems={getItems}
|
||||||
filterHook={filterHook}
|
getCount={getCount}
|
||||||
view={View.Tags}
|
|
||||||
alterQuery={alterQuery}
|
alterQuery={alterQuery}
|
||||||
otherOperations={otherOperations}
|
filterHook={filterHook}
|
||||||
addKeybinds={addKeybinds}
|
view={view}
|
||||||
renderContent={renderContent}
|
selectable
|
||||||
renderDeleteDialog={renderDeleteDialog}
|
>
|
||||||
renderEditDialog={renderEditDialog}
|
<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 * as GQL from "src/core/generated-graphql";
|
||||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import React from "react";
|
|
||||||
import { ConfigurationContext } from "src/hooks/Config";
|
|
||||||
|
|
||||||
export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => {
|
export const useStudioFilterHook = (
|
||||||
const { configuration } = React.useContext(ConfigurationContext);
|
studio: GQL.StudioDataFragment,
|
||||||
|
showChildStudioContent?: boolean
|
||||||
|
) => {
|
||||||
return (filter: ListFilterModel) => {
|
return (filter: ListFilterModel) => {
|
||||||
const studioValue = { id: studio.id, label: studio.name };
|
const studioValue = { id: studio.id, label: studio.name };
|
||||||
// if studio is already present, then we modify it, otherwise add
|
// if studio is already present, then we modify it, otherwise add
|
||||||
@@ -22,7 +22,7 @@ export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => {
|
|||||||
studioCriterion.value = {
|
studioCriterion.value = {
|
||||||
items: [studioValue],
|
items: [studioValue],
|
||||||
excluded: [],
|
excluded: [],
|
||||||
depth: configuration?.ui.showChildStudioContent ? -1 : 0,
|
depth: showChildStudioContent ? -1 : 0,
|
||||||
};
|
};
|
||||||
studioCriterion.modifier = GQL.CriterionModifier.Includes;
|
studioCriterion.modifier = GQL.CriterionModifier.Includes;
|
||||||
filter.criteria.push(studioCriterion);
|
filter.criteria.push(studioCriterion);
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
TagsCriterionOption,
|
TagsCriterionOption,
|
||||||
} from "src/models/list-filter/criteria/tags";
|
} from "src/models/list-filter/criteria/tags";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import React from "react";
|
|
||||||
import { ConfigurationContext } from "src/hooks/Config";
|
|
||||||
|
|
||||||
export const useTagFilterHook = (tag: GQL.TagDataFragment) => {
|
export const useTagFilterHook = (
|
||||||
const { configuration } = React.useContext(ConfigurationContext);
|
tag: GQL.TagDataFragment,
|
||||||
|
showSubTagContent?: boolean
|
||||||
|
) => {
|
||||||
return (filter: ListFilterModel) => {
|
return (filter: ListFilterModel) => {
|
||||||
const tagValue = { id: tag.id, label: tag.name };
|
const tagValue = { id: tag.id, label: tag.name };
|
||||||
// if tag is already present, then we modify it, otherwise add
|
// if tag is already present, then we modify it, otherwise add
|
||||||
@@ -42,7 +42,7 @@ export const useTagFilterHook = (tag: GQL.TagDataFragment) => {
|
|||||||
tagCriterion.value = {
|
tagCriterion.value = {
|
||||||
items: [tagValue],
|
items: [tagValue],
|
||||||
excluded: [],
|
excluded: [],
|
||||||
depth: configuration?.ui.showChildTagContent ? -1 : 0,
|
depth: showSubTagContent ? -1 : 0,
|
||||||
};
|
};
|
||||||
tagCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
tagCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||||
filter.criteria.push(tagCriterion);
|
filter.criteria.push(tagCriterion);
|
||||||
|
|||||||
10
ui/v2.5/src/hooks/modal.ts
Normal file
10
ui/v2.5/src/hooks/modal.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function useModal() {
|
||||||
|
const [modal, setModal] = React.useState<React.ReactNode>();
|
||||||
|
|
||||||
|
const closeModal = () => setModal(undefined);
|
||||||
|
const showModal = (m: React.ReactNode) => setModal(m);
|
||||||
|
|
||||||
|
return { modal, closeModal, showModal };
|
||||||
|
}
|
||||||
@@ -258,6 +258,15 @@ dd {
|
|||||||
padding: 5px 0;
|
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 {
|
.item-list-container {
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
|
|
||||||
|
|||||||
@@ -1086,7 +1086,9 @@
|
|||||||
"image_index": "Image #",
|
"image_index": "Image #",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"include_parent_tags": "Include parent tags",
|
"include_parent_tags": "Include parent tags",
|
||||||
|
"include_sub_studio_content": "Include sub-studio content",
|
||||||
"include_sub_studios": "Include subsidiary studios",
|
"include_sub_studios": "Include subsidiary studios",
|
||||||
|
"include_sub_tag_content": "Include sub-tag content",
|
||||||
"include_sub_tags": "Include sub-tags",
|
"include_sub_tags": "Include sub-tags",
|
||||||
"index_of_total": "{index} of {total}",
|
"index_of_total": "{index} of {total}",
|
||||||
"instagram": "Instagram",
|
"instagram": "Instagram",
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ export abstract class Criterion<V extends CriterionValue> {
|
|||||||
this.value = value;
|
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) {
|
public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) {
|
||||||
const modifierMessageID = modifierMessageIDs[modifier];
|
const modifierMessageID = modifierMessageIDs[modifier];
|
||||||
|
|
||||||
@@ -251,6 +260,19 @@ export class ILabeledIdCriterionOption extends CriterionOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
|
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 {
|
protected getLabelValue(_intl: IntlShape): string {
|
||||||
return this.value.map((v) => v.label).join(", ");
|
return this.value.map((v) => v.label).join(", ");
|
||||||
}
|
}
|
||||||
@@ -272,23 +294,33 @@ export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
|
|||||||
|
|
||||||
return this.value.length > 0;
|
return this.value.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(type: CriterionOption) {
|
|
||||||
super(type, []);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> {
|
export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> {
|
||||||
constructor(type: CriterionOption) {
|
constructor(
|
||||||
const value: IHierarchicalLabelValue = {
|
type: CriterionOption,
|
||||||
|
value: IHierarchicalLabelValue = {
|
||||||
items: [],
|
items: [],
|
||||||
excluded: [],
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
}
|
||||||
|
) {
|
||||||
super(type, value);
|
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 {
|
override get modifier(): CriterionModifier {
|
||||||
return this._modifier;
|
return this._modifier;
|
||||||
}
|
}
|
||||||
@@ -501,8 +533,17 @@ export class StringCriterion extends Criterion<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MultiStringCriterion extends Criterion<string[]> {
|
export class MultiStringCriterion extends Criterion<string[]> {
|
||||||
constructor(type: CriterionOption) {
|
constructor(type: CriterionOption, value: string[] = []) {
|
||||||
super(type, []);
|
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) {
|
protected getLabelValue(_intl: IntlShape) {
|
||||||
|
|||||||
@@ -67,26 +67,48 @@ export class ListFilterModel {
|
|||||||
public constructor(
|
public constructor(
|
||||||
mode: FilterMode,
|
mode: FilterMode,
|
||||||
config?: ConfigDataFragment,
|
config?: ConfigDataFragment,
|
||||||
defaultZoomIndex?: number
|
options?: {
|
||||||
|
defaultZoomIndex?: number;
|
||||||
|
defaultSortBy?: string;
|
||||||
|
defaultSortDir?: SortDirectionEnum;
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.options = getFilterOptions(mode);
|
this.options = getFilterOptions(mode);
|
||||||
const { defaultSortBy, displayModeOptions } = this.options;
|
const { defaultSortBy, displayModeOptions } = this.options;
|
||||||
|
|
||||||
this.sortBy = defaultSortBy;
|
if (options?.defaultSortBy) {
|
||||||
if (this.sortBy === "date") {
|
this.sortBy = options.defaultSortBy;
|
||||||
this.sortDirection = SortDirectionEnum.Desc;
|
if (options.defaultSortDir) {
|
||||||
|
this.sortDirection = options.defaultSortDir;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.sortBy = defaultSortBy;
|
||||||
|
if (this.sortBy === "date") {
|
||||||
|
this.sortDirection = SortDirectionEnum.Desc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.displayMode = displayModeOptions[0];
|
this.displayMode = displayModeOptions[0];
|
||||||
if (defaultZoomIndex !== undefined) {
|
if (options?.defaultZoomIndex !== undefined) {
|
||||||
this.defaultZoomIndex = defaultZoomIndex;
|
this.defaultZoomIndex = options.defaultZoomIndex;
|
||||||
this.zoomIndex = defaultZoomIndex;
|
this.zoomIndex = options.defaultZoomIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public clone() {
|
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
|
// returns the number of filters applied
|
||||||
@@ -443,4 +465,44 @@ export class ListFilterModel {
|
|||||||
zoom_index: this.zoomIndex,
|
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 * as GQL from "src/core/generated-graphql";
|
||||||
import isEqual from "lodash-es/isEqual";
|
import isEqual from "lodash-es/isEqual";
|
||||||
|
import { IHasID } from "./data";
|
||||||
|
|
||||||
interface IHasRating {
|
interface IHasRating {
|
||||||
rating100?: GQL.Maybe<number> | undefined;
|
rating100?: GQL.Maybe<number> | undefined;
|
||||||
@@ -21,10 +22,6 @@ export function getAggregateRating(state: IHasRating[]) {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IHasID {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IHasStudio {
|
interface IHasStudio {
|
||||||
studio?: GQL.Maybe<IHasID> | undefined;
|
studio?: GQL.Maybe<IHasID> | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
export const filterData = <T>(data?: (T | null | undefined)[] | null) =>
|
export const filterData = <T>(data?: (T | null | undefined)[] | null) =>
|
||||||
data ? (data.filter((item) => item) as T[]) : [];
|
data ? (data.filter((item) => item) as T[]) : [];
|
||||||
|
|
||||||
|
export interface IHasID {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ITypename {
|
export interface ITypename {
|
||||||
__typename?: string;
|
__typename?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user