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 264122a56..6219f2d32 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, @@ -28,7 +15,10 @@ import { useContext, } 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"; @@ -41,12 +31,15 @@ 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"; +import { FIELD_SKELETON_MAP, gridLoadingStyles } from "./Loader"; +import { Skeleton } from "@mui/material"; +import DataGridSkeletonCell from "../../../../../../shell/components/DataGridSkeletonCell"; type ItemListTableProps = { loading: boolean; rows: ContentItem[]; + fields: ContentModelField[]; noRowsOverlay: () => JSX.Element; }; @@ -265,7 +258,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(); @@ -275,8 +268,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 && localStorage) { const currentState = apiRef.current.exportState(); @@ -319,6 +310,11 @@ export const ItemListTable = memo( }, [saveSnapshot, fields, modelZUID]); const columns = useMemo(() => { + const gridState = localStorage?.getItem(`${modelZUID}-dataGridState`); + const colDimensions = gridState + ? JSON.parse(gridState)?.columns?.dimensions + : null; + let result: any[] = [ { field: "version", @@ -326,6 +322,7 @@ export const ItemListTable = memo( width: 64, sortable: true, filterable: false, + cellClassName: "version", renderCell: (params: GridRenderCellParams) => ( ), @@ -340,6 +337,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 { @@ -360,21 +358,24 @@ export const ItemListTable = memo( })), ]; } - return [...result, ...METADATA_COLUMNS]; - }, [fields]); - - if (!initialState) { - return ( - - - - ); - } + result = [ + ...result, + ...METADATA_COLUMNS?.map((column) => ({ + ...column, + cellClassName: column.field, + flex: !fields?.length ? 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 ( @@ -382,7 +383,7 @@ export const ItemListTable = memo( ( - - ), + baseCheckbox: (props: any) => + loading ? ( + + ) : ( + + ), + skeletonCell: DataGridSkeletonCell, }} slotProps={{ baseTooltip: { @@ -436,6 +443,10 @@ export const ItemListTable = memo( }, }, }, + loadingOverlay: { + variant: "skeleton", + noRowsVariant: "skeleton", + }, }} getRowClassName={(params) => { // if included in staged changes, highlight the row @@ -473,6 +484,7 @@ export const ItemListTable = memo( params.row?.meta?.version && !(stagedChanges && Object.keys(stagedChanges)?.length) } + onColumnWidthChange={saveSnapshot} sx={{ backgroundColor: "common.white", ".MuiDataGrid-row": { @@ -515,6 +527,11 @@ 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 new file mode 100644 index 000000000..6beda9f34 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/Loader.tsx @@ -0,0 +1,312 @@ +import { + useGridApiContext, + gridColumnsTotalWidthSelector, + gridColumnPositionsSelector, +} from "@mui/x-data-grid-pro"; +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", + }, +}; + +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: ( + + ), +}; + +export const SkeletonHeaderLabel = () => ( + + + +); + +export const SkeletonContentHeader = () => { + return ( + <> + + + + + / + + {[...new Array(2)].map((_, i) => ( + + + + + + + / + + + ))} + + + + + + + + + + + + ); +}; + +export const SkeletonItemListFilters = () => { + return ( + + + + + + + + ); +}; + +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 cc262c143..54588fe8d 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -4,9 +4,9 @@ import { Box, Button, Typography } from "@mui/material"; import { useGetContentModelFieldsQuery, useGetContentModelQuery, + useGetContentNavItemsQuery, useGetLangsQuery, } from "../../../../../../shell/services/instance"; -import { theme } from "@zesty-io/material"; import { ItemListEmpty } from "./ItemListEmpty"; import { ItemListActions } from "./ItemListActions"; import { @@ -38,6 +38,7 @@ import { fetchItems } from "../../../../../../shell/store/content"; import { TableSortContext } from "./TableSortProvider"; import { fetchFields } from "../../../../../../shell/store/fields"; import { debounce } from "lodash"; +import { SkeletonContentHeader, SkeletonItemListFilters } from "./Loader"; const dateFormatter = new Intl.DateTimeFormat("en-US", { year: "numeric", @@ -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); @@ -274,8 +275,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(() => { @@ -576,6 +577,7 @@ export const ItemList = () => { justifyContent: "space-between", alignItems: "start", gap: 4, + minHeight: "106px", }} > {(stagedChanges && Object.keys(stagedChanges)?.length) || @@ -583,26 +585,32 @@ export const ItemList = () => { ) : ( <> - - - - {model?.label} - - - + {isUsersFetching || isLangsLoading ? ( + + ) : ( + <> + + + + {model?.label} + + + + + )} )} @@ -619,11 +627,18 @@ export const ItemList = () => { ) : ( <> - + {isFieldsFetching || isUsersFetching ? ( + + ) : ( + + )} { if (search && !isModelItemsFetching) { return ( diff --git a/src/shell/components/DataGridSkeletonCell/index.tsx b/src/shell/components/DataGridSkeletonCell/index.tsx new file mode 100644 index 000000000..bd7496408 --- /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;