From db870247362681f38dfd3657c60ecf676be6036c Mon Sep 17 00:00:00 2001 From: ascariandrea Date: Sat, 13 Jan 2024 16:28:25 +0100 Subject: [PATCH] feat(ui): define InfiniteListBox component and related logic --- .../shared/src/providers/api-rest.provider.ts | 13 +- .../src/components/Common/Editor/Editor.tsx | 2 - .../ui/src/components/lists/ActorList.tsx | 3 + .../list/InfiniteListBox/InfiniteList.tsx | 175 ++++++++++++++++++ .../list/InfiniteListBox/InfiniteListBox.tsx | 161 ++++++++++++++++ .../list/InfiniteListBox/InfiniteMasonry.tsx | 147 +++++++++++++++ .../containers/list/InfiniteListBox/types.ts | 6 + .../containers/list/InfiniteMediaListBox.tsx | 46 +++++ .../ui/src/templates/MediaSearchTemplate.tsx | 9 +- .../List/InfiniteListBox.stories.tsx | 122 ++++++++++++ 10 files changed, 674 insertions(+), 10 deletions(-) create mode 100644 packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteList.tsx create mode 100644 packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteListBox.tsx create mode 100644 packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteMasonry.tsx create mode 100644 packages/@liexp/ui/src/containers/list/InfiniteListBox/types.ts create mode 100644 packages/@liexp/ui/src/containers/list/InfiniteMediaListBox.tsx create mode 100644 services/storybook/src/stories/containers/List/InfiniteListBox.stories.tsx diff --git a/packages/@liexp/shared/src/providers/api-rest.provider.ts b/packages/@liexp/shared/src/providers/api-rest.provider.ts index 31e8ca38ea..59affc3234 100644 --- a/packages/@liexp/shared/src/providers/api-rest.provider.ts +++ b/packages/@liexp/shared/src/providers/api-rest.provider.ts @@ -58,6 +58,16 @@ const liftClientRequest = ( ); }; +export const paramsToPagination = ( + start: number, + end: number, +): RA.GetListParams["pagination"] => { + return { + page: start < 20 ? 1 : Math.ceil(start / 20) + 1, + perPage: 20, + }; +}; + const formatParams =

( params: P, ): RA.GetListParams => { @@ -104,9 +114,10 @@ export const APIRESTClient = ({ client.get(`${resource}/${params.id}`, { params }), )(), getList: (resource, params) => { + const formattedParams = formatParams(params); return liftClientRequest>(() => client.get(resource, { - params: formatParams(params), + params: formattedParams, }), )(); }, diff --git a/packages/@liexp/ui/src/components/Common/Editor/Editor.tsx b/packages/@liexp/ui/src/components/Common/Editor/Editor.tsx index f395610f4c..b83e00f7ba 100644 --- a/packages/@liexp/ui/src/components/Common/Editor/Editor.tsx +++ b/packages/@liexp/ui/src/components/Common/Editor/Editor.tsx @@ -62,8 +62,6 @@ const Editor: React.FC> = ({ }), ); - // console.log({ lastChar }); - if (lastChar === "/") { if (!open) { setOpen(true); diff --git a/packages/@liexp/ui/src/components/lists/ActorList.tsx b/packages/@liexp/ui/src/components/lists/ActorList.tsx index 8e5b6d4782..6e55d6554f 100644 --- a/packages/@liexp/ui/src/components/lists/ActorList.tsx +++ b/packages/@liexp/ui/src/components/lists/ActorList.tsx @@ -15,6 +15,7 @@ export interface ActorListItemProps extends ListItemProps { avatarSize?: AvatarSize; displayFullName?: boolean; style?: React.CSSProperties; + onLoad?: () => void; } export const ActorListItem: React.FC = ({ @@ -23,6 +24,7 @@ export const ActorListItem: React.FC = ({ displayFullName = false, onClick, style, + onLoad }) => { return ( = ({ src={src} size={avatarSize} style={{ margin: 5 }} + onLoad={onLoad} /> )), O.toNullable, diff --git a/packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteList.tsx b/packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteList.tsx new file mode 100644 index 0000000000..c74af6f204 --- /dev/null +++ b/packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteList.tsx @@ -0,0 +1,175 @@ +import React from "react"; +import { + CellMeasurer, + CellMeasurerCache, + List, + type ListRowProps, +} from "react-virtualized"; +import { styled } from "../../../theme/index.js"; +import { type InfiniteListBaseProps } from "./types.js"; + +export interface RowRendererProps extends Omit { + item: any; + isLast: boolean; + measure: () => void; + onRowInvalidate?: () => void; +} + +type RowRenderer = React.ForwardRefExoticComponent; + +type InfiniteListRowProps

= ListRowProps & + Omit & + Omit & { + RowRenderer: RowRenderer; + k: string; + }; + +const Row: React.FC> = (props) => { + const { + isVisible, + isLast, + style, + parent, + index, + k: key, + RowRenderer, + item, + onRowInvalidate, + ...rest + } = props; + + return ( + + {({ registerChild, measure }) => { + if (!item) { + return ( +

} + key={key} + style={{ height: 300 }} + /> + ); + } + + if (!isVisible) { + // console.log("no visible", index); + return ( +
} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + height: 300, + width: "100%", + ...props.style, + }} + /> + ); + } + + const rowRendererProps = { + ref: registerChild as React.Ref, + isVisible, + isLast, + style, + index, + item, + measure, + onRowInvalidate: () => { + cellCache.clear(index, 0); + onRowInvalidate?.(); + setTimeout(() => { + measure(); + }, 300); + }, + ...rest, + }; + + // console.log(rowRendererProps); + + return ; + }} + + ); +}; + +const PREFIX = "InfiniteList"; +const classes = { + timeline: `${PREFIX}-timeline`, + listSubheader: `${PREFIX}-listSubheader`, + listItemUList: `${PREFIX}-listItemUList`, +}; + +const StyledList = styled(List)(({ theme }) => ({ + [`&.${classes.timeline}`]: { + padding: 0, + paddingTop: 20, + width: "100%", + }, + + [`& .${classes.listSubheader}`]: { + backgroundColor: theme.palette.common.white, + }, + + [`& .${classes.listItemUList}`]: { + padding: 0, + width: "100%", + }, +})); + +export interface InfiniteListProps extends InfiniteListBaseProps { + onRowsRendered: (params: { startIndex: number; stopIndex: number }) => void; + RowRenderer: RowRenderer; +} + +const cellCache = new CellMeasurerCache({ + fixedWidth: true, + minWidth: 200, + fixedHeight: true, + defaultHeight: 300, +}); + +const InfiniteListForwardRef: React.ForwardRefRenderFunction< + unknown, + InfiniteListProps +> = ({ width, height, onRowsRendered, items, getItem, ...rest }, listRef) => { + // console.log("items", items.length); + return ( + { + const item = getItem(items, props.index); + const isLast = items.length === props.index + 1; + // console.log({ item: item.id, isLast, isVisible: props.isVisible }); + return ( + + ); + }} + rowCount={items.length} + rowHeight={cellCache.rowHeight} + deferredMeasurementCache={cellCache} + /> + ); +}; + +export const InfiniteList = React.forwardRef(InfiniteListForwardRef); diff --git a/packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteListBox.tsx b/packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteListBox.tsx new file mode 100644 index 0000000000..34dd172239 --- /dev/null +++ b/packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteListBox.tsx @@ -0,0 +1,161 @@ +import { type APIError } from "@liexp/shared/lib/io/http/Error/APIError.js"; +import { type EndpointsQueryProvider } from "@liexp/shared/lib/providers/EndpointQueriesProvider/index.js"; +import { type ResourceQuery } from "@liexp/shared/lib/providers/EndpointQueriesProvider/types.js"; +import { paramsToPagination } from "@liexp/shared/lib/providers/api-rest.provider.js"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import React from "react"; +import { AutoSizer, InfiniteLoader, type Index } from "react-virtualized"; +import { FullSizeLoader } from "../../../components/Common/FullSizeLoader.js"; +import { useEndpointQueries } from "../../../hooks/useEndpointQueriesProvider.js"; +import { InfiniteList, type InfiniteListProps } from "./InfiniteList.js"; +import { + InfiniteMasonry, + type InfiniteMasonryProps, +} from "./InfiniteMasonry.js"; + +export type ListType = "masonry" | "list"; + +type ListProps = T extends "masonry" + ? { type: "masonry" } & Omit< + InfiniteMasonryProps, + "width" | "height" | "items" | "cellMeasureCache" + > + : { type: "list" } & Omit< + InfiniteListProps, + "width" | "height" | "items" | "onRowsRendered" + >; + +export interface InfiniteListBoxProps { + listProps: ListProps; + useListQuery: >( + queryProvider: EndpointsQueryProvider, + ) => R; +} + +export const InfiniteListBox = ({ + useListQuery, + ...rest +}: InfiniteListBoxProps): JSX.Element => { + const Q = useEndpointQueries(); + + const query = React.useMemo(() => { + return useListQuery(Q); + }, [useListQuery]); + + const queryKey = query.getKey({} as any, undefined, false, "infinite-list"); + + const { + data, + hasNextPage, + isFetching, + isFetchingNextPage, + fetchNextPage, + isRefetching, + // refetch, + } = useInfiniteQuery< + any, + APIError, + { + pages: Array<{ data: any[]; total: number }>; + lastPage: { data: any[]; total: number }; + }, + any, + { _start: number; _end: number } + >({ + initialPageParam: { _start: 0, _end: 20 }, + queryKey, + queryFn: (opts) => { + const pageParam: any = paramsToPagination( + opts.pageParam._start, + opts.pageParam._end, + ); + + return query.fetch( + { + pagination: pageParam, + filter: null, + }, + undefined, + false, + ); + }, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + // console.log("get next params", { + // lastPage, + // allPages, + // lastPageParam, + // allPageParams, + // }); + return { _start: lastPageParam._end, _end: lastPageParam._end + 20 }; + }, + }); + + const isRowLoaded = (params: Index): boolean => { + const rowLoaded = items[params.index] !== undefined; + return rowLoaded; + }; + + const { items, total } = React.useMemo(() => { + // console.log('data', data); + const items = data?.pages.flatMap((p) => p.data) ?? []; + const total = data?.pages[0]?.total ?? 0; + return { items, total }; + }, [data]); + + const handleLoadMoreRows = React.useCallback( + async (props: any) => { + if (hasNextPage && !(isFetchingNextPage || isRefetching)) { + const pageParams = { + _start: props.startIndex, + _end: props.stopIndex + 1 - props.startIndex, + } as any; + // console.log("handleLoadMoreRows", pageParams); + await fetchNextPage(pageParams); + } + }, + [fetchNextPage, hasNextPage, isFetchingNextPage, isRefetching], + ); + + if (isFetching && items.length === 0) { + return ; + } + + return ( + + {({ onRowsRendered, registerChild }) => ( + + {({ width, height }) => { + if (rest.listProps.type === "masonry") { + return ( + + ); + } + return ( + + ); + }} + + )} + + ); +}; diff --git a/packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteMasonry.tsx b/packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteMasonry.tsx new file mode 100644 index 0000000000..1399bda680 --- /dev/null +++ b/packages/@liexp/ui/src/containers/list/InfiniteListBox/InfiniteMasonry.tsx @@ -0,0 +1,147 @@ +import React from "react"; +import { + CellMeasurer, + CellMeasurerCache, + Masonry, + createMasonryCellPositioner, + type MasonryCellProps, + type MasonryProps, +} from "react-virtualized"; +import { styled } from "../../../theme/index.js"; +import { type InfiniteListBaseProps } from "./types.js"; + +export interface CellRendererProps + extends Omit { + item: any; + isLast: boolean; + measure: () => void; + onRowInvalidate?: () => void; +} + +type CellRenderer = React.ForwardRefExoticComponent; + +export type InfiniteMasonryCellProps

= MasonryCellProps & + Omit & + Omit & { + CellRenderer: CellRenderer; + k: string; + }; + +const Cell: React.FC> = (props) => { + const { + isLast, + style, + parent, + index, + k: key, + CellRenderer, + item, + onRowInvalidate, + ...rest + } = props; + + return ( + + {({ registerChild, measure }) => { + const cellRendererProps = { + ref: registerChild as React.Ref, + isLast, + style, + index, + item, + measure, + onRowInvalidate: () => { + cellCache.clear(index, 0); + onRowInvalidate?.(); + setTimeout(() => { + measure(); + }, 300); + }, + ...rest, + }; + + // console.log(rowRendererProps); + + return ; + }} + + ); +}; + +const PREFIX = "InfiniteList"; +const classes = { + root: `${PREFIX}-timeline`, + listSubheader: `${PREFIX}-listSubheader`, + listItemUList: `${PREFIX}-listItemUList`, +}; + +const StyledMasonry = styled(Masonry)(({ theme }) => ({ + [`&.${classes.root}`]: { + // padding: 0, + // paddingTop: 20, + // width: "100%", + }, + + [`& .${classes.listSubheader}`]: {}, + + [`& .${classes.listItemUList}`]: {}, +})); + +const cellCache = new CellMeasurerCache({ + minWidth: 200, + defaultHeight: 300, +}); + +export type InfiniteMasonryProps = MasonryProps & + InfiniteListBaseProps & { + CellRenderer: CellRenderer; + columnCount: number; + }; + +const InfiniteMasonryForwardRef: React.ForwardRefRenderFunction< + any, + InfiniteMasonryProps +> = ( + { columnCount, items, getItem, cellRenderer, CellRenderer, ...props }, + ref, +) => { + const positionerCache = React.useMemo( + () => + createMasonryCellPositioner({ + cellMeasurerCache: cellCache, + columnCount, + columnWidth: 200, + spacer: 2, + }), + [columnCount], + ); + + return ( + { + props.onCellsRendered?.({ startIndex, stopIndex }); + }} + cellRenderer={({ key, ...rest }) => { + const item = getItem(items, rest.index); + const isLast = items.length === rest.index + 1; + return ( + + ); + }} + /> + ); +}; + +export const InfiniteMasonry = React.forwardRef(InfiniteMasonryForwardRef); diff --git a/packages/@liexp/ui/src/containers/list/InfiniteListBox/types.ts b/packages/@liexp/ui/src/containers/list/InfiniteListBox/types.ts new file mode 100644 index 0000000000..6666b980f9 --- /dev/null +++ b/packages/@liexp/ui/src/containers/list/InfiniteListBox/types.ts @@ -0,0 +1,6 @@ +export interface InfiniteListBaseProps { + width: number; + height: number; + items: any[]; + getItem: (data: any[], index: number) => any; +} diff --git a/packages/@liexp/ui/src/containers/list/InfiniteMediaListBox.tsx b/packages/@liexp/ui/src/containers/list/InfiniteMediaListBox.tsx new file mode 100644 index 0000000000..5a8254136c --- /dev/null +++ b/packages/@liexp/ui/src/containers/list/InfiniteMediaListBox.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import MediaElement from "../../components/Media/MediaElement"; +import { + InfiniteListBox, + type InfiniteListBoxProps, + type ListType, +} from "./InfiniteListBox/InfiniteListBox"; +import { type CellRendererProps } from "./InfiniteListBox/InfiniteMasonry"; + +export const InfiniteMediaListBox: React.FC< + Partial, "useListQuery">> +> = ({ listProps, ...props }) => { + return ( + Q.Media.list as any, + listProps: { + type: "masonry", + getItem: (data: any[], index: number) => { + return data[index]; + }, + // eslint-disable-next-line react/display-name + CellRenderer: React.forwardRef( + ({ item, measure, index, style, ...others }, ref) => { + // console.log("row render", style); + + return ( +

+ +
+ ); + }, + ), + ...(listProps as any), + }, + ...props, + }} + /> + ); +}; diff --git a/packages/@liexp/ui/src/templates/MediaSearchTemplate.tsx b/packages/@liexp/ui/src/templates/MediaSearchTemplate.tsx index 55998ee34b..9af711f1f0 100644 --- a/packages/@liexp/ui/src/templates/MediaSearchTemplate.tsx +++ b/packages/@liexp/ui/src/templates/MediaSearchTemplate.tsx @@ -8,8 +8,8 @@ import SearchEventInput, { import { Box, Container } from "../components/mui/index.js"; import ActorsBox from "../containers/ActorsBox.js"; import { GroupsBox } from "../containers/GroupsBox.js"; -import { MediaBox } from "../containers/MediaBox.js"; import { PageContentBox } from "../containers/PageContentBox.js"; +import { InfiniteMediaListBox } from "../containers/list/InfiniteMediaListBox.js"; export interface MediaSearchTemplateProps { filter: SearchFilter; @@ -97,12 +97,7 @@ const MediaSearchTemplate: React.FC = ({ - + ); diff --git a/services/storybook/src/stories/containers/List/InfiniteListBox.stories.tsx b/services/storybook/src/stories/containers/List/InfiniteListBox.stories.tsx new file mode 100644 index 0000000000..da92075483 --- /dev/null +++ b/services/storybook/src/stories/containers/List/InfiniteListBox.stories.tsx @@ -0,0 +1,122 @@ +import MediaElement from "@liexp/ui/lib/components/Media/MediaElement"; +import { ActorListItem } from "@liexp/ui/lib/components/lists/ActorList"; +import { AreaListItem } from "@liexp/ui/lib/components/lists/AreaList"; +import { + InfiniteListBox, + type InfiniteListBoxProps, + type ListType, +} from "@liexp/ui/lib/containers/list/InfiniteListBox/InfiniteListBox"; +import { type CellRendererProps } from "@liexp/ui/lib/containers/list/InfiniteListBox/InfiniteMasonry"; +import { type Meta, type StoryFn } from "@storybook/react"; +import * as React from "react"; + +const meta: Meta = { + title: "Containers/List/InfiniteListBox", + component: InfiniteListBox, +}; + +export default meta; + +const Template: StoryFn> = (props) => { + return ( +
+ +
+ ); +}; + +const InfiniteMediaListBoxExample = Template.bind({}); + +const args: InfiniteListBoxProps<"masonry"> = { + useListQuery: (Q) => Q.Media.list as any, + listProps: { + type: "masonry", + getItem: (data: any[], index: number) => { + return data[index]; + }, + // eslint-disable-next-line react/display-name + CellRenderer: React.forwardRef( + ({ item, measure, index, style, ...others }, ref) => { + // console.log("row render", style); + + return ( +
+ +
+ ); + }, + ), + }, +}; + +InfiniteMediaListBoxExample.args = args; + +const InfiniteAreaListBoxExample = Template.bind({}); +const infiniteAreaListBoxExampleArgs: InfiniteListBoxProps<"masonry"> = { + useListQuery: (Q) => Q.Area.list as any, + listProps: { + type: "masonry", + getItem: (data: any[], index: number) => { + return data[index]; + }, + // eslint-disable-next-line react/display-name + CellRenderer: React.forwardRef( + ({ item, measure, index, style, ...others }, ref) => { + // console.log("row render", others); + + React.useEffect(() => { + measure(); + }, []); + return ( +
+ +
+ ); + }, + ), + }, +}; +InfiniteAreaListBoxExample.args = infiniteAreaListBoxExampleArgs; + +const InfiniteActorListBoxExample = Template.bind({}); +const infiniteActorListBoxArgs: InfiniteListBoxProps<"masonry"> = { + useListQuery: (Q) => Q.Actor.list as any, + listProps: { + type: "masonry", + getItem: (data: any, index: any) => { + return data[index]; + }, + // eslint-disable-next-line react/display-name + CellRenderer: React.forwardRef( + ({ item, measure, index, style, ...others }, ref) => { + // console.log("row render", others); + + return ( +
+ {}} + onLoad={measure} + /> +
+ ); + }, + ), + }, +}; + +InfiniteActorListBoxExample.args = infiniteActorListBoxArgs; + +export { + InfiniteActorListBoxExample, + InfiniteAreaListBoxExample, + InfiniteMediaListBoxExample, +};