Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/short-teeth-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"saleor-dashboard": minor
---

Added expandable rows to the Category list with lazy loading of subcategories and improved nested selection logic.
3 changes: 3 additions & 0 deletions locale/defaultMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -1843,6 +1843,9 @@
"context": "dialog content",
"string": "{counter,plural,one{Are you sure you want to publish this model?} other{Are you sure you want to publish {displayQuantity} models?}}"
},
"8zbcLs": {
"string": "№ of subcategories"
},
"9+iLpf": {
"context": "link text to product type settings",
"string": "Configure in product type settings"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.5.6",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-storybook": "10.2.4",
"eslint-plugin-storybook": "^10.2.7",
"eslint-plugin-unicorn": "^61.0.2",
"eslint-plugin-unused-imports": "^4.3.0",
"front-matter": "^4.0.2",
Expand Down
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,50 @@ import { CategoryFragment } from "@dashboard/graphql";
import { getPrevLocationState } from "@dashboard/hooks/useBackLinkWithState";
import useNavigator from "@dashboard/hooks/useNavigator";
import { PageListProps, SortPage } from "@dashboard/types";
import { Item } from "@glideapps/glide-data-grid";
import { CompactSelection, GridSelection, Item } from "@glideapps/glide-data-grid";
import { ReactNode, useCallback, useMemo } from "react";
import { useIntl } from "react-intl";
import { useLocation } from "react-router";

import { categoryListStaticColumnsAdapter, createGetCellContent } from "./datagrid";
import {
categoryListExpandColumn,
categoryListStaticColumnsAdapter,
createGetCellContent,
} from "./datagrid";
import { messages } from "./messages";

interface CategoryListDatagridProps
extends PageListProps,
Partial<SortPage<CategoryListUrlSortField>> {
categories: CategoryFragment[];
selectedCategoriesIds?: string[];
onSelectCategoriesIds: (ids: number[], clearSelection: () => void) => void;
onSelectedCategoriesIdsChange?: (ids: string[]) => void;
selectionActionButton?: ReactNode | null;
hasRowHover?: boolean;
isCategoryExpanded?: (categoryId: string) => boolean;
onCategoryExpandToggle?: (categoryId: string) => void;
isCategoryChildrenLoading?: (categoryId: string) => boolean;
getCategoryDepth?: (categoryId: string) => number;
}

export const CategoryListDatagrid = ({
sort,
onSort,
categories,
selectedCategoriesIds = [],
disabled,
onSelectCategoriesIds,
onSelectedCategoriesIdsChange,
settings,
onUpdateListSettings,
selectionActionButton = null,
hasRowHover = true,
}: CategoryListDatagridProps) => {
isCategoryExpanded,
onCategoryExpandToggle,
isCategoryChildrenLoading,
getCategoryDepth,
}: CategoryListDatagridProps): JSX.Element => {
const navigate = useNavigator();
const location = useLocation();
const datagridState = useDatagridChangeState();
Expand All @@ -47,6 +63,45 @@ export const CategoryListDatagrid = ({
() => categoryListStaticColumnsAdapter(intl, sort),
[intl, sort],
);
const isSelectionControlled = !!onSelectedCategoriesIdsChange;
const controlledSelection = useMemo<GridSelection>(() => {
const rowIndexByCategoryId = categories.reduce<Record<string, number>>(
(acc, category, index) => {
acc[category.id] = index;

return acc;
},
{},
);
const rows = selectedCategoriesIds.reduce((selection, categoryId) => {
const rowIndex = rowIndexByCategoryId[categoryId];

if (rowIndex === undefined) {
return selection;
}

return selection.add(rowIndex);
}, CompactSelection.empty());

return {
columns: CompactSelection.empty(),
rows,
};
}, [categories, selectedCategoriesIds]);
const handleControlledSelectionChange = useCallback(
(selection: GridSelection | undefined) => {
if (!onSelectedCategoriesIdsChange) {
return;
}

const selectedIds = (selection?.rows.toArray() ?? [])
.map(rowIndex => categories[rowIndex]?.id)
.filter((id): id is string => !!id);

onSelectedCategoriesIdsChange(selectedIds);
},
[categories, onSelectedCategoriesIdsChange],
);
const handleColumnChange = useCallback(
(picked: string[]) => {
if (onUpdateListSettings) {
Expand All @@ -61,21 +116,94 @@ export const CategoryListDatagrid = ({
selectedColumns: settings?.columns ?? [],
onSave: handleColumnChange,
});
const getCellContent = useCallback(createGetCellContent(categories, visibleColumns), [
categories,
visibleColumns,
]);
const shouldShowExpandColumn = Boolean(isCategoryExpanded && onCategoryExpandToggle);
const availableColumns = useMemo(
() => (shouldShowExpandColumn ? [categoryListExpandColumn, ...visibleColumns] : visibleColumns),
[shouldShowExpandColumn, visibleColumns],
);
const getCellContent = useMemo(
() =>
createGetCellContent(categories, availableColumns, {
isCategoryExpanded,
isCategoryChildrenLoading,
getCategoryDepth,
}),
[categories, availableColumns, isCategoryExpanded, isCategoryChildrenLoading, getCategoryDepth],
);
const handleRowAnchor = useCallback(
([, row]: Item) => categoryUrl(categories[row].id),
[categories],
);
const handleHeaderClick = useCallback(
(col: number) => {
if (sort !== undefined && onSort) {
onSort(visibleColumns[col].id as CategoryListUrlSortField);
const columnId = availableColumns[col]?.id;

if (sort === undefined || !onSort || !columnId) {
return;
}

switch (columnId) {
case CategoryListUrlSortField.name:
case CategoryListUrlSortField.productCount:
case CategoryListUrlSortField.subcategoryCount:
onSort(columnId);
break;
}
},
[visibleColumns, onSort, sort],
[availableColumns, onSort, sort],
);
const handleRowAnchor = useCallback(
([, row]: Item) => categoryUrl(categories[row].id),
[categories],
const handleRowClick = useCallback(
([col, row]: Item) => {
const rowData = categories[row];

if (!rowData) {
return;
}

const clickedColumnId = availableColumns[col]?.id;

if (clickedColumnId === "expand") {
const hasSubcategories = (rowData.children?.totalCount ?? 0) > 0;
const isLoading = isCategoryChildrenLoading?.(rowData.id) ?? false;

if (hasSubcategories && !isLoading && onCategoryExpandToggle) {
onCategoryExpandToggle(rowData.id);
}

return;
}

navigate(categoryUrl(rowData.id));
},
[availableColumns, categories, navigate, onCategoryExpandToggle, isCategoryChildrenLoading],
);
const handleColumnMove = useCallback(
(startIndex: number, endIndex: number) => {
if (!shouldShowExpandColumn) {
handlers.onMove(startIndex, endIndex);

return;
}

if (startIndex === 0 || endIndex === 0) {
return;
}

handlers.onMove(startIndex - 1, endIndex - 1);
},
[handlers, shouldShowExpandColumn],
);
const handleColumnResize = useCallback(
(...args: Parameters<typeof handlers.onResize>) => {
const [column] = args;

if (column.id === "expand") {
return;
}

handlers.onResize(...args);
},
[handlers],
);

return (
Expand All @@ -87,22 +215,24 @@ export const CategoryListDatagrid = ({
columnSelect={sort !== undefined ? "single" : undefined}
verticalBorder={false}
rowMarkers="checkbox-visible"
availableColumns={visibleColumns}
availableColumns={availableColumns}
rows={categories?.length ?? 0}
getCellContent={getCellContent}
getCellError={() => false}
emptyText={intl.formatMessage(messages.noData)}
onHeaderClicked={handleHeaderClick}
rowAnchor={handleRowAnchor}
menuItems={() => []}
onRowClick={item => {
navigate(handleRowAnchor(item));
}}
onRowClick={handleRowClick}
actionButtonPosition="right"
selectionActions={() => selectionActionButton}
onColumnResize={handlers.onResize}
onColumnMoved={handlers.onMove}
onColumnResize={handleColumnResize}
onColumnMoved={handleColumnMove}
onRowSelectionChange={onSelectCategoriesIds}
controlledSelection={isSelectionControlled ? controlledSelection : undefined}
onControlledSelectionChange={
isSelectionControlled ? handleControlledSelectionChange : undefined
}
renderColumnPicker={() => (
<ColumnPicker
onToggle={handlers.onToggle}
Expand Down
47 changes: 44 additions & 3 deletions src/categories/components/CategoryListDatagrid/datagrid.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CategoryListUrlSortField } from "@dashboard/categories/urls";
import { readonlyTextCell } from "@dashboard/components/Datagrid/customCells/cells";
import { loadingCell, readonlyTextCell } from "@dashboard/components/Datagrid/customCells/cells";
import { AvailableColumn } from "@dashboard/components/Datagrid/types";
import { CategoryFragment } from "@dashboard/graphql";
import { Sort } from "@dashboard/types";
Expand All @@ -9,6 +9,19 @@ import { IntlShape } from "react-intl";

import { columnsMessages } from "./messages";

interface CreateGetCellContentOptions {
isCategoryExpanded?: (categoryId: string) => boolean;
isCategoryChildrenLoading?: (categoryId: string) => boolean;
getCategoryDepth?: (categoryId: string) => number;
}

export const categoryListExpandColumn: AvailableColumn = {
id: "expand",
title: "",
width: 20,
action: () => true,
};

export const categoryListStaticColumnsAdapter = (
intl: IntlShape,
sort?: Sort<CategoryListUrlSortField>,
Expand All @@ -34,8 +47,19 @@ export const categoryListStaticColumnsAdapter = (
icon: sort ? getColumnSortDirectionIcon(sort, column.id) : undefined,
}));

const getIndentedName = (name: string, depth: number): string =>
`${"\u00A0".repeat(depth * 4)}${name}`;

export const createGetCellContent =
(categories: CategoryFragment[], columns: AvailableColumn[]) =>
(
categories: CategoryFragment[],
columns: AvailableColumn[],
{
isCategoryExpanded,
isCategoryChildrenLoading,
getCategoryDepth,
}: CreateGetCellContentOptions = {},
) =>
([column, row]: Item): GridCell => {
const columnId = columns[column]?.id;
const rowData: CategoryFragment | undefined = categories[row];
Expand All @@ -45,8 +69,25 @@ export const createGetCellContent =
}

switch (columnId) {
case "expand": {
const subcategoriesCount = rowData.children?.totalCount ?? 0;

if (!subcategoriesCount) {
return readonlyTextCell("", false);
}

if (isCategoryChildrenLoading?.(rowData.id)) {
return loadingCell();
}

const isExpanded = isCategoryExpanded?.(rowData.id) ?? false;

return readonlyTextCell(isExpanded ? "v" : ">");
}
case "name":
return readonlyTextCell(rowData?.name ?? "");
const depth = getCategoryDepth?.(rowData.id) ?? 0;

return readonlyTextCell(getIndentedName(rowData.name ?? "", depth));
case "subcategories":
return readonlyTextCell(rowData?.children?.totalCount?.toString() ?? "");
case "products":
Expand Down
Loading
Loading