From dd1711b76e4433ea41f449a8eeff3f196085224f Mon Sep 17 00:00:00 2001 From: Andres Galindo Date: Mon, 7 Apr 2025 15:18:41 -0700 Subject: [PATCH 1/6] Add error handling on load instance (#3359) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/shell/components/load-instance/index.js | 21 +++++++++++++-------- src/shell/store/instance.js | 1 + 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/shell/components/load-instance/index.js b/src/shell/components/load-instance/index.js index 48720282b2..f6ac841280 100644 --- a/src/shell/components/load-instance/index.js +++ b/src/shell/components/load-instance/index.js @@ -46,14 +46,19 @@ export default connect((state) => { return; } - props.dispatch(fetchInstance()).then((res) => { - if (res.status !== 200) { - setError("You do not have permission to access this instance"); - } else { - document.title = `Manager - ${res.data?.name} - Zesty`; - CONFIG.URL_PREVIEW_FULL = `${CONFIG.URL_PREVIEW_PROTOCOL}${res.data?.randomHashID}${CONFIG.URL_PREVIEW}`; - } - }); + props + .dispatch(fetchInstance()) + .then((res) => { + if (res.status !== 200) { + setError("You do not have permission to access this instance"); + } else { + document.title = `Manager - ${res.data?.name} - Zesty`; + CONFIG.URL_PREVIEW_FULL = `${CONFIG.URL_PREVIEW_PROTOCOL}${res.data?.randomHashID}${CONFIG.URL_PREVIEW}`; + } + }) + .catch(() => { + setError("Failed to load instance"); + }); Promise.all([ props.dispatch(fetchUser(props.user.ZUID)), diff --git a/src/shell/store/instance.js b/src/shell/store/instance.js index bc7b662ff1..e00fb2aee0 100644 --- a/src/shell/store/instance.js +++ b/src/shell/store/instance.js @@ -46,6 +46,7 @@ export function fetchInstance() { }) .catch((err) => { console.error("fetchInstance failed:", err); + return Promise.reject(err); }); }; } From 3bd2bdd6c33228a7ca8ee0ed036dd6766df2d03f Mon Sep 17 00:00:00 2001 From: geodem Date: Wed, 30 Apr 2025 22:22:53 +0800 Subject: [PATCH 2/6] INIT --- .../src/app/views/ItemList/ItemListTable.tsx | 21 +- .../src/app/views/ItemList/SkeletonLoader.tsx | 434 ++++++++++++++++++ .../src/app/views/ItemList/index.tsx | 78 ++-- 3 files changed, 491 insertions(+), 42 deletions(-) create mode 100644 src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index 74d480732a..261ff5868c 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -1,23 +1,10 @@ import { useHistory, useParams as useRouterParams } from "react-router"; -import { - Box, - CircularProgress, - Chip, - Typography, - Link, - Checkbox, - Stack, -} from "@mui/material"; -import { ImageRounded } from "@mui/icons-material"; -import { useGetContentModelFieldsQuery } from "../../../../../../shell/services/instance"; +import { Box, Typography, Link, Checkbox } from "@mui/material"; import { DataGridPro, GridRenderCellParams, - GRID_CHECKBOX_SELECTION_COL_DEF, useGridApiRef, GridInitialState, - GridComparatorFn, - GridPinnedColumns, } from "@mui/x-data-grid-pro"; import { memo, @@ -41,12 +28,12 @@ import { currencies } from "../../../../../../shell/components/FieldTypeCurrency import { Currency } from "../../../../../../shell/components/FieldTypeCurrency/currencies"; import { ImageCell } from "./TableCells/ImageCell"; import { SingleRelationshipCell } from "./TableCells/SingleRelationshipCell"; -import { useParams } from "../../../../../../shell/hooks/useParams"; import { TableSortContext } from "./TableSortProvider"; type ItemListTableProps = { loading: boolean; rows: ContentItem[]; + fields: ContentModelField[]; noRowsOverlay: () => JSX.Element; }; @@ -80,6 +67,7 @@ const METADATA_COLUMNS = [ width: 240, filterable: true, renderCell: (params: GridRenderCellParams) => , + type: "createdBy", }, { field: "createdOn", @@ -265,7 +253,7 @@ const fieldTypeColumnConfigMap = { } as const; export const ItemListTable = memo( - ({ loading, rows, noRowsOverlay }: ItemListTableProps) => { + ({ loading, rows, fields, noRowsOverlay }: ItemListTableProps) => { const { modelZUID } = useRouterParams<{ modelZUID: string }>(); const apiRef = useGridApiRef(); const [initialState, setInitialState] = useState(); @@ -316,6 +304,7 @@ export const ItemListTable = memo( width: 64, sortable: true, filterable: false, + type: "version", renderCell: (params: GridRenderCellParams) => ( ), diff --git a/src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx b/src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx new file mode 100644 index 0000000000..ef8d008c0e --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx @@ -0,0 +1,434 @@ +import { Skeleton, Box, Typography } from "@mui/material"; +import ImageRoundedIcon from "@mui/icons-material/ImageRounded"; +import { useLocation } from "react-router"; +import { GridColDef } from "@mui/x-data-grid-pro"; + +export const FIELD_SKELETON_MAP: Record = { + images: ( + + + + + ), + one_to_one: ( + + + + ), + one_to_many: ( + + + + + + ), + version: ( + + + + ), + link: , + internal_link: , + color: ( + + + + + ), + yes_no: ( + + ), + sort: ( + + ), + number: ( + + + + ), + dropdown: , + createdBy: ( + + + + + ), + header: ( + + + + ), + default: ( + + + + ), +}; + +export const ItemListFiltersSkeleton = () => { + return ( + + + + + + + + ); +}; + +export const getSkeletonRows = (rows: number) => { + return [...new Array(rows)].map((_, i) => ({ + id: i, + })); +}; + +export const getSkeletonColumns = (columns: any[]) => { + if (!columns) return []; + return [ + { + field: "id", + width: 50, + maxWidth: 50, + minWidth: 50, + renderHeader: () => ( + + ), + renderCell: () => ( + + ), + }, + ...columns.map((column, index) => { + return { + field: `col-${index + 1}`, + width: column?.width, + sortable: false, + filterable: false, + resizable: false, + + renderHeader: () => ( + + + + ), + renderCell: () => ( + + {FIELD_SKELETON_MAP[column.type] || FIELD_SKELETON_MAP.default} + + ), + }; + }), + ]; +}; + +export const ContentHeaderSkeleton = () => { + return ( + <> + + + + + / + + {[...new Array(2)].map((_, i) => ( + <> + + + + + + / + + + ))} + + + + + + + + + + + + ); +}; + +export const LoadingOverlay = ({ + columns, + rowCount, + dimentions, +}: { + columns: GridColDef[]; + rowCount: number; + dimentions: any; +}) => { + return ( + <> + {[...new Array(rowCount)].map((_, i) => ( + + + + + {columns?.map((column: any) => { + const dimentionValues = dimentions?.[column?.field]; + console.debug("dimentionValues: ", dimentionValues); + + return ( + + {FIELD_SKELETON_MAP[column?.type] || FIELD_SKELETON_MAP.default} + + ); + })} + + ))} + + ); +}; + +export const HeaderLabel = ({ width }: { width: number }) => ( + + + +); diff --git a/src/apps/content-editor/src/app/views/ItemList/index.tsx b/src/apps/content-editor/src/app/views/ItemList/index.tsx index cc262c143c..7cfc6af0fc 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -38,6 +38,10 @@ import { fetchItems } from "../../../../../../shell/store/content"; import { TableSortContext } from "./TableSortProvider"; import { fetchFields } from "../../../../../../shell/store/fields"; import { debounce } from "lodash"; +import { + ContentHeaderSkeleton, + ItemListFiltersSkeleton, +} from "./SkeletonLoader"; const dateFormatter = new Intl.DateTimeFormat("en-US", { year: "numeric", @@ -80,7 +84,9 @@ export const ItemList = () => { useGetContentModelQuery(modelZUID); const { data: fields, isFetching: isFieldsFetching } = useGetContentModelFieldsQuery(modelZUID); - const { data: languages } = useGetLangsQuery({}); + const { data: languages, isLoading: isLanguagesLoading } = useGetLangsQuery( + {} + ); const activeLangId = languages?.find((lang) => lang.code === activeLanguageCode)?.ID || 1; const allItems = useSelector((state: AppState) => state.content); @@ -94,7 +100,11 @@ export const ItemList = () => { const allFields = useSelector((state: AppState) => state.fields); const user = useSelector((state: AppState) => state.user); - const { data: users, isFetching: isUsersFetching } = useGetUsersQuery(); + const { + data: users, + isFetching: isUsersFetching, + isLoading: isUsersLoading, + } = useGetUsersQuery(); const [processedItems, setProcessedItems] = useState([]); const [isModelItemsFetching, setIsModelItemsFetching] = useState(true); const [sortModel] = useContext(TableSortContext); @@ -112,6 +122,7 @@ export const ItemList = () => { }; }, [params]); const userFilter = params.get("user"); + const usersAndLanguagesLoaded = !isUsersLoading && !isLanguagesLoading; const fieldMap = useMemo(() => { if (!fields?.length) return new Map(); @@ -274,8 +285,8 @@ export const ItemList = () => { }); }, [items, fields, users, isFieldsFetching, isUsersFetching, fieldMap]); - /* - We debounce the processed items compute since certain fields can call for items to be fetched if they are not in memory + /* + We debounce the processed items compute since certain fields can call for items to be fetched if they are not in memory and we don't want to trigger a heavy computation on every item fetched in parallel. This reduces initial load time. */ const debouncedCompute = useMemo(() => { @@ -583,26 +594,32 @@ export const ItemList = () => { ) : ( <> - - - - {model?.label} - - - + {!usersAndLanguagesLoaded ? ( + + ) : ( + <> + + + + {model?.label} + + + + + )} )} @@ -619,10 +636,19 @@ export const ItemList = () => { ) : ( <> - + {isModelItemsFetching ? ( + + ) : ( + + )} { if (search && !isModelItemsFetching) { From beb2ad63a20add4333f0bb74476954fc646a5393 Mon Sep 17 00:00:00 2001 From: geodem Date: Fri, 2 May 2025 00:39:09 +0800 Subject: [PATCH 3/6] fix column with issue when refreshed --- .../src/app/views/ItemList/ItemListTable.tsx | 3 +- .../src/app/views/ItemList/SkeletonLoader.tsx | 502 ++++++++---------- .../src/app/views/ItemList/index.tsx | 18 +- 3 files changed, 230 insertions(+), 293 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index 261ff5868c..eb7a31ccf0 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -304,10 +304,10 @@ export const ItemListTable = memo( width: 64, sortable: true, filterable: false, - type: "version", renderCell: (params: GridRenderCellParams) => ( ), + type: "version", }, ]; if (fields) { @@ -336,6 +336,7 @@ export const ItemListTable = memo( field?.settings?.options?.[1] !== "Yes" && { width: 280, }), + type: field.datatype, })), ]; } diff --git a/src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx b/src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx index ef8d008c0e..50f819e83f 100644 --- a/src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx @@ -1,88 +1,105 @@ -import { Skeleton, Box, Typography } from "@mui/material"; +import { + useGridApiContext, + gridColumnsTotalWidthSelector, + gridColumnPositionsSelector, + gridDensityRowHeightSelector, +} from "@mui/x-data-grid-pro"; +import { ReactNode, useMemo } from "react"; +import { Box, Skeleton } from "@mui/material"; import ImageRoundedIcon from "@mui/icons-material/ImageRounded"; -import { useLocation } from "react-router"; -import { GridColDef } from "@mui/x-data-grid-pro"; +import Typography from "@mui/material/Typography"; -export const FIELD_SKELETON_MAP: Record = { - images: ( +const CellWrapper = ({ + align = "left", + direction = "row", + gap = 0, + children, +}: { + align?: "left" | "center" | "right"; + direction?: "row" | "column"; + gap?: number | string; + children: ReactNode; +}) => { + const justify = direction === "column" ? "center" : align; + const alignment = direction === "column" ? align : "center"; + return ( - + ); +}; + +export const FIELD_SKELETON_MAP: Record = { + checkboxSelection: ( + + + + ), + images: ( + + - - + sx={{ + position: "relative", + }} + > + + + + ), one_to_one: ( - + - + ), one_to_many: ( - + - + ), version: ( - + - + + + ), + link: ( + + + + ), + internal_link: ( + + + ), - link: , - internal_link: , color: ( - + = { }} /> - + ), yes_no: ( - + + + ), sort: ( - + + + ), number: ( - + - + + ), + dropdown: ( + + + ), - dropdown: , createdBy: ( - - - - + + + + ), header: ( = { display="flex" alignItems="center" justifyContent="flex-start" + position="absolute" > ), default: ( - + - + ), }; -export const ItemListFiltersSkeleton = () => { - return ( - - - - - - - - ); -}; +export const SkeletonLoadingOverlay = () => { + const apiRef = useGridApiContext(); -export const getSkeletonRows = (rows: number) => { - return [...new Array(rows)].map((_, i) => ({ - id: i, - })); -}; + const dimensions = apiRef.current?.getRootDimensions(); + const viewportHeight = dimensions?.viewportInnerSize.height ?? 0; -export const getSkeletonColumns = (columns: any[]) => { - if (!columns) return []; - return [ - { - field: "id", - width: 50, - maxWidth: 50, - minWidth: 50, - renderHeader: () => ( - - ), - renderCell: () => ( - - ), - }, - ...columns.map((column, index) => { - return { - field: `col-${index + 1}`, - width: column?.width, - sortable: false, - filterable: false, - resizable: false, + const rowHeight = gridDensityRowHeightSelector(apiRef); + const skeletonRowsCount = Math.ceil(viewportHeight / rowHeight); - renderHeader: () => ( - - - - ), - renderCell: () => ( + const totalWidth = gridColumnsTotalWidthSelector(apiRef); + const positions = gridColumnPositionsSelector(apiRef); + const inViewportCount = useMemo( + () => positions.filter((value) => value <= totalWidth).length, + [totalWidth, positions] + ); + const columns = apiRef.current.getVisibleColumns().slice(0, inViewportCount); + + const children = useMemo(() => { + const array: ReactNode[] = []; + + for (let i = 0; i < skeletonRowsCount; i += 1) { + for (const column of columns) { + array.push( {FIELD_SKELETON_MAP[column.type] || FIELD_SKELETON_MAP.default} - ), - }; - }), - ]; + ); + } + array.push(); + } + return array; + }, [skeletonRowsCount, columns]); + + return ( + `${computedWidth}px`) + .join(" ")} 1fr`, + gridAutoRows: `${rowHeight}px`, + }} + > + {children} + + ); }; -export const ContentHeaderSkeleton = () => { +export const SkeletonHeaderLabel = () => ( + + + +); + +export const SkeletonContentHeader = () => { return ( <> - + { flexDirection="row" justifyContent="flex-start" alignItems="center" - mt="2px" > { ); }; -export const LoadingOverlay = ({ - columns, - rowCount, - dimentions, -}: { - columns: GridColDef[]; - rowCount: number; - dimentions: any; -}) => { +export const SkeletonItemListFilters = () => { return ( - <> - {[...new Array(rowCount)].map((_, i) => ( - - - - - {columns?.map((column: any) => { - const dimentionValues = dimentions?.[column?.field]; - console.debug("dimentionValues: ", dimentionValues); - - return ( - - {FIELD_SKELETON_MAP[column?.type] || FIELD_SKELETON_MAP.default} - - ); - })} - - ))} - + + + + + + + ); }; - -export const HeaderLabel = ({ width }: { width: number }) => ( - - - -); diff --git a/src/apps/content-editor/src/app/views/ItemList/index.tsx b/src/apps/content-editor/src/app/views/ItemList/index.tsx index 7cfc6af0fc..4133726858 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -6,7 +6,6 @@ import { useGetContentModelQuery, useGetLangsQuery, } from "../../../../../../shell/services/instance"; -import { theme } from "@zesty-io/material"; import { ItemListEmpty } from "./ItemListEmpty"; import { ItemListActions } from "./ItemListActions"; import { @@ -19,7 +18,6 @@ import { } from "react"; import { SearchRounded, RestartAltRounded } from "@mui/icons-material"; import noSearchResults from "../../../../../../../public/images/noSearchResults.svg"; -import { ItemListFilters } from "./ItemListFilters"; import { useParams } from "../../../../../../shell/hooks/useParams"; import { useDispatch, useSelector } from "react-redux"; import { AppState } from "../../../../../../shell/store/types"; @@ -38,10 +36,7 @@ import { fetchItems } from "../../../../../../shell/store/content"; import { TableSortContext } from "./TableSortProvider"; import { fetchFields } from "../../../../../../shell/store/fields"; import { debounce } from "lodash"; -import { - ContentHeaderSkeleton, - ItemListFiltersSkeleton, -} from "./SkeletonLoader"; +import { SkeletonContentHeader } from "./SkeletonLoader"; const dateFormatter = new Intl.DateTimeFormat("en-US", { year: "numeric", @@ -122,7 +117,6 @@ export const ItemList = () => { }; }, [params]); const userFilter = params.get("user"); - const usersAndLanguagesLoaded = !isUsersLoading && !isLanguagesLoading; const fieldMap = useMemo(() => { if (!fields?.length) return new Map(); @@ -594,8 +588,8 @@ export const ItemList = () => { ) : ( <> - {!usersAndLanguagesLoaded ? ( - + {isUsersLoading || isLanguagesLoading || isModelFetching ? ( + ) : ( <> @@ -636,11 +630,6 @@ export const ItemList = () => { ) : ( <> - {isModelItemsFetching ? ( - - ) : ( - - )} { isModelFetching } rows={sortedAndFilteredItems} + fields={fields} noRowsOverlay={() => { if (search && !isModelItemsFetching) { return ( From c08f2b8d63bd49b7742e9e8b18aa93c6473c1941 Mon Sep 17 00:00:00 2001 From: geodem Date: Mon, 12 May 2025 17:06:29 +0800 Subject: [PATCH 4/6] Fixed conflicts --- .../src/app/views/ItemList/ItemListTable.tsx | 78 ++++++++++++------- .../{SkeletonLoader.tsx => Loader.tsx} | 66 +++++++++++++--- .../src/app/views/ItemList/index.tsx | 71 ++++++++--------- 3 files changed, 137 insertions(+), 78 deletions(-) rename src/apps/content-editor/src/app/views/ItemList/{SkeletonLoader.tsx => Loader.tsx} (84%) diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index eb7a31ccf0..4b200b6cb2 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -13,9 +13,13 @@ import { useMemo, useState, useContext, + useEffect, } from "react"; import AutoSizer, { Size } from "react-virtualized-auto-sizer"; -import { ContentItem } from "../../../../../../shell/services/types"; +import { + ContentItem, + ContentModelField, +} from "../../../../../../shell/services/types"; import { useStagedChanges } from "./StagedChangesContext"; import { OneToManyCell } from "./TableCells/OneToManyCell"; import { UserCell } from "./TableCells/UserCell"; @@ -29,6 +33,12 @@ import { Currency } from "../../../../../../shell/components/FieldTypeCurrency/c import { ImageCell } from "./TableCells/ImageCell"; import { SingleRelationshipCell } from "./TableCells/SingleRelationshipCell"; import { TableSortContext } from "./TableSortProvider"; +import { + FIELD_SKELETON_MAP, + gridLoadingStyles, + SkeletonLoadingOverlay, +} from "./Loader"; +import { Skeleton } from "@mui/material"; type ItemListTableProps = { loading: boolean; @@ -67,7 +77,6 @@ const METADATA_COLUMNS = [ width: 240, filterable: true, renderCell: (params: GridRenderCellParams) => , - type: "createdBy", }, { field: "createdOn", @@ -263,8 +272,6 @@ export const ItemListTable = memo( const [sortModel, setSortModel] = useContext(TableSortContext); const [pinnedColumns, setPinnedColumns] = useState({}); - const { data: fields } = useGetContentModelFieldsQuery(modelZUID); - const saveSnapshot = useCallback(() => { if (apiRef?.current?.exportState && localStorage) { const currentState = apiRef.current.exportState(); @@ -297,6 +304,12 @@ export const ItemListTable = memo( }, [saveSnapshot, fields, modelZUID]); const columns = useMemo(() => { + const stateFromLocalStorage = localStorage?.getItem( + `${modelZUID}-dataGridState` + ); + const colDimensions = stateFromLocalStorage + ? JSON.parse(stateFromLocalStorage)?.columns?.dimensions + : null; let result: any[] = [ { field: "version", @@ -307,7 +320,6 @@ export const ItemListTable = memo( renderCell: (params: GridRenderCellParams) => ( ), - type: "version", }, ]; if (fields) { @@ -336,25 +348,26 @@ export const ItemListTable = memo( field?.settings?.options?.[1] !== "Yes" && { width: 280, }), - type: field.datatype, })), ]; } - return [...result, ...METADATA_COLUMNS]; - }, [fields]); - - if (!initialState) { - return ( - - - - ); - } + result = [ + ...result, + ...METADATA_COLUMNS?.map((column) => ({ + ...column, + flex: loading && !fields ? 1 : 0, + })), + ]; + return result.map((column) => { + const { headerName, ...other } = column; + return { + ...other, + width: colDimensions?.[column.field]?.width || column.width, + renderHeader: () => + loading ? FIELD_SKELETON_MAP.header : headerName, + }; + }); + }, [fields, loading]); return ( @@ -362,7 +375,7 @@ export const ItemListTable = memo( ( - - ), + baseCheckbox: (props: any) => + loading ? ( + + ) : ( + + ), + + loadingOverlay: () => , }} slotProps={{ baseTooltip: { @@ -453,6 +473,7 @@ export const ItemListTable = memo( params.row?.meta?.version && !(stagedChanges && Object.keys(stagedChanges)?.length) } + onColumnWidthChange={saveSnapshot} sx={{ backgroundColor: "common.white", ".MuiDataGrid-row": { @@ -495,6 +516,7 @@ export const ItemListTable = memo( "& [data-cy='NoResults']": { pointerEvents: "all", }, + ...(loading ? gridLoadingStyles : {}), }} /> )} diff --git a/src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx b/src/apps/content-editor/src/app/views/ItemList/Loader.tsx similarity index 84% rename from src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx rename to src/apps/content-editor/src/app/views/ItemList/Loader.tsx index 50f819e83f..c4a08a6a17 100644 --- a/src/apps/content-editor/src/app/views/ItemList/SkeletonLoader.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/Loader.tsx @@ -2,12 +2,25 @@ import { useGridApiContext, gridColumnsTotalWidthSelector, gridColumnPositionsSelector, - gridDensityRowHeightSelector, } from "@mui/x-data-grid-pro"; -import { ReactNode, useMemo } from "react"; +import { Fragment, ReactNode, useMemo } from "react"; import { Box, Skeleton } from "@mui/material"; import ImageRoundedIcon from "@mui/icons-material/ImageRounded"; import Typography from "@mui/material/Typography"; +import { ContentModelField } from "../../../../../../shell/services/types"; + +export const gridLoadingStyles = { + pointerEvents: "none", + "& .MuiDataGrid-virtualScroller": { + overflow: "hidden", + }, + "& .MuiDataGrid-columnHeader .MuiDataGrid-iconButtonContainer": { + visibility: "hidden!important", + }, + "& .MuiDataGrid-columnSeparator": { + visibility: "hidden!important", + }, +}; const CellWrapper = ({ align = "left", @@ -165,8 +178,14 @@ export const FIELD_SKELETON_MAP: Record = { alignItems="center" justifyContent="flex-start" position="absolute" + pr={2} > - + ), default: ( @@ -181,14 +200,19 @@ export const FIELD_SKELETON_MAP: Record = { ), }; -export const SkeletonLoadingOverlay = () => { +export const SkeletonLoadingOverlay = ({ + fields, +}: { + fields?: ContentModelField[]; +}) => { const apiRef = useGridApiContext(); const dimensions = apiRef.current?.getRootDimensions(); const viewportHeight = dimensions?.viewportInnerSize.height ?? 0; - - const rowHeight = gridDensityRowHeightSelector(apiRef); - const skeletonRowsCount = Math.ceil(viewportHeight / rowHeight); + const rowHeight = apiRef.current.state.dimensions.rowHeight; + const skeletonRowsCount = Math.ceil( + viewportHeight / apiRef.current.state.dimensions.rowHeight + ); const totalWidth = gridColumnsTotalWidthSelector(apiRef); const positions = gridColumnPositionsSelector(apiRef); @@ -198,6 +222,23 @@ export const SkeletonLoadingOverlay = () => { ); const columns = apiRef.current.getVisibleColumns().slice(0, inViewportCount); + const COL_TYPE_MAP = useMemo(() => { + const typesMap = fields?.reduce((acc, field) => { + acc[field.name] = field.datatype; + return acc; + }, {} as Record); + return { + ...typesMap, + createdBy: "createdBy", + createdOn: "text", + lastSaved: "text", + lastPublished: "text", + zuid: "text", + version: "version", + __check__: "checkboxSelection", + }; + }, [fields]); + const children = useMemo(() => { const array: ReactNode[] = []; @@ -217,7 +258,11 @@ export const SkeletonLoadingOverlay = () => { borderColor: "border", }} > - {FIELD_SKELETON_MAP[column.type] || FIELD_SKELETON_MAP.default} + {FIELD_SKELETON_MAP[ + COL_TYPE_MAP[ + column.field as keyof typeof COL_TYPE_MAP + ] as keyof typeof FIELD_SKELETON_MAP + ] || FIELD_SKELETON_MAP.default} ); } @@ -299,9 +344,8 @@ export const SkeletonContentHeader = () => { / {[...new Array(2)].map((_, i) => ( - <> + { > / - + ))} { useGetContentModelQuery(modelZUID); const { data: fields, isFetching: isFieldsFetching } = useGetContentModelFieldsQuery(modelZUID); - const { data: languages, isLoading: isLanguagesLoading } = useGetLangsQuery( - {} - ); + const { data: languages } = useGetLangsQuery({}); const activeLangId = languages?.find((lang) => lang.code === activeLanguageCode)?.ID || 1; const allItems = useSelector((state: AppState) => state.content); @@ -95,11 +94,7 @@ export const ItemList = () => { const allFields = useSelector((state: AppState) => state.fields); const user = useSelector((state: AppState) => state.user); - const { - data: users, - isFetching: isUsersFetching, - isLoading: isUsersLoading, - } = useGetUsersQuery(); + const { data: users, isFetching: isUsersFetching } = useGetUsersQuery(); const [processedItems, setProcessedItems] = useState([]); const [isModelItemsFetching, setIsModelItemsFetching] = useState(true); const [sortModel] = useContext(TableSortContext); @@ -586,34 +581,30 @@ export const ItemList = () => { {(stagedChanges && Object.keys(stagedChanges)?.length) || selectedItems?.length ? ( + ) : isModelFetching ? ( + ) : ( <> - {isUsersLoading || isLanguagesLoading || isModelFetching ? ( - - ) : ( - <> - - - - {model?.label} - - - - - )} + + + + {model?.label} + + + )} @@ -630,13 +621,15 @@ export const ItemList = () => { ) : ( <> + {isFieldsFetching || isUsersFetching ? ( + + ) : ( + + )} Date: Thu, 15 May 2025 11:27:30 +0800 Subject: [PATCH 5/6] updated loading dependencies --- .../src/app/views/ItemList/ItemListTable.tsx | 28 +- .../src/app/views/ItemList/Loader.tsx | 306 ++++++------------ .../src/app/views/ItemList/index.tsx | 58 ++-- 3 files changed, 146 insertions(+), 246 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index 4b200b6cb2..5d539ea55c 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -13,7 +13,6 @@ import { useMemo, useState, useContext, - useEffect, } from "react"; import AutoSizer, { Size } from "react-virtualized-auto-sizer"; import { @@ -34,9 +33,9 @@ import { ImageCell } from "./TableCells/ImageCell"; import { SingleRelationshipCell } from "./TableCells/SingleRelationshipCell"; import { TableSortContext } from "./TableSortProvider"; import { + DataGridSkeletonCell, FIELD_SKELETON_MAP, gridLoadingStyles, - SkeletonLoadingOverlay, } from "./Loader"; import { Skeleton } from "@mui/material"; @@ -304,12 +303,11 @@ export const ItemListTable = memo( }, [saveSnapshot, fields, modelZUID]); const columns = useMemo(() => { - const stateFromLocalStorage = localStorage?.getItem( - `${modelZUID}-dataGridState` - ); - const colDimensions = stateFromLocalStorage - ? JSON.parse(stateFromLocalStorage)?.columns?.dimensions + const gridState = localStorage?.getItem(`${modelZUID}-dataGridState`); + const colDimensions = gridState + ? JSON.parse(gridState)?.columns?.dimensions : null; + let result: any[] = [ { field: "version", @@ -317,6 +315,7 @@ export const ItemListTable = memo( width: 64, sortable: true, filterable: false, + cellClassName: "version", renderCell: (params: GridRenderCellParams) => ( ), @@ -331,6 +330,7 @@ export const ItemListTable = memo( field: field.name, headerName: field.label, filterable: false, + cellClassName: field?.datatype, valueGetter: (params: any, row: any) => { if (field.datatype === "currency") { return { @@ -355,7 +355,8 @@ export const ItemListTable = memo( ...result, ...METADATA_COLUMNS?.map((column) => ({ ...column, - flex: loading && !fields ? 1 : 0, + cellClassName: column.field, + flex: !fields?.length ? 1 : 0, })), ]; return result.map((column) => { @@ -417,8 +418,7 @@ export const ItemListTable = memo( {...props} /> ), - - loadingOverlay: () => , + skeletonCell: DataGridSkeletonCell, }} slotProps={{ baseTooltip: { @@ -436,6 +436,10 @@ export const ItemListTable = memo( }, }, }, + loadingOverlay: { + variant: "skeleton", + noRowsVariant: "skeleton", + }, }} getRowClassName={(params) => { // if included in staged changes, highlight the row @@ -516,6 +520,10 @@ export const ItemListTable = memo( "& [data-cy='NoResults']": { pointerEvents: "all", }, + "& .MuiDataGrid-row.MuiDataGrid-rowSkeleton": { + borderBottom: "1px solid", + borderColor: "border", + }, ...(loading ? gridLoadingStyles : {}), }} /> diff --git a/src/apps/content-editor/src/app/views/ItemList/Loader.tsx b/src/apps/content-editor/src/app/views/ItemList/Loader.tsx index c4a08a6a17..6beda9f34a 100644 --- a/src/apps/content-editor/src/app/views/ItemList/Loader.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/Loader.tsx @@ -22,97 +22,57 @@ export const gridLoadingStyles = { }, }; -const CellWrapper = ({ - align = "left", - direction = "row", - gap = 0, - children, -}: { - align?: "left" | "center" | "right"; - direction?: "row" | "column"; - gap?: number | string; - children: ReactNode; -}) => { - const justify = direction === "column" ? "center" : align; - const alignment = direction === "column" ? align : "center"; - return ( +export const FIELD_SKELETON_MAP: Record = { + checkboxSelection: , + __check__: , + images: ( - {children} - - ); -}; - -export const FIELD_SKELETON_MAP: Record = { - checkboxSelection: ( - - - - ), - images: ( - - - - - - - ), - one_to_one: ( - - - + variant="rectangular" + sx={{ position: "absolute", top: 0, left: 0 }} + /> + + ), + one_to_one: , one_to_many: ( - + - + ), version: ( - + - - ), - link: ( - - - - ), - internal_link: ( - - - + ), + link: , + internal_link: , color: ( - + = { }} /> - + ), yes_no: ( - - - + ), sort: ( - - - - ), - number: ( - - - - ), - dropdown: ( - - - + ), + number: , + dropdown: , createdBy: ( - + = { sx={{ minWidth: "32px" }} /> - + ), header: ( = { variant="rounded" height="12px" width="calc(100% - 16px)" - sx={{ maxWidth: "180px" }} + sx={{ maxWidth: "200px" }} /> ), default: ( - - - + ), }; -export const SkeletonLoadingOverlay = ({ - fields, -}: { - fields?: ContentModelField[]; -}) => { - const apiRef = useGridApiContext(); - - const dimensions = apiRef.current?.getRootDimensions(); - const viewportHeight = dimensions?.viewportInnerSize.height ?? 0; - const rowHeight = apiRef.current.state.dimensions.rowHeight; - const skeletonRowsCount = Math.ceil( - viewportHeight / apiRef.current.state.dimensions.rowHeight - ); - - const totalWidth = gridColumnsTotalWidthSelector(apiRef); - const positions = gridColumnPositionsSelector(apiRef); - const inViewportCount = useMemo( - () => positions.filter((value) => value <= totalWidth).length, - [totalWidth, positions] - ); - const columns = apiRef.current.getVisibleColumns().slice(0, inViewportCount); - - const COL_TYPE_MAP = useMemo(() => { - const typesMap = fields?.reduce((acc, field) => { - acc[field.name] = field.datatype; - return acc; - }, {} as Record); - return { - ...typesMap, - createdBy: "createdBy", - createdOn: "text", - lastSaved: "text", - lastPublished: "text", - zuid: "text", - version: "version", - __check__: "checkboxSelection", - }; - }, [fields]); - - const children = useMemo(() => { - const array: ReactNode[] = []; - - for (let i = 0; i < skeletonRowsCount; i += 1) { - for (const column of columns) { - array.push( - - {FIELD_SKELETON_MAP[ - COL_TYPE_MAP[ - column.field as keyof typeof COL_TYPE_MAP - ] as keyof typeof FIELD_SKELETON_MAP - ] || FIELD_SKELETON_MAP.default} - - ); - } - array.push(); - } - return array; - }, [skeletonRowsCount, columns]); - - return ( - `${computedWidth}px`) - .join(" ")} 1fr`, - gridAutoRows: `${rowHeight}px`, - }} - > - {children} - - ); -}; - export const SkeletonHeaderLabel = () => ( { columnGap="12px" py={2} > - - - - - + + + + + + + ); +}; + +export const DataGridSkeletonCell = (props: any) => { + const { width, field, align, className, style, empty } = props; + const apiRef = useGridApiContext(); + const columns = apiRef.current.state.columns.lookup; + const dataType = columns[field]?.cellClassName; + const computedWidth = columns[field]?.computedWidth; + const rowHeight = apiRef.current.state.dimensions.rowHeight; + + if (empty) return null; + return ( + + {field === "__check__" + ? FIELD_SKELETON_MAP.checkboxSelection + : FIELD_SKELETON_MAP[dataType as any] || FIELD_SKELETON_MAP.default} ); }; diff --git a/src/apps/content-editor/src/app/views/ItemList/index.tsx b/src/apps/content-editor/src/app/views/ItemList/index.tsx index 8e1ef8a0b8..76dfd43f18 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -4,6 +4,7 @@ import { Box, Button, Typography } from "@mui/material"; import { useGetContentModelFieldsQuery, useGetContentModelQuery, + useGetContentNavItemsQuery, useGetLangsQuery, } from "../../../../../../shell/services/instance"; import { ItemListEmpty } from "./ItemListEmpty"; @@ -80,7 +81,7 @@ export const ItemList = () => { useGetContentModelQuery(modelZUID); const { data: fields, isFetching: isFieldsFetching } = useGetContentModelFieldsQuery(modelZUID); - const { data: languages } = useGetLangsQuery({}); + const { data: languages, isLoading: isLangsLoading } = useGetLangsQuery({}); const activeLangId = languages?.find((lang) => lang.code === activeLanguageCode)?.ID || 1; const allItems = useSelector((state: AppState) => state.content); @@ -576,35 +577,40 @@ export const ItemList = () => { justifyContent: "space-between", alignItems: "start", gap: 4, + minHeight: "106px", }} > {(stagedChanges && Object.keys(stagedChanges)?.length) || selectedItems?.length ? ( - ) : isModelFetching ? ( - ) : ( <> - - - - {model?.label} - - - + {isUsersFetching || isLangsLoading ? ( + + ) : ( + <> + + + + {model?.label} + + + + + )} )} @@ -628,11 +634,9 @@ export const ItemList = () => { )} { if (search && !isModelItemsFetching) { return ( From facb3e13132d8adae0ce1c795e77a047ca78c5aa Mon Sep 17 00:00:00 2001 From: geodem Date: Sat, 24 May 2025 13:14:33 +0800 Subject: [PATCH 6/6] relocated CellGridSkeletonCell component in Shell's foleder --- .../src/app/views/ItemList/ItemListTable.tsx | 7 +- .../src/app/views/ItemList/index.tsx | 4 +- .../components/DataGridSkeletonCell/index.tsx | 204 ++++++++++++++++++ 3 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 src/shell/components/DataGridSkeletonCell/index.tsx diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index e749adae7d..6219f2d32e 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -32,12 +32,9 @@ import { Currency } from "../../../../../../shell/components/FieldTypeCurrency/c import { ImageCell } from "./TableCells/ImageCell"; import { SingleRelationshipCell } from "./TableCells/SingleRelationshipCell"; import { TableSortContext } from "./TableSortProvider"; -import { - DataGridSkeletonCell, - FIELD_SKELETON_MAP, - gridLoadingStyles, -} from "./Loader"; +import { FIELD_SKELETON_MAP, gridLoadingStyles } from "./Loader"; import { Skeleton } from "@mui/material"; +import DataGridSkeletonCell from "../../../../../../shell/components/DataGridSkeletonCell"; type ItemListTableProps = { loading: boolean; diff --git a/src/apps/content-editor/src/app/views/ItemList/index.tsx b/src/apps/content-editor/src/app/views/ItemList/index.tsx index 76dfd43f18..54588fe8d3 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -634,7 +634,9 @@ export const ItemList = () => { )} { diff --git a/src/shell/components/DataGridSkeletonCell/index.tsx b/src/shell/components/DataGridSkeletonCell/index.tsx new file mode 100644 index 0000000000..bd74964085 --- /dev/null +++ b/src/shell/components/DataGridSkeletonCell/index.tsx @@ -0,0 +1,204 @@ +/** + * DataGridSkeletonCell.tsx + * + * This component renders a custom skeleton cell in MUI X DataGridPro based on the + * `cellClassName` defined in column definitions. + * + * ------------------------------------------------------------------------------ + * USAGE GUIDE: + * + * 1. Add `cellClassName` to the column definition + * -------------------------------------------------- + * The `cellClassName` should match a key in `FIELD_SKELETON_MAP`. + * This defines which skeleton component to render for that column. + * + * Example: + * const columns = [ + * { field: "version", cellClassName: "version" }, + * { field: "created_by", cellClassName: "createdBy" }, + * ]; + * + * 2. Register `DataGridSkeletonCell` in the DataGrid `slots` + * -------------------------------------------------------- + * Pass the custom skeleton cell renderer in the `skeletonCell` slot. + * + * Example: + * + * + * 3. Extend `FIELD_SKELETON_MAP` for new skeleton types (optional) + * -------------------------------------------------------------- + * If a new cell type needs a custom skeleton, add it to `FIELD_SKELETON_MAP` + * using the same name as the column's `cellClassName`. + * + * Example: + * FIELD_SKELETON_MAP["my_custom_type"] = ( + * + * ); + * + * Then use in column: + * { field: "custom", cellClassName: "my_custom_type" } + * + * ------------------------------------------------------------------------------ + */ + +import { useGridApiContext } from "@mui/x-data-grid-pro"; +import { Box, Skeleton } from "@mui/material"; +import ImageRoundedIcon from "@mui/icons-material/ImageRounded"; + +// A mapping from cellClassName to its corresponding Skeleton component. +export const FIELD_SKELETON_MAP: Record = { + checkboxSelection: , + __check__: , + images: ( + + + + + ), + one_to_one: , + one_to_many: ( + + + + + + ), + version: ( + + + + + ), + link: , + internal_link: , + color: ( + + + + + ), + yes_no: ( + + ), + sort: ( + + ), + number: , + dropdown: , + createdBy: ( + + + + + ), + header: ( + + + + ), + default: ( + + ), +}; + +/** + * DataGridSkeletonCell + * + * A custom skeleton cell renderer for MUI DataGridPro. + * Dynamically selects a skeleton based on the `cellClassName` of the column. + */ +const DataGridSkeletonCell = (props: any) => { + const { width, field, align, className, style, empty } = props; + const apiRef = useGridApiContext(); + const columns = apiRef.current.state.columns.lookup; + const dataType = columns[field]?.cellClassName; + const computedWidth = columns[field]?.computedWidth; + const rowHeight = apiRef.current.state.dimensions.rowHeight; + + if (empty) return null; + + return ( + + {field === "__check__" + ? FIELD_SKELETON_MAP.checkboxSelection + : FIELD_SKELETON_MAP[dataType as any] || FIELD_SKELETON_MAP.default} + + ); +}; + +export default DataGridSkeletonCell;