From 6a5dc4e7744846d82498e0958f8a45bf51e0ac9f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 Jul 2024 16:35:37 +1000 Subject: [PATCH] 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. --- .../src/components/Galleries/GalleryList.tsx | 49 +- ui/v2.5/src/components/Groups/GroupList.tsx | 47 +- ui/v2.5/src/components/Images/ImageList.tsx | 120 +-- .../src/components/List/FilterProvider.tsx | 76 ++ .../components/List/FilteredListToolbar.tsx | 87 ++ ui/v2.5/src/components/List/ItemList.tsx | 839 ++++++------------ ui/v2.5/src/components/List/ListFilter.tsx | 5 +- .../components/List/ListOperationButtons.tsx | 2 +- ui/v2.5/src/components/List/ListProvider.tsx | 156 ++++ ui/v2.5/src/components/List/PagedList.tsx | 98 ++ ui/v2.5/src/components/List/util.ts | 321 ++++++- .../components/Performers/PerformerList.tsx | 47 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 117 +-- .../src/components/Scenes/SceneMarkerList.tsx | 41 +- .../src/components/Shared/FilterSelect.tsx | 5 +- ui/v2.5/src/components/Shared/styles.scss | 22 +- .../Studios/StudioDetails/Studio.tsx | 59 +- .../StudioDetails/StudioChildrenPanel.tsx | 26 +- .../StudioDetails/StudioGalleriesPanel.tsx | 4 +- .../StudioDetails/StudioGroupsPanel.tsx | 4 +- .../StudioDetails/StudioImagesPanel.tsx | 4 +- .../StudioDetails/StudioPerformersPanel.tsx | 4 +- .../StudioDetails/StudioScenesPanel.tsx | 4 +- ui/v2.5/src/components/Studios/StudioList.tsx | 45 +- .../src/components/Tags/TagDetails/Tag.tsx | 82 +- .../Tags/TagDetails/TagGalleriesPanel.tsx | 4 +- .../Tags/TagDetails/TagGroupsPanel.tsx | 5 +- .../Tags/TagDetails/TagImagesPanel.tsx | 9 +- .../Tags/TagDetails/TagMarkersPanel.tsx | 30 +- .../Tags/TagDetails/TagPerformersPanel.tsx | 4 +- .../Tags/TagDetails/TagScenesPanel.tsx | 9 +- .../Tags/TagDetails/TagStudiosPanel.tsx | 4 +- ui/v2.5/src/components/Tags/TagList.tsx | 55 +- ui/v2.5/src/core/studios.ts | 10 +- ui/v2.5/src/core/tags.ts | 10 +- ui/v2.5/src/hooks/modal.ts | 10 + ui/v2.5/src/index.scss | 9 + ui/v2.5/src/locales/en-GB.json | 2 + .../models/list-filter/criteria/criterion.ts | 61 +- ui/v2.5/src/models/list-filter/filter.ts | 78 +- ui/v2.5/src/utils/bulkUpdate.ts | 5 +- ui/v2.5/src/utils/data.ts | 4 + 42 files changed, 1644 insertions(+), 929 deletions(-) create mode 100644 ui/v2.5/src/components/List/FilterProvider.tsx create mode 100644 ui/v2.5/src/components/List/FilteredListToolbar.tsx create mode 100644 ui/v2.5/src/components/List/ListProvider.tsx create mode 100644 ui/v2.5/src/components/List/PagedList.tsx create mode 100644 ui/v2.5/src/hooks/modal.ts diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index ba6334e33..7becbe93a 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -4,7 +4,7 @@ import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; -import { makeItemList, showWhenSelected } from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { queryFindGalleries, useFindGalleries } from "src/core/StashService"; @@ -16,16 +16,13 @@ import { GalleryListTable } from "./GalleryListTable"; import { GalleryCardGrid } from "./GalleryGridCard"; import { View } from "../List/views"; -const GalleryItemList = makeItemList({ - filterMode: GQL.FilterMode.Galleries, - useResult: useFindGalleries, - getItems(result: GQL.FindGalleriesQueryResult) { - return result?.data?.findGalleries?.galleries ?? []; - }, - getCount(result: GQL.FindGalleriesQueryResult) { - return result?.data?.findGalleries?.count ?? 0; - }, -}); +function getItems(result: GQL.FindGalleriesQueryResult) { + return result?.data?.findGalleries?.galleries ?? []; +} + +function getCount(result: GQL.FindGalleriesQueryResult) { + return result?.data?.findGalleries?.count ?? 0; +} interface IGalleryList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -43,6 +40,8 @@ export const GalleryList: React.FC = ({ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const filterMode = GQL.FilterMode.Galleries; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.view_random" }), @@ -185,17 +184,25 @@ export const GalleryList: React.FC = ({ } return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/Groups/GroupList.tsx b/ui/v2.5/src/components/Groups/GroupList.tsx index 727587496..ba4591276 100644 --- a/ui/v2.5/src/components/Groups/GroupList.tsx +++ b/ui/v2.5/src/components/Groups/GroupList.tsx @@ -11,23 +11,20 @@ import { useFindGroups, useGroupsDestroy, } from "src/core/StashService"; -import { makeItemList, showWhenSelected } from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { GroupCardGrid } from "./GroupCardGrid"; import { EditGroupsDialog } from "./EditGroupsDialog"; import { View } from "../List/views"; -const GroupItemList = makeItemList({ - filterMode: GQL.FilterMode.Groups, - useResult: useFindGroups, - getItems(result: GQL.FindGroupsQueryResult) { - return result?.data?.findGroups?.groups ?? []; - }, - getCount(result: GQL.FindGroupsQueryResult) { - return result?.data?.findGroups?.count ?? 0; - }, -}); +function getItems(result: GQL.FindGroupsQueryResult) { + return result?.data?.findGroups?.groups ?? []; +} + +function getCount(result: GQL.FindGroupsQueryResult) { + return result?.data?.findGroups?.count ?? 0; +} interface IGroupList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -45,6 +42,8 @@ export const GroupList: React.FC = ({ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const filterMode = GQL.FilterMode.Groups; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.view_random" }), @@ -174,16 +173,24 @@ export const GroupList: React.FC = ({ } return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 3000195d9..5bcea0d4a 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -11,11 +11,7 @@ import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindImages, useFindImages } from "src/core/StashService"; -import { - makeItemList, - IItemListOperation, - showWhenSelected, -} from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; @@ -31,6 +27,7 @@ import TextUtils from "src/utils/text"; import { ConfigurationContext } from "src/hooks/Config"; import { ImageGridCard } from "./ImageGridCard"; import { View } from "../List/views"; +import { IItemListOperation } from "../List/FilteredListToolbar"; interface IImageWallProps { images: GQL.SlimImageDataFragment[]; @@ -222,51 +219,49 @@ const ImageListImages: React.FC = ({ return <>; }; -const ImageItemList = makeItemList({ - filterMode: GQL.FilterMode.Images, - useResult: useFindImages, - getItems(result: GQL.FindImagesQueryResult) { - return result?.data?.findImages?.images ?? []; - }, - getCount(result: GQL.FindImagesQueryResult) { - return result?.data?.findImages?.count ?? 0; - }, - renderMetadataByline(result: GQL.FindImagesQueryResult) { - const megapixels = result?.data?.findImages?.megapixels; - const size = result?.data?.findImages?.filesize; - const filesize = size ? TextUtils.fileSize(size) : undefined; +function getItems(result: GQL.FindImagesQueryResult) { + return result?.data?.findImages?.images ?? []; +} - if (!megapixels && !size) { - return; - } +function getCount(result: GQL.FindImagesQueryResult) { + return result?.data?.findImages?.count ?? 0; +} - const separator = megapixels && size ? " - " : ""; +function renderMetadataByline(result: GQL.FindImagesQueryResult) { + const megapixels = result?.data?.findImages?.megapixels; + const size = result?.data?.findImages?.filesize; + const filesize = size ? TextUtils.fileSize(size) : undefined; - return ( - -  ( - {megapixels ? ( - - Megapixels - - ) : undefined} - {separator} - {size && filesize ? ( - - - {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} - - ) : undefined} - ) - - ); - }, -}); + if (!megapixels && !size) { + return; + } + + const separator = megapixels && size ? " - " : ""; + + return ( + +  ( + {megapixels ? ( + + Megapixels + + ) : undefined} + {separator} + {size && filesize ? ( + + + {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} + + ) : undefined} + ) + + ); +} interface IImageList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -289,6 +284,8 @@ export const ImageList: React.FC = ({ const [isExportAll, setIsExportAll] = useState(false); const [slideshowRunning, setSlideshowRunning] = useState(false); + const filterMode = GQL.FilterMode.Images; + const otherOperations = [ ...(extraOperations ?? []), { @@ -415,17 +412,26 @@ export const ImageList: React.FC = ({ } return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/List/FilterProvider.tsx b/ui/v2.5/src/components/List/FilterProvider.tsx new file mode 100644 index 000000000..9f0abc4e0 --- /dev/null +++ b/ui/v2.5/src/components/List/FilterProvider.tsx @@ -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>; +} + +export interface IFilterContextState { + filter: ListFilterModel; + setFilter: React.Dispatch>; +} + +export const FilterStateContext = + React.createContext(null); + +export const FilterContext = ( + props: IFilterContextOptions & { + children?: + | ((props: IFilterContextState) => React.ReactNode) + | React.ReactNode; + } +) => { + const { filter, setFilter, children } = props; + + const state = { + filter, + setFilter, + }; + + return ( + + {isFunction(children) + ? (children as (props: IFilterContextState) => React.ReactNode)(state) + : children} + + ); +}; + +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 ( + + {children} + + ); +}; diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx new file mode 100644 index 000000000..d6887c51d --- /dev/null +++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx @@ -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 { + text: string; + onClick: ( + result: T, + filter: ListFilterModel, + selectedIds: Set + ) => Promise; + isDisplayed?: ( + result: T, + filter: ListFilterModel, + selectedIds: Set + ) => 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 ( + + showEditFilter()} + view={view} + /> + 0} + onEdit={onEdit} + onDelete={onDelete} + /> + + + ); +}; diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 5ffe97d4e..5be226c33 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -1,16 +1,10 @@ import React, { + PropsWithChildren, useCallback, - useContext, useEffect, - useLayoutEffect, useMemo, - useRef, useState, } from "react"; -import clone from "lodash-es/clone"; -import cloneDeep from "lodash-es/cloneDeep"; -import isEqual from "lodash-es/isEqual"; -import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { QueryResult } from "@apollo/client"; import { @@ -18,69 +12,30 @@ import { CriterionValue, } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; -import { useHistory, useLocation } from "react-router-dom"; -import { ConfigurationContext } from "src/hooks/Config"; -import { getFilterOptions } from "src/models/list-filter/factory"; -import { Pagination, PaginationIndex } from "./Pagination"; import { EditFilterDialog } from "src/components/List/EditFilterDialog"; -import { ListFilter } from "./ListFilter"; import { FilterTags } from "./FilterTags"; -import { ListViewOptions } from "./ListViewOptions"; -import { ListOperationButtons } from "./ListOperationButtons"; -import { LoadingIndicator } from "../Shared/LoadingIndicator"; -import { DisplayMode } from "src/models/list-filter/types"; -import { ButtonToolbar } from "react-bootstrap"; import { View } from "./views"; -import { useDefaultFilter } from "./util"; +import { IHasID } from "src/utils/data"; +import { + ListContext, + QueryResultContext, + useListContext, + useQueryResultContext, +} from "./ListProvider"; +import { FilterContext, SetFilterURL, useFilter } from "./FilterProvider"; +import { useModal } from "src/hooks/modal"; +import { + useDefaultFilter, + useEnsureValidPage, + useListKeyboardShortcuts, + useScrollToTopOnPageChange, +} from "./util"; +import { FilteredListToolbar, IItemListOperation } from "./FilteredListToolbar"; +import { PagedList } from "./PagedList"; -interface IDataItem { - id: string; -} - -export interface IItemListOperation { - text: string; - onClick: ( - result: T, - filter: ListFilterModel, - selectedIds: Set - ) => Promise; - isDisplayed?: ( - result: T, - filter: ListFilterModel, - selectedIds: Set - ) => boolean; - postRefetch?: boolean; - icon?: IconDefinition; - buttonVariant?: string; -} - -interface IItemListOptions { - 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 { +interface IItemListProps { view?: View; - defaultSort?: string; - filterHook?: (filter: ListFilterModel) => ListFilterModel; - filterDialog?: ( - criteria: Criterion[], - setCriteria: (v: Criterion[]) => void - ) => React.ReactNode; zoomable?: boolean; - selectable?: boolean; - alterQuery?: boolean; - defaultZoomIndex?: number; otherOperations?: IItemListOperation[]; renderContent: ( result: T, @@ -90,6 +45,7 @@ interface IItemListProps { onChangePage: (page: number) => void, pageCount: number ) => React.ReactNode; + renderMetadataByline?: (data: T) => React.ReactNode; renderEditDialog?: ( selected: E[], onClose: (applied: boolean) => void @@ -105,570 +61,273 @@ interface IItemListProps { ) => () => void; } -const getSelectedData = ( - data: I[], - selectedIds: Set -) => data.filter((value) => selectedIds.has(value.id)); - -/** - * A factory function for ItemList components. - * IMPORTANT: as the component manipulates the URL query string, if there are - * ever multiple ItemLists rendered at once, all but one of them need to have - * `alterQuery` set to false to prevent conflicts. - */ -export function makeItemList({ - filterMode, - useResult, - getCount, - renderMetadataByline, - getItems, -}: IItemListOptions) { - const filterOptions = getFilterOptions(filterMode); - - const RenderList: React.FC & IRenderListProps> = ({ - filter, - filterHook, - onChangePage: _onChangePage, - updateFilter, +export const ItemList = ( + props: IItemListProps +) => { + const { view, zoomable, - selectable, otherOperations, renderContent, renderEditDialog, renderDeleteDialog, + renderMetadataByline, addKeybinds, - }) => { - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [lastClickedId, setLastClickedId] = useState(); + } = props; - const [editingCriterion, setEditingCriterion] = useState(); - const [showEditFilter, setShowEditFilter] = useState(false); + const { filter, setFilter: updateFilter } = useFilter(); + const { effectiveFilter, result, cachedResult, totalCount } = + useQueryResultContext(); + const { + selectedIds, + getSelected, + onSelectChange, + onSelectAll, + onSelectNone, + } = useListContext(); - const effectiveFilter = useMemo(() => { - if (filterHook) { - return filterHook(cloneDeep(filter)); + const { modal, showModal, closeModal } = useModal(); + + const metadataByline = useMemo(() => { + if (cachedResult.loading) return ""; + + return renderMetadataByline?.(cachedResult) ?? ""; + }, [renderMetadataByline, cachedResult]); + + const pages = Math.ceil(totalCount / filter.itemsPerPage); + + const onChangePage = useCallback( + (p: number) => { + updateFilter(filter.changePage(p)); + }, + [filter, updateFilter] + ); + + useEnsureValidPage(filter, totalCount, updateFilter); + + const showEditFilter = useCallback( + (editingCriterion?: string) => { + function onApplyEditFilter(f: ListFilterModel) { + closeModal(); + updateFilter(f); } - return filter; - }, [filter, filterHook]); - const result = useResult(effectiveFilter); - const [totalCount, setTotalCount] = useState(0); - const [metadataByline, setMetadataByline] = useState(); - const items = useMemo(() => getItems(result), [result]); + showModal( + closeModal()} + editingCriterion={editingCriterion} + /> + ); + }, + [filter, updateFilter, showModal, closeModal] + ); - const [arePaging, setArePaging] = useState(false); - const hidePagination = !arePaging && result.loading; - - // useLayoutEffect to set total count before paint, avoiding a 0 being displayed - useLayoutEffect(() => { - if (result.loading) return; - setArePaging(false); - - setTotalCount(getCount(result)); - setMetadataByline(renderMetadataByline?.(result)); - }, [result]); - - const onChangePage = useCallback( - (page: number) => { - setArePaging(true); - _onChangePage(page); - }, - [_onChangePage] - ); - - // handle case where page is more than there are pages - useEffect(() => { - const pages = Math.ceil(totalCount / filter.itemsPerPage); - if (pages > 0 && filter.currentPage > pages) { - onChangePage(pages); - } - }, [filter, onChangePage, totalCount]); - - // set up hotkeys - useEffect(() => { - Mousetrap.bind("f", (e) => { - setShowEditFilter(true); - // prevent default behavior of typing f in a text field - // otherwise the filter dialog closes, the query field is focused and - // f is typed. - e.preventDefault(); - }); + useListKeyboardShortcuts({ + currentPage: filter.currentPage, + onChangePage, + onSelectAll, + onSelectNone, + pages, + showEditFilter, + }); + useEffect(() => { + if (addKeybinds) { + const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds); return () => { - Mousetrap.unbind("f"); + unbindExtras(); }; - }, []); - useEffect(() => { - const pages = Math.ceil(totalCount / filter.itemsPerPage); - Mousetrap.bind("right", () => { - if (filter.currentPage < pages) { - onChangePage(filter.currentPage + 1); - } - }); - Mousetrap.bind("left", () => { - if (filter.currentPage > 1) { - onChangePage(filter.currentPage - 1); - } - }); - Mousetrap.bind("shift+right", () => { - onChangePage(Math.min(pages, filter.currentPage + 10)); - }); - Mousetrap.bind("shift+left", () => { - onChangePage(Math.max(1, filter.currentPage - 10)); - }); - Mousetrap.bind("ctrl+end", () => { - onChangePage(pages); - }); - Mousetrap.bind("ctrl+home", () => { - onChangePage(1); - }); - - return () => { - Mousetrap.unbind("right"); - Mousetrap.unbind("left"); - Mousetrap.unbind("shift+right"); - Mousetrap.unbind("shift+left"); - Mousetrap.unbind("ctrl+end"); - Mousetrap.unbind("ctrl+home"); - }; - }, [filter, onChangePage, totalCount]); - useEffect(() => { - if (addKeybinds) { - const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds); - return () => { - unbindExtras(); - }; - } - }, [addKeybinds, result, effectiveFilter, selectedIds]); - - function singleSelect(id: string, selected: boolean) { - setLastClickedId(id); - - const newSelectedIds = clone(selectedIds); - if (selected) { - newSelectedIds.add(id); - } else { - newSelectedIds.delete(id); - } - - setSelectedIds(newSelectedIds); } + }, [addKeybinds, result, effectiveFilter, selectedIds]); - function selectRange(startIndex: number, endIndex: number) { - let start = startIndex; - let end = endIndex; - if (start > end) { - const tmp = start; - start = end; - end = tmp; - } - - const subset = items.slice(start, end + 1); - const newSelectedIds = new Set(); - - 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(); - items.forEach((item) => { - newSelectedIds.add(item.id); - }); - - setSelectedIds(newSelectedIds); - setLastClickedId(undefined); - } - - function onSelectNone() { - const newSelectedIds = new Set(); - setSelectedIds(newSelectedIds); - setLastClickedId(undefined); - } - - function onChangeZoom(newZoomIndex: number) { - const newFilter = cloneDeep(filter); - newFilter.zoomIndex = newZoomIndex; - updateFilter(newFilter); - } - - async function onOperationClicked(o: IItemListOperation) { - await o.onClick(result, effectiveFilter, selectedIds); - if (o.postRefetch) { - result.refetch(); - } - } - - const operations = otherOperations?.map((o) => ({ - text: o.text, - onClick: () => { - onOperationClicked(o); - }, - isDisplayed: () => { - if (o.isDisplayed) { - return o.isDisplayed(result, effectiveFilter, selectedIds); - } - - return true; - }, - icon: o.icon, - buttonVariant: o.buttonVariant, - })); - - function onEdit() { - setIsEditDialogOpen(true); - } - - function onEditDialogClosed(applied: boolean) { - if (applied) { - onSelectNone(); - } - setIsEditDialogOpen(false); - - // refetch + async function onOperationClicked(o: IItemListOperation) { + await o.onClick(result, effectiveFilter, selectedIds); + if (o.postRefetch) { result.refetch(); } + } - function onDelete() { - setIsDeleteDialogOpen(true); - } - - function onDeleteDialogClosed(deleted: boolean) { - if (deleted) { - onSelectNone(); - } - setIsDeleteDialogOpen(false); - - // refetch - result.refetch(); - } - - function renderPagination() { - if (hidePagination) return; - return ( - - ); - } - - function renderPaginationIndex() { - if (hidePagination) return; - return ( - - ); - } - - function maybeRenderContent() { - if (result.loading) { - return ; - } - if (result.error) { - return

{result.error.message}

; + const operations = otherOperations?.map((o) => ({ + text: o.text, + onClick: () => { + onOperationClicked(o); + }, + isDisplayed: () => { + if (o.isDisplayed) { + return o.isDisplayed(result, effectiveFilter, selectedIds); } - const pages = Math.ceil(totalCount / filter.itemsPerPage); - return ( - <> - {renderContent( - result, - // #4780 - use effectiveFilter to ensure filterHook is applied - effectiveFilter, - selectedIds, - onSelectChange, - onChangePage, - pages - )} - {!!pages && ( - <> - {renderPaginationIndex()} - {renderPagination()} - - )} - - ); + return true; + }, + icon: o.icon, + buttonVariant: o.buttonVariant, + })); + + function onEdit() { + if (!renderEditDialog) { + return; } - function onChangeDisplayMode(displayMode: DisplayMode) { - const newFilter = cloneDeep(filter); - newFilter.displayMode = displayMode; - updateFilter(newFilter); - } - - function onRemoveCriterion(removedCriterion: Criterion) { - 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 ( -
- - setShowEditFilter(true)} - view={view} - /> - 0} - onEdit={renderEditDialog ? onEdit : undefined} - onDelete={renderDeleteDialog ? onDelete : undefined} - /> - - - setEditingCriterion(c.criterionOption.type)} - onRemoveCriterion={onRemoveCriterion} - onRemoveAll={() => onClearAllCriteria()} - /> - {(showEditFilter || editingCriterion) && ( - - )} - {isEditDialogOpen && - renderEditDialog && - renderEditDialog(getSelectedData(items, selectedIds), (applied) => - onEditDialogClosed(applied) - )} - {isDeleteDialogOpen && - renderDeleteDialog && - renderDeleteDialog(getSelectedData(items, selectedIds), (deleted) => - onDeleteDialogClosed(deleted) - )} - {renderPagination()} - {renderPaginationIndex()} - {maybeRenderContent()} -
+ showModal( + renderEditDialog(getSelected(), (applied) => onEditDialogClosed(applied)) ); - }; + } - const ItemList: React.FC> = (props) => { - const { - view, - defaultSort = filterOptions.defaultSortBy, - defaultZoomIndex, - alterQuery = true, - } = props; + function onEditDialogClosed(applied: boolean) { + if (applied) { + onSelectNone(); + } + closeModal(); - const history = useHistory(); - const location = useLocation(); - const [filterInitialised, setFilterInitialised] = useState(false); - const { configuration: config } = useContext(ConfigurationContext); + // refetch + result.refetch(); + } - const lastPathname = useRef(location.pathname); - const defaultDisplayMode = filterOptions.displayModeOptions[0]; - const [filter, setFilter] = useState( - () => new ListFilterModel(filterMode) + function onDelete() { + if (!renderDeleteDialog) { + return; + } + + showModal( + renderDeleteDialog(getSelected(), (deleted) => + onDeleteDialogClosed(deleted) + ) ); + } - const { defaultFilter, loading: defaultFilterLoading } = useDefaultFilter( - filterMode, - view - ); + function onDeleteDialogClosed(deleted: boolean) { + if (deleted) { + onSelectNone(); + } + closeModal(); - const updateQueryParams = useCallback( - (newFilter: ListFilterModel) => { - if (!alterQuery) return; + // refetch + result.refetch(); + } - const newParams = newFilter.makeQueryParameters(); - history.replace({ ...history.location, search: newParams }); - }, - [alterQuery, history] - ); + function onRemoveCriterion(removedCriterion: Criterion) { + updateFilter(filter.removeCriterion(removedCriterion.criterionOption.type)); + } - const updateFilter = useCallback( - (newFilter: ListFilterModel) => { - setFilter(newFilter); - updateQueryParams(newFilter); - }, - [updateQueryParams] - ); + function onClearAllCriteria() { + updateFilter(filter.clearCriteria()); + } - // 'Startup' hook, initialises the filters - useEffect(() => { - // Only run once - if (filterInitialised) return; - - let newFilter = new ListFilterModel(filterMode, config, defaultZoomIndex); - let loadDefault = true; - if (alterQuery && location.search) { - loadDefault = false; - newFilter.configureFromQueryString(location.search); - } - - if (view) { - // only set default filter if uninitialised - if (loadDefault) { - // wait until default filter is loaded - if (defaultFilterLoading) return; - - if (defaultFilter) { - newFilter = defaultFilter.clone(); - - // #1507 - reset random seed when loaded - newFilter.randomSeed = -1; - } - } - } - - setFilter(newFilter); - updateQueryParams(newFilter); - - setFilterInitialised(true); - }, [ - filterInitialised, - location, - config, - defaultSort, - defaultDisplayMode, - defaultZoomIndex, - alterQuery, - view, - updateQueryParams, - defaultFilter, - defaultFilterLoading, - ]); - - // This hook runs on every page location change (ie navigation), - // and updates the filter accordingly. - useEffect(() => { - if (!filterInitialised || !alterQuery) return; - - // re-init if the pathname has changed - if (location.pathname !== lastPathname.current) { - lastPathname.current = location.pathname; - setFilterInitialised(false); - return; - } - - // re-init to load default filter on empty new query params - if (!location.search) { - setFilterInitialised(false); - return; - } - - // the query has changed, update filter if necessary - setFilter((prevFilter) => { - let newFilter = prevFilter.clone(); - newFilter.configureFromQueryString(location.search); - if (!isEqual(newFilter, prevFilter)) { - return newFilter; - } else { - return prevFilter; - } - }); - }, [filterInitialised, alterQuery, location]); - - const onChangePage = useCallback( - (page: number) => { - const newFilter = cloneDeep(filter); - newFilter.currentPage = page; - updateFilter(newFilter); - - // if the current page has a detail-header, then - // scroll up relative to that rather than 0, 0 - const detailHeader = document.querySelector(".detail-header"); - if (detailHeader) { - window.scrollTo(0, detailHeader.scrollHeight - 50); - } else { - window.scrollTo(0, 0); - } - }, - [filter, updateFilter] - ); - - if (!filterInitialised) return null; - - return ( - + - ); - }; + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={onRemoveCriterion} + onRemoveAll={() => onClearAllCriteria()} + /> + {modal} - return ItemList; + + {renderContent( + result, + // #4780 - use effectiveFilter to ensure filterHook is applied + effectiveFilter, + selectedIds, + onSelectChange, + onChangePage, + pages + )} + + + ); +}; + +interface IItemListContextProps { + 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 = ( + props: PropsWithChildren> +) => { + 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( + () => + 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 ( + + + + {({ items }) => ( + + {children} + + )} + + + + ); +}; + export const showWhenSelected = ( result: T, filter: ListFilterModel, diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index ccfd32409..23244cbf0 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -19,7 +19,6 @@ import { import { Icon } from "../Shared/Icon"; import { ListFilterModel } from "src/models/list-filter/filter"; import useFocus from "src/utils/focus"; -import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { FormattedMessage, useIntl } from "react-intl"; import { SavedFilterDropdown } from "./SavedFilterList"; import { @@ -36,7 +35,6 @@ import { View } from "./views"; interface IListFilterProps { onFilterUpdate: (newFilter: ListFilterModel) => void; filter: ListFilterModel; - filterOptions: ListFilterOptions; view?: View; openFilterDialog: () => void; } @@ -46,7 +44,6 @@ const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"]; export const ListFilter: React.FC = ({ onFilterUpdate, filter, - filterOptions, openFilterDialog, view, }) => { @@ -58,6 +55,8 @@ export const ListFilter: React.FC = ({ const perPageSelect = useRef(null); const [perPageInput, perPageFocus] = useFocus(); + const filterOptions = filter.options; + const searchQueryUpdated = useCallback( (value: string) => { const newFilter = cloneDeep(filter); diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index c279020e9..4373d9338 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -16,7 +16,7 @@ import { faTrash, } from "@fortawesome/free-solid-svg-icons"; -interface IListFilterOperation { +export interface IListFilterOperation { text: string; onClick: () => void; isDisplayed?: () => boolean; diff --git a/ui/v2.5/src/components/List/ListProvider.tsx b/ui/v2.5/src/components/List/ListProvider.tsx new file mode 100644 index 000000000..a3a41a93d --- /dev/null +++ b/ui/v2.5/src/components/List/ListProvider.tsx @@ -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 { + selectable?: boolean; + items: T[]; +} + +export type IListContextState = IListSelect & { + selectable: boolean; + items: T[]; +}; + +export const ListStateContext = React.createContext( + null +); + +export const ListContext = ( + props: IListContextOptions & { + children?: + | ((props: IListContextState) => React.ReactNode) + | React.ReactNode; + } +) => { + const { selectable = false, items, children } = props; + + const { + selectedIds, + getSelected, + onSelectChange, + onSelectAll, + onSelectNone, + } = useListSelect(items); + + const state: IListContextState = { + selectable, + selectedIds, + getSelected, + onSelectChange, + onSelectAll, + onSelectNone, + items, + }; + + return ( + + {isFunction(children) + ? (children as (props: IListContextState) => React.ReactNode)(state) + : children} + + ); +}; + +export function useListContext() { + const context = React.useContext(ListStateContext); + + if (context === null) { + throw new Error("useListContext must be used within a ListStateContext"); + } + + return context as IListContextState; +} + +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(null); + +export const QueryResultContext = < + T extends QueryResult, + E extends IHasID = IHasID +>( + props: IQueryResultContextOptions & { + children?: + | ((props: IQueryResultContextState) => 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 = { + effectiveFilter, + result, + cachedResult, + items, + totalCount, + }; + + return ( + + {isFunction(children) + ? (children as (props: IQueryResultContextState) => React.ReactNode)( + state + ) + : children} + + ); +}; + +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; +} diff --git a/ui/v2.5/src/components/List/PagedList.tsx b/ui/v2.5/src/components/List/PagedList.tsx new file mode 100644 index 000000000..bf34f4fdd --- /dev/null +++ b/ui/v2.5/src/components/List/PagedList.tsx @@ -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 ( + + ); + }, [ + filter.itemsPerPage, + filter.currentPage, + totalCount, + metadataByline, + onChangePage, + ]); + + const paginationIndex = useMemo(() => { + if (cachedResult.loading) return; + return ( + + ); + }, [ + cachedResult.loading, + filter.itemsPerPage, + filter.currentPage, + totalCount, + metadataByline, + ]); + + const content = useMemo(() => { + if (result.loading) { + return ; + } + if (result.error) { + return

{result.error.message}

; + } + + return ( + <> + {children} + {!!pages && ( + <> + {paginationIndex} + {pagination} + + )} + + ); + }, [ + result.loading, + result.error, + pages, + children, + pagination, + paginationIndex, + ]); + + return ( + <> + {pagination} + {paginationIndex} + {content} + + ); +}; diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 7ebe679b9..0cda1b4cb 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -1,11 +1,69 @@ -import { useContext, useMemo } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; -import * as GQL from "src/core/generated-graphql"; +import { useHistory, useLocation } from "react-router-dom"; +import { isEqual, isFunction } from "lodash-es"; +import { QueryResult } from "@apollo/client"; +import { IHasID } from "src/utils/data"; import { ConfigurationContext } from "src/hooks/Config"; import { View } from "./views"; -export function useDefaultFilter(mode: GQL.FilterMode, view?: View) { - const emptyFilter = useMemo(() => new ListFilterModel(mode), [mode]); +export function useFilterURL( + filter: ListFilterModel, + setFilter: React.Dispatch>, + options?: { + defaultFilter?: ListFilterModel; + setURL?: boolean; + } +) { + const { defaultFilter, setURL = true } = options ?? {}; + + const history = useHistory(); + const location = useLocation(); + + // when the filter changes, update the URL + const updateFilter = useCallback( + ( + value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel) + ) => { + const newFilter = isFunction(value) ? value(filter) : value; + + if (setURL) { + const newParams = newFilter.makeQueryParameters(); + history.replace({ ...history.location, search: newParams }); + } else { + // set the filter without updating the URL + setFilter(newFilter); + } + }, + [history, setURL, setFilter, filter] + ); + + // This hook runs on every page location change (ie navigation), + // and updates the filter accordingly. + useEffect(() => { + // re-init to load default filter on empty new query params + if (!location.search) { + if (defaultFilter) updateFilter(defaultFilter.clone()); + return; + } + + // the query has changed, update filter if necessary + setFilter((prevFilter) => { + let newFilter = prevFilter.empty(); + newFilter.configureFromQueryString(location.search); + if (!isEqual(newFilter, prevFilter)) { + return newFilter; + } else { + return prevFilter; + } + }); + }, [location.search, defaultFilter, setFilter, updateFilter]); + + return { setFilter: updateFilter }; +} + +export function useDefaultFilter(emptyFilter: ListFilterModel, view?: View) { const { configuration: config, loading } = useContext(ConfigurationContext); const defaultFilter = useMemo(() => { @@ -30,3 +88,258 @@ export function useDefaultFilter(mode: GQL.FilterMode, view?: View) { return { defaultFilter: retFilter, loading }; } + +export function useListKeyboardShortcuts(props: { + currentPage?: number; + onChangePage?: (page: number) => void; + showEditFilter?: () => void; + pages?: number; + onSelectAll?: () => void; + onSelectNone?: () => void; +}) { + const { + currentPage, + onChangePage, + showEditFilter, + pages = 0, + onSelectAll, + onSelectNone, + } = props; + + // set up hotkeys + useEffect(() => { + if (showEditFilter) { + Mousetrap.bind("f", (e) => { + showEditFilter(); + // prevent default behavior of typing f in a text field + // otherwise the filter dialog closes, the query field is focused and + // f is typed. + e.preventDefault(); + }); + + return () => { + Mousetrap.unbind("f"); + }; + } + }, [showEditFilter]); + + useEffect(() => { + if (!currentPage || !changePage || !pages) return; + + function changePage(page: number) { + if (!currentPage || !onChangePage || !pages) return; + if (page >= 1 && page <= pages) { + onChangePage(page); + } + } + + Mousetrap.bind("right", () => { + changePage(currentPage + 1); + }); + Mousetrap.bind("left", () => { + changePage(currentPage - 1); + }); + Mousetrap.bind("shift+right", () => { + changePage(Math.min(pages, currentPage + 10)); + }); + Mousetrap.bind("shift+left", () => { + changePage(Math.max(1, currentPage - 10)); + }); + Mousetrap.bind("ctrl+end", () => { + changePage(pages); + }); + Mousetrap.bind("ctrl+home", () => { + changePage(1); + }); + + return () => { + Mousetrap.unbind("right"); + Mousetrap.unbind("left"); + Mousetrap.unbind("shift+right"); + Mousetrap.unbind("shift+left"); + Mousetrap.unbind("ctrl+end"); + Mousetrap.unbind("ctrl+home"); + }; + }, [currentPage, onChangePage, pages]); + + useEffect(() => { + Mousetrap.bind("s a", () => onSelectAll?.()); + Mousetrap.bind("s n", () => onSelectNone?.()); + + return () => { + Mousetrap.unbind("s a"); + Mousetrap.unbind("s n"); + }; + }, [onSelectAll, onSelectNone]); +} + +export function useListSelect(items: T[]) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [lastClickedId, setLastClickedId] = useState(); + + 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(); + + 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(); + items.forEach((item) => { + newSelectedIds.add(item.id); + }); + + setSelectedIds(newSelectedIds); + setLastClickedId(undefined); + } + + function onSelectNone() { + const newSelectedIds = new Set(); + 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 = ReturnType>; + +// 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( + 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> +) { + useEffect(() => { + const totalPages = Math.ceil(totalCount / filter.itemsPerPage); + + if (totalPages > 0 && filter.currentPage > totalPages) { + setFilter((prevFilter) => prevFilter.changePage(1)); + } + }, [filter, totalCount, setFilter]); +} diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 9dd0ff277..68733d3b8 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -9,7 +9,7 @@ import { useFindPerformers, usePerformersDestroy, } from "src/core/StashService"; -import { makeItemList, showWhenSelected } from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { PerformerTagger } from "../Tagger/performers/PerformerTagger"; @@ -23,16 +23,13 @@ import TextUtils from "src/utils/text"; import { PerformerCardGrid } from "./PerformerCardGrid"; import { View } from "../List/views"; -const PerformerItemList = makeItemList({ - filterMode: GQL.FilterMode.Performers, - useResult: useFindPerformers, - getItems(result: GQL.FindPerformersQueryResult) { - return result?.data?.findPerformers?.performers ?? []; - }, - getCount(result: GQL.FindPerformersQueryResult) { - return result?.data?.findPerformers?.count ?? 0; - }, -}); +function getItems(result: GQL.FindPerformersQueryResult) { + return result?.data?.findPerformers?.performers ?? []; +} + +function getCount(result: GQL.FindPerformersQueryResult) { + return result?.data?.findPerformers?.count ?? 0; +} export const FormatHeight = (height?: number | null) => { const intl = useIntl(); @@ -175,6 +172,8 @@ export const PerformerList: React.FC = ({ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const filterMode = GQL.FilterMode.Performers; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.open_random" }), @@ -319,16 +318,24 @@ export const PerformerList: React.FC = ({ } return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 4ffd25040..6fa7c5dbd 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -5,7 +5,7 @@ import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindScenes, useFindScenes } from "src/core/StashService"; -import { makeItemList, showWhenSelected } from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { Tagger } from "../Tagger/scenes/SceneTagger"; @@ -26,51 +26,49 @@ import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; import { View } from "../List/views"; -const SceneItemList = makeItemList({ - filterMode: GQL.FilterMode.Scenes, - useResult: useFindScenes, - getItems(result: GQL.FindScenesQueryResult) { - return result?.data?.findScenes?.scenes ?? []; - }, - getCount(result: GQL.FindScenesQueryResult) { - return result?.data?.findScenes?.count ?? 0; - }, - renderMetadataByline(result: GQL.FindScenesQueryResult) { - const duration = result?.data?.findScenes?.duration; - const size = result?.data?.findScenes?.filesize; - const filesize = size ? TextUtils.fileSize(size) : undefined; +function getItems(result: GQL.FindScenesQueryResult) { + return result?.data?.findScenes?.scenes ?? []; +} - if (!duration && !size) { - return; - } +function getCount(result: GQL.FindScenesQueryResult) { + return result?.data?.findScenes?.count ?? 0; +} - const separator = duration && size ? " - " : ""; +function renderMetadataByline(result: GQL.FindScenesQueryResult) { + const duration = result?.data?.findScenes?.duration; + const size = result?.data?.findScenes?.filesize; + const filesize = size ? TextUtils.fileSize(size) : undefined; - return ( - -  ( - {duration ? ( - - {TextUtils.secondsAsTimeString(duration, 3)} - - ) : undefined} - {separator} - {size && filesize ? ( - - - {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} - - ) : undefined} - ) - - ); - }, -}); + if (!duration && !size) { + return; + } + + const separator = duration && size ? " - " : ""; + + return ( + +  ( + {duration ? ( + + {TextUtils.secondsAsTimeString(duration, 3)} + + ) : undefined} + {separator} + {size && filesize ? ( + + + {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} + + ) : undefined} + ) + + ); +} interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -95,6 +93,8 @@ export const SceneList: React.FC = ({ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const filterMode = GQL.FilterMode.Scenes; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.play_selected" }), @@ -350,19 +350,28 @@ export const SceneList: React.FC = ({ return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index b81a4aecf..2bf7ae8db 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -9,22 +9,19 @@ import { useFindSceneMarkers, } from "src/core/StashService"; import NavUtils from "src/utils/navigation"; -import { makeItemList } from "../List/ItemList"; +import { ItemList, ItemListContext } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "../Wall/WallPanel"; import { View } from "../List/views"; -const SceneMarkerItemList = makeItemList({ - filterMode: GQL.FilterMode.SceneMarkers, - useResult: useFindSceneMarkers, - getItems(result: GQL.FindSceneMarkersQueryResult) { - return result?.data?.findSceneMarkers?.scene_markers ?? []; - }, - getCount(result: GQL.FindSceneMarkersQueryResult) { - return result?.data?.findSceneMarkers?.count ?? 0; - }, -}); +function getItems(result: GQL.FindSceneMarkersQueryResult) { + return result?.data?.findSceneMarkers?.scene_markers ?? []; +} + +function getCount(result: GQL.FindSceneMarkersQueryResult) { + return result?.data?.findSceneMarkers?.count ?? 0; +} interface ISceneMarkerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -40,6 +37,8 @@ export const SceneMarkerList: React.FC = ({ const intl = useIntl(); const history = useHistory(); + const filterMode = GQL.FilterMode.SceneMarkers; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.play_random" }), @@ -97,14 +96,22 @@ export const SceneMarkerList: React.FC = ({ } return ( - + > + + ); }; diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx index c8fcb7013..732b1cffb 100644 --- a/ui/v2.5/src/components/Shared/FilterSelect.tsx +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -14,10 +14,7 @@ import cx from "classnames"; import { useToast } from "src/hooks/Toast"; import { useDebounce } from "src/hooks/debounce"; - -interface IHasID { - id: string; -} +import { IHasID } from "src/utils/data"; export type Option = { value: string; object: T }; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index c3bcf684d..2fc991cf1 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -1,3 +1,23 @@ +.LoadingIndicator { + // fade in animation - delay showing + animation: fadeInAnimation ease 200ms; + animation-delay: 200ms; + animation-fill-mode: forwards; + animation-iteration-count: 1; + + opacity: 0; +} + +@keyframes fadeInAnimation { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + .LoadingIndicator { align-items: center; display: flex; @@ -6,7 +26,7 @@ width: 100%; &:not(.card-based) { - height: 70vh; + padding-top: 2rem; } &-message { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index d3c71f787..522dfe64a 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -1,4 +1,4 @@ -import { Tabs, Tab } from "react-bootstrap"; +import { Tabs, Tab, Form } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; @@ -79,16 +79,18 @@ const StudioTabs: React.FC<{ abbreviateCounter: boolean; showAllCounts?: boolean; }> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => { + const [showAllDetails, setShowAllDetails] = useState(showAllCounts); + const sceneCount = - (showAllCounts ? studio.scene_count_all : studio.scene_count) ?? 0; + (showAllDetails ? studio.scene_count_all : studio.scene_count) ?? 0; const galleryCount = - (showAllCounts ? studio.gallery_count_all : studio.gallery_count) ?? 0; + (showAllDetails ? studio.gallery_count_all : studio.gallery_count) ?? 0; const imageCount = - (showAllCounts ? studio.image_count_all : studio.image_count) ?? 0; + (showAllDetails ? studio.image_count_all : studio.image_count) ?? 0; const performerCount = - (showAllCounts ? studio.performer_count_all : studio.performer_count) ?? 0; + (showAllDetails ? studio.performer_count_all : studio.performer_count) ?? 0; const groupCount = - (showAllCounts ? studio.group_count_all : studio.group_count) ?? 0; + (showAllDetails ? studio.group_count_all : studio.group_count) ?? 0; const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; @@ -123,6 +125,21 @@ const StudioTabs: React.FC<{ baseURL: `/studios/${studio.id}`, }); + const contentSwitch = useMemo( + () => ( +
+ setShowAllDetails(!showAllDetails)} + type="switch" + label={} + /> +
+ ), + [showAllDetails] + ); + return ( } > - + {contentSwitch} + } > - + {contentSwitch} + } > - + {contentSwitch} + } > + {contentSwitch} } > - + {contentSwitch} + = ({ - active, - studio, -}) => { - function filterHook(filter: ListFilterModel) { +function useFilterHook(studio: GQL.StudioDataFragment) { + return (filter: ListFilterModel) => { const studioValue = { id: studio.id!, label: studio.name! }; // if studio is already present, then we modify it, otherwise add let parentStudioCriterion = filter.criteria.find((c) => { @@ -44,7 +36,19 @@ export const StudioChildrenPanel: React.FC = ({ } return filter; - } + }; +} + +interface IStudioChildrenPanel { + active: boolean; + studio: GQL.StudioDataFragment; +} + +export const StudioChildrenPanel: React.FC = ({ + active, + studio, +}) => { + const filterHook = useFilterHook(studio); return ( = ({ active, studio, + showChildStudioContent, }) => { - const filterHook = useStudioFilterHook(studio); + const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( = ({ active, studio, + showChildStudioContent, }) => { - const filterHook = useStudioFilterHook(studio); + const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( = ({ active, studio, + showChildStudioContent, }) => { - const filterHook = useStudioFilterHook(studio); + const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( = ({ active, studio, + showChildStudioContent, }) => { const studioCriterion = new StudiosCriterion(); studioCriterion.value = { @@ -28,7 +30,7 @@ export const StudioPerformersPanel: React.FC = ({ groups: [studioCriterion], }; - const filterHook = useStudioFilterHook(studio); + const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( = ({ active, studio, + showChildStudioContent, }) => { - const filterHook = useStudioFilterHook(studio); + const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( = ({ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const filterMode = GQL.FilterMode.Studios; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.view_random" }), @@ -177,15 +176,23 @@ export const StudioList: React.FC = ({ } return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index d392707d1..1c80dc157 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -1,4 +1,4 @@ -import { Tabs, Tab, Dropdown } from "react-bootstrap"; +import { Tabs, Tab, Dropdown, Form } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; @@ -82,20 +82,22 @@ const TagTabs: React.FC<{ abbreviateCounter: boolean; showAllCounts?: boolean; }> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => { + const [showAllDetails, setShowAllDetails] = useState(showAllCounts); + const sceneCount = - (showAllCounts ? tag.scene_count_all : tag.scene_count) ?? 0; + (showAllDetails ? tag.scene_count_all : tag.scene_count) ?? 0; const imageCount = - (showAllCounts ? tag.image_count_all : tag.image_count) ?? 0; + (showAllDetails ? tag.image_count_all : tag.image_count) ?? 0; const galleryCount = - (showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0; + (showAllDetails ? tag.gallery_count_all : tag.gallery_count) ?? 0; const groupCount = - (showAllCounts ? tag.group_count_all : tag.group_count) ?? 0; + (showAllDetails ? tag.group_count_all : tag.group_count) ?? 0; const sceneMarkerCount = - (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; + (showAllDetails ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; const performerCount = - (showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0; + (showAllDetails ? tag.performer_count_all : tag.performer_count) ?? 0; const studioCount = - (showAllCounts ? tag.studio_count_all : tag.studio_count) ?? 0; + (showAllDetails ? tag.studio_count_all : tag.studio_count) ?? 0; const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; @@ -133,6 +135,21 @@ const TagTabs: React.FC<{ baseURL: `/tags/${tag.id}`, }); + const contentSwitch = useMemo( + () => ( +
+ setShowAllDetails(!showAllDetails)} + type="switch" + label={} + /> +
+ ), + [showAllDetails] + ); + return ( } > - + {contentSwitch} +
} > - + {contentSwitch} + } > - + {contentSwitch} + } > - + {contentSwitch} + } > - + {contentSwitch} + } > - + {contentSwitch} + } > - + {contentSwitch} +
); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx index 7d46c4e31..bb95a7ea1 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx @@ -7,13 +7,15 @@ import { View } from "src/components/List/views"; interface ITagGalleriesPanel { active: boolean; tag: GQL.TagDataFragment; + showSubTagContent?: boolean; } export const TagGalleriesPanel: React.FC = ({ active, tag, + showSubTagContent, }) => { - const filterHook = useTagFilterHook(tag); + const filterHook = useTagFilterHook(tag, showSubTagContent); return ( = ({ active, tag }) => { - const filterHook = useTagFilterHook(tag); + showSubTagContent?: boolean; +}> = ({ active, tag, showSubTagContent }) => { + const filterHook = useTagFilterHook(tag, showSubTagContent); return ; }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx index 61e235499..19ceb5431 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx @@ -7,10 +7,15 @@ import { View } from "src/components/List/views"; interface ITagImagesPanel { active: boolean; tag: GQL.TagDataFragment; + showSubTagContent?: boolean; } -export const TagImagesPanel: React.FC = ({ active, tag }) => { - const filterHook = useTagFilterHook(tag); +export const TagImagesPanel: React.FC = ({ + active, + tag, + showSubTagContent, +}) => { + const filterHook = useTagFilterHook(tag, showSubTagContent); return ( = ({ - active, - tag, -}) => { - function filterHook(filter: ListFilterModel) { +function useFilterHook(tag: GQL.TagDataFragment, showSubTagContent?: boolean) { + return (filter: ListFilterModel) => { const tagValue = { id: tag.id, label: tag.name }; // if tag is already present, then we modify it, otherwise add let tagCriterion = filter.criteria.find((c) => { @@ -45,13 +37,27 @@ export const TagMarkersPanel: React.FC = ({ tagCriterion.value = { items: [tagValue], excluded: [], - depth: 0, + depth: showSubTagContent ? -1 : 0, }; filter.criteria.push(tagCriterion); } return filter; - } + }; +} + +interface ITagMarkersPanel { + active: boolean; + tag: GQL.TagDataFragment; + showSubTagContent?: boolean; +} + +export const TagMarkersPanel: React.FC = ({ + active, + tag, + showSubTagContent, +}) => { + const filterHook = useFilterHook(tag, showSubTagContent); return ( = ({ active, tag, + showSubTagContent, }) => { - const filterHook = useTagFilterHook(tag); + const filterHook = useTagFilterHook(tag, showSubTagContent); return ( = ({ active, tag }) => { - const filterHook = useTagFilterHook(tag); +export const TagScenesPanel: React.FC = ({ + active, + tag, + showSubTagContent, +}) => { + const filterHook = useTagFilterHook(tag, showSubTagContent); return ( = ({ active, tag, + showSubTagContent, }) => { - const filterHook = useTagFilterHook(tag); + const filterHook = useTagFilterHook(tag, showSubTagContent); return ; }; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 42a6316f9..cbde60a5c 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -3,7 +3,7 @@ import cloneDeep from "lodash-es/cloneDeep"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { makeItemList, showWhenSelected } from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { Button } from "react-bootstrap"; import { Link, useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; @@ -27,27 +27,27 @@ import { TagCardGrid } from "./TagCardGrid"; import { EditTagsDialog } from "./EditTagsDialog"; import { View } from "../List/views"; +function getItems(result: GQL.FindTagsQueryResult) { + return result?.data?.findTags?.tags ?? []; +} + +function getCount(result: GQL.FindTagsQueryResult) { + return result?.data?.findTags?.count ?? 0; +} + interface ITagList { filterHook?: (filter: ListFilterModel) => ListFilterModel; alterQuery?: boolean; } -const TagItemList = makeItemList({ - filterMode: GQL.FilterMode.Tags, - useResult: useFindTags, - getItems(result: GQL.FindTagsQueryResult) { - return result?.data?.findTags?.tags ?? []; - }, - getCount(result: GQL.FindTagsQueryResult) { - return result?.data?.findTags?.count ?? 0; - }, -}); - export const TagList: React.FC = ({ filterHook, alterQuery }) => { const Toast = useToast(); const [deletingTag, setDeletingTag] = useState | null>(null); + const filterMode = GQL.FilterMode.Tags; + const view = View.Tags; + function getDeleteTagInput() { const tagInput: Partial = {}; if (deletingTag) { @@ -355,18 +355,25 @@ export const TagList: React.FC = ({ filterHook, alterQuery }) => { } return ( - + filterHook={filterHook} + view={view} + selectable + > + + ); }; diff --git a/ui/v2.5/src/core/studios.ts b/ui/v2.5/src/core/studios.ts index ca1d88c5f..377812741 100644 --- a/ui/v2.5/src/core/studios.ts +++ b/ui/v2.5/src/core/studios.ts @@ -1,11 +1,11 @@ import * as GQL from "src/core/generated-graphql"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { ListFilterModel } from "src/models/list-filter/filter"; -import React from "react"; -import { ConfigurationContext } from "src/hooks/Config"; -export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => { - const { configuration } = React.useContext(ConfigurationContext); +export const useStudioFilterHook = ( + studio: GQL.StudioDataFragment, + showChildStudioContent?: boolean +) => { return (filter: ListFilterModel) => { const studioValue = { id: studio.id, label: studio.name }; // if studio is already present, then we modify it, otherwise add @@ -22,7 +22,7 @@ export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => { studioCriterion.value = { items: [studioValue], excluded: [], - depth: configuration?.ui.showChildStudioContent ? -1 : 0, + depth: showChildStudioContent ? -1 : 0, }; studioCriterion.modifier = GQL.CriterionModifier.Includes; filter.criteria.push(studioCriterion); diff --git a/ui/v2.5/src/core/tags.ts b/ui/v2.5/src/core/tags.ts index 6ce586941..b62e69547 100644 --- a/ui/v2.5/src/core/tags.ts +++ b/ui/v2.5/src/core/tags.ts @@ -6,11 +6,11 @@ import { TagsCriterionOption, } from "src/models/list-filter/criteria/tags"; import { ListFilterModel } from "src/models/list-filter/filter"; -import React from "react"; -import { ConfigurationContext } from "src/hooks/Config"; -export const useTagFilterHook = (tag: GQL.TagDataFragment) => { - const { configuration } = React.useContext(ConfigurationContext); +export const useTagFilterHook = ( + tag: GQL.TagDataFragment, + showSubTagContent?: boolean +) => { return (filter: ListFilterModel) => { const tagValue = { id: tag.id, label: tag.name }; // if tag is already present, then we modify it, otherwise add @@ -42,7 +42,7 @@ export const useTagFilterHook = (tag: GQL.TagDataFragment) => { tagCriterion.value = { items: [tagValue], excluded: [], - depth: configuration?.ui.showChildTagContent ? -1 : 0, + depth: showSubTagContent ? -1 : 0, }; tagCriterion.modifier = GQL.CriterionModifier.IncludesAll; filter.criteria.push(tagCriterion); diff --git a/ui/v2.5/src/hooks/modal.ts b/ui/v2.5/src/hooks/modal.ts new file mode 100644 index 000000000..8b69f94c1 --- /dev/null +++ b/ui/v2.5/src/hooks/modal.ts @@ -0,0 +1,10 @@ +import React from "react"; + +export function useModal() { + const [modal, setModal] = React.useState(); + + const closeModal = () => setModal(undefined); + const showModal = (m: React.ReactNode) => setModal(m); + + return { modal, closeModal, showModal }; +} diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 7bdb49040..c277e864a 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -258,6 +258,15 @@ dd { padding: 5px 0; } + .item-list-header { + align-content: center; + // border-bottom: solid 2px #192127; + display: flex; + justify-content: center; + margin: 0; + padding: 5px 0 0 0; + } + .item-list-container { padding-top: 15px; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 5145ea829..59ea1459e 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1086,7 +1086,9 @@ "image_index": "Image #", "images": "Images", "include_parent_tags": "Include parent tags", + "include_sub_studio_content": "Include sub-studio content", "include_sub_studios": "Include subsidiary studios", + "include_sub_tag_content": "Include sub-tag content", "include_sub_tags": "Include sub-tags", "index_of_total": "{index} of {total}", "instagram": "Instagram", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 6be7d6040..2c36cf545 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -89,6 +89,15 @@ export abstract class Criterion { this.value = value; } + public clone(): Criterion { + const newCriterion = new (this.constructor as new ( + type: CriterionOption, + value: V + ) => Criterion)(this.criterionOption, this.value); + newCriterion.modifier = this.modifier; + return newCriterion; + } + public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) { const modifierMessageID = modifierMessageIDs[modifier]; @@ -251,6 +260,19 @@ export class ILabeledIdCriterionOption extends CriterionOption { } export class ILabeledIdCriterion extends Criterion { + constructor(type: CriterionOption, value: ILabeledId[] = []) { + super(type, value); + } + + public clone(): Criterion { + const newCriterion = new ILabeledIdCriterion( + this.criterionOption, + this.value.map((v) => ({ ...v })) + ); + newCriterion.modifier = this.modifier; + return newCriterion; + } + protected getLabelValue(_intl: IntlShape): string { return this.value.map((v) => v.label).join(", "); } @@ -272,23 +294,33 @@ export class ILabeledIdCriterion extends Criterion { return this.value.length > 0; } - - constructor(type: CriterionOption) { - super(type, []); - } } export class IHierarchicalLabeledIdCriterion extends Criterion { - constructor(type: CriterionOption) { - const value: IHierarchicalLabelValue = { + constructor( + type: CriterionOption, + value: IHierarchicalLabelValue = { items: [], excluded: [], depth: 0, - }; - + } + ) { super(type, value); } + public clone(): Criterion { + const newCriterion = new IHierarchicalLabeledIdCriterion( + this.criterionOption, + { + ...this.value, + items: this.value.items.map((v) => ({ ...v })), + excluded: this.value.excluded.map((v) => ({ ...v })), + } + ); + newCriterion.modifier = this.modifier; + return newCriterion; + } + override get modifier(): CriterionModifier { return this._modifier; } @@ -501,8 +533,17 @@ export class StringCriterion extends Criterion { } export class MultiStringCriterion extends Criterion { - constructor(type: CriterionOption) { - super(type, []); + constructor(type: CriterionOption, value: string[] = []) { + super(type, value); + } + + public clone(): Criterion { + const newCriterion = new MultiStringCriterion( + this.criterionOption, + this.value.slice() + ); + newCriterion.modifier = this.modifier; + return newCriterion; } protected getLabelValue(_intl: IntlShape) { diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 794fd2a7e..599a6bbae 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -67,26 +67,48 @@ export class ListFilterModel { public constructor( mode: FilterMode, config?: ConfigDataFragment, - defaultZoomIndex?: number + options?: { + defaultZoomIndex?: number; + defaultSortBy?: string; + defaultSortDir?: SortDirectionEnum; + } ) { this.mode = mode; this.config = config; this.options = getFilterOptions(mode); const { defaultSortBy, displayModeOptions } = this.options; - this.sortBy = defaultSortBy; - if (this.sortBy === "date") { - this.sortDirection = SortDirectionEnum.Desc; + if (options?.defaultSortBy) { + this.sortBy = options.defaultSortBy; + if (options.defaultSortDir) { + this.sortDirection = options.defaultSortDir; + } + } else { + this.sortBy = defaultSortBy; + if (this.sortBy === "date") { + this.sortDirection = SortDirectionEnum.Desc; + } } this.displayMode = displayModeOptions[0]; - if (defaultZoomIndex !== undefined) { - this.defaultZoomIndex = defaultZoomIndex; - this.zoomIndex = defaultZoomIndex; + if (options?.defaultZoomIndex !== undefined) { + this.defaultZoomIndex = options.defaultZoomIndex; + this.zoomIndex = options.defaultZoomIndex; } } public clone() { - return Object.assign(new ListFilterModel(this.mode, this.config), this); + const ret = Object.assign( + new ListFilterModel(this.mode, this.config), + this + ); + ret.criteria = this.criteria.map((c) => c.clone()); + return ret; + } + + public empty() { + return new ListFilterModel(this.mode, this.config, { + defaultZoomIndex: this.defaultZoomIndex, + }); } // returns the number of filters applied @@ -443,4 +465,44 @@ export class ListFilterModel { zoom_index: this.zoomIndex, }; } + + public clearCriteria() { + const ret = this.clone(); + ret.criteria = []; + ret.currentPage = 1; + return ret; + } + + public removeCriterion(type: CriterionType) { + const ret = this.clone(); + const c = ret.criteria.find((cc) => cc.criterionOption.type === type); + + if (!c) return ret; + + const newCriteria = ret.criteria.filter((cc) => { + return cc.getId() !== c.getId(); + }); + + ret.criteria = newCriteria; + ret.currentPage = 1; + return ret; + } + + public changePage(page: number) { + const ret = this.clone(); + ret.currentPage = page; + return ret; + } + + public setZoom(zoomIndex: number) { + const ret = this.clone(); + ret.zoomIndex = zoomIndex; + return ret; + } + + public setDisplayMode(displayMode: DisplayMode) { + const ret = this.clone(); + ret.displayMode = displayMode; + return ret; + } } diff --git a/ui/v2.5/src/utils/bulkUpdate.ts b/ui/v2.5/src/utils/bulkUpdate.ts index 70a5b38fc..deb8956bb 100644 --- a/ui/v2.5/src/utils/bulkUpdate.ts +++ b/ui/v2.5/src/utils/bulkUpdate.ts @@ -1,5 +1,6 @@ import * as GQL from "src/core/generated-graphql"; import isEqual from "lodash-es/isEqual"; +import { IHasID } from "./data"; interface IHasRating { rating100?: GQL.Maybe | undefined; @@ -21,10 +22,6 @@ export function getAggregateRating(state: IHasRating[]) { return ret; } -interface IHasID { - id: string; -} - interface IHasStudio { studio?: GQL.Maybe | undefined; } diff --git a/ui/v2.5/src/utils/data.ts b/ui/v2.5/src/utils/data.ts index 166602552..26505bebb 100644 --- a/ui/v2.5/src/utils/data.ts +++ b/ui/v2.5/src/utils/data.ts @@ -1,6 +1,10 @@ export const filterData = (data?: (T | null | undefined)[] | null) => data ? (data.filter((item) => item) as T[]) : []; +export interface IHasID { + id: string; +} + export interface ITypename { __typename?: string; }