From 741e2b5a8e8cd1e3a21e1cecac08c3d6fed2e8de Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:17:23 +0000 Subject: [PATCH 01/15] feat(datagrid): add optional controlled row selection API --- src/components/Datagrid/Datagrid.tsx | 32 ++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/Datagrid/Datagrid.tsx b/src/components/Datagrid/Datagrid.tsx index f18deedab5e..a2d25de331c 100644 --- a/src/components/Datagrid/Datagrid.tsx +++ b/src/components/Datagrid/Datagrid.tsx @@ -110,6 +110,8 @@ interface DatagridProps { navigatorOpts?: NavigatorOpts; showTopBorder?: boolean; themeOverride?: Partial; + controlledSelection?: GridSelection; + onControlledSelectionChange?: (selection: GridSelection | undefined) => void; } const Datagrid = ({ @@ -147,6 +149,8 @@ const Datagrid = ({ navigatorOpts, showTopBorder = true, themeOverride, + controlledSelection, + onControlledSelectionChange, ...datagridProps }: DatagridProps): ReactElement => { const classes = useStyles({ actionButtonPosition }); @@ -172,7 +176,21 @@ const Datagrid = ({ const fullScreenClasses = useFullScreenStyles(classes); const { isOpen, isAnimationOpenFinished, toggle } = useFullScreenMode(); const { clearTooltip, tooltip, setTooltip } = useTooltipContainer(); - const [selection, setSelection] = useState(); + const [uncontrolledSelection, setUncontrolledSelection] = useState(); + const isSelectionControlled = typeof onControlledSelectionChange === "function"; + const selection = isSelectionControlled ? controlledSelection : uncontrolledSelection; + const setSelectionState = useCallback( + (newSelection: GridSelection | undefined) => { + if (isSelectionControlled) { + onControlledSelectionChange?.(newSelection); + + return; + } + + setUncontrolledSelection(newSelection); + }, + [isSelectionControlled, onControlledSelectionChange], + ); const [areCellsDirty, setCellsDirty] = useState(true); const { rowAnchorRef, setRowAnchorRef, setAnchorPosition } = useRowAnchor({ @@ -192,10 +210,10 @@ const Datagrid = ({ if (onRowSelectionChange && selection) { // Second parameter is callback to clear selection from parent component onRowSelectionChange(Array.from(selection.rows), () => { - setSelection(undefined); + setSelectionState(undefined); }); } - }, [onRowSelectionChange, selection]); + }, [onRowSelectionChange, selection, setSelectionState]); useEffect(() => { if (recentlyAddedColumn && editor.current) { const columnIndex = availableColumns.findIndex(column => column.id === recentlyAddedColumn); @@ -326,11 +344,11 @@ const Datagrid = ({ const handleGridSelectionChange = (gridSelection: GridSelection) => { // In readonly we not allow selecting cells, but we allow selcting column if (readonly && !gridSelection.current) { - setSelection(gridSelection); + setSelectionState(gridSelection); } if (!readonly) { - setSelection(gridSelection); + setSelectionState(gridSelection); } }; const handleGetThemeOverride = useCallback( @@ -389,10 +407,10 @@ const Datagrid = ({ (rows: number[]) => { if (selection?.rows) { onRowsRemoved(rows); - setSelection(undefined); + setSelectionState(undefined); } }, - [selection, onRowsRemoved], + [selection, onRowsRemoved, setSelectionState], ); const handleColumnResize = useCallback( (column: GridColumn, newSize: number) => { From 030ad7bedf0908d3e6ea2ce054fc1f4ef2e2a0e1 Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:19:00 +0000 Subject: [PATCH 02/15] feat(selection): extend row selection hook with partial update helpers --- src/hooks/useRowSelection.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/hooks/useRowSelection.ts b/src/hooks/useRowSelection.ts index 67e2614870c..75b4043081c 100644 --- a/src/hooks/useRowSelection.ts +++ b/src/hooks/useRowSelection.ts @@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from "react"; export interface UseRowSelection { selectedRowIds: string[]; setSelectedRowIds: React.Dispatch>; + setSelectedRows: (ids: string[]) => void; + excludeFromSelected: (ids: string[]) => void; clearRowSelection: () => void; setClearDatagridRowSelectionCallback: (callback: () => void) => void; } @@ -19,6 +21,15 @@ export const useRowSelection = (paginationParams?: Pagination): UseRowSelection setSelectedRowIds([]); }; + + const setSelectedRows = (ids: string[]) => { + setSelectedRowIds(ids); + }; + + const excludeFromSelected = (ids: string[]) => { + setSelectedRowIds(prev => prev.filter(x => !ids.includes(x))); + }; + const setClearDatagridRowSelectionCallback = (callback: () => void) => { clearDatagridRowSelectionCallback.current = callback; }; @@ -31,6 +42,8 @@ export const useRowSelection = (paginationParams?: Pagination): UseRowSelection return { selectedRowIds, setSelectedRowIds, + setSelectedRows, + excludeFromSelected, clearRowSelection, setClearDatagridRowSelectionCallback, }; From ad3d5fe4715efc7ac5eae80e11731873198634bf Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:49:44 +0000 Subject: [PATCH 03/15] feat(categories): add expandable categories with lazy child loading and targeted cache invalidation --- .../CategoryListDatagrid.tsx | 168 ++++++- .../CategoryListDatagrid/datagrid.ts | 47 +- .../CategoryListPage/CategoryListPage.tsx | 68 ++- .../components/CategoryListPage/messages.ts | 4 + src/categories/queries.ts | 18 + .../views/CategoryList/CategoryList.tsx | 450 ++++++++++++++++-- src/graphql/hooks.generated.ts | 48 ++ src/graphql/types.generated.ts | 9 + 8 files changed, 755 insertions(+), 57 deletions(-) diff --git a/src/categories/components/CategoryListDatagrid/CategoryListDatagrid.tsx b/src/categories/components/CategoryListDatagrid/CategoryListDatagrid.tsx index 13318024c57..43e5cd1789d 100644 --- a/src/categories/components/CategoryListDatagrid/CategoryListDatagrid.tsx +++ b/src/categories/components/CategoryListDatagrid/CategoryListDatagrid.tsx @@ -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> { 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(); @@ -47,6 +63,45 @@ export const CategoryListDatagrid = ({ () => categoryListStaticColumnsAdapter(intl, sort), [intl, sort], ); + const isSelectionControlled = !!onSelectedCategoriesIdsChange; + const controlledSelection = useMemo(() => { + const rowIndexByCategoryId = categories.reduce>( + (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) { @@ -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) => { + const [column] = args; + + if (column.id === "expand") { + return; + } + + handlers.onResize(...args); + }, + [handlers], ); return ( @@ -87,7 +215,7 @@ 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} @@ -95,14 +223,16 @@ export const CategoryListDatagrid = ({ 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={() => ( 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, @@ -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]; @@ -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": diff --git a/src/categories/components/CategoryListPage/CategoryListPage.tsx b/src/categories/components/CategoryListPage/CategoryListPage.tsx index 458b01be760..f2a223ce4d0 100644 --- a/src/categories/components/CategoryListPage/CategoryListPage.tsx +++ b/src/categories/components/CategoryListPage/CategoryListPage.tsx @@ -16,8 +16,8 @@ import { CategoryFragment } from "@dashboard/graphql"; import useNavigator from "@dashboard/hooks/useNavigator"; import { sectionNames } from "@dashboard/intl"; import { PageListProps, SearchPageProps, SortPage, TabPageProps } from "@dashboard/types"; -import { Box, Button } from "@saleor/macaw-ui-next"; -import { useState } from "react"; +import { Box, Button, Input, Text } from "@saleor/macaw-ui-next"; +import { ChangeEvent, useCallback, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { CategoryListDatagrid } from "../CategoryListDatagrid"; @@ -35,6 +35,13 @@ interface CategoryTableProps onTabUpdate: (tabName: string) => void; onCategoriesDelete: () => void; onSelectCategoriesIds: (ids: number[], clearSelection: () => void) => void; + onSelectedCategoriesIdsChange?: (ids: string[]) => void; + isCategoryExpanded?: (categoryId: string) => boolean; + onCategoryExpandToggle?: (categoryId: string) => void; + isCategoryChildrenLoading?: (categoryId: string) => boolean; + getCategoryDepth?: (categoryId: string) => number; + subcategoryPageSize: number; + onSubcategoryPageSizeChange: (value: number) => void; } export const CategoryListPage = ({ @@ -51,9 +58,17 @@ export const CategoryListPage = ({ onTabUpdate, hasPresetsChanged, onCategoriesDelete, + onSelectCategoriesIds, + onSelectedCategoriesIdsChange, selectedCategoriesIds, + isCategoryExpanded, + isCategoryChildrenLoading, + onCategoryExpandToggle, + getCategoryDepth, + subcategoryPageSize, + onSubcategoryPageSizeChange, ...listProps -}: CategoryTableProps) => { +}: CategoryTableProps): JSX.Element => { const navigate = useNavigator(); const intl = useIntl(); @@ -67,6 +82,18 @@ export const CategoryListPage = ({ selectedCategoriesIds, ); const extensionCreateButtonItems = getExtensionItemsForOverviewCreate(CATEGORY_OVERVIEW_CREATE); + const handleSubcategoryPageSizeInputChange = useCallback( + (event: ChangeEvent) => { + const nextValue = Number.parseInt(event.target.value, 10); + + if (Number.isNaN(nextValue)) { + return; + } + + onSubcategoryPageSizeChange(nextValue); + }, + [onSubcategoryPageSizeChange], + ); return ( @@ -128,16 +155,41 @@ export const CategoryListPage = ({ onSearchChange={onSearchChange} /> - {selectedCategoriesIds.length > 0 && ( - - - - )} + + + + + + + + {selectedCategoriesIds.length > 0 && ( + + + + )} + diff --git a/src/categories/components/CategoryListPage/messages.ts b/src/categories/components/CategoryListPage/messages.ts index 5087d75a902..b7518fdf2f0 100644 --- a/src/categories/components/CategoryListPage/messages.ts +++ b/src/categories/components/CategoryListPage/messages.ts @@ -18,4 +18,8 @@ export const messages = defineMessages({ defaultMessage: "Delete categories", id: "FiO/W/", }, + subcategoriesPageSizeLabel: { + id: "8zbcLs", + defaultMessage: "№ of subcategories", + }, }); diff --git a/src/categories/queries.ts b/src/categories/queries.ts index 880df6565f3..f391eceabce 100644 --- a/src/categories/queries.ts +++ b/src/categories/queries.ts @@ -62,3 +62,21 @@ export const categoryDetails = gql` } } `; + +export const categoryChildren = gql` + query CategoryChildren($id: ID!, $first: Int!, $after: String) { + category(id: $id) { + id + children(first: $first, after: $after) { + edges { + node { + ...Category + } + } + pageInfo { + ...PageInfo + } + } + } + } +`; diff --git a/src/categories/views/CategoryList/CategoryList.tsx b/src/categories/views/CategoryList/CategoryList.tsx index cb67d2eba72..9328ed4bea0 100644 --- a/src/categories/views/CategoryList/CategoryList.tsx +++ b/src/categories/views/CategoryList/CategoryList.tsx @@ -1,8 +1,13 @@ +import { useApolloClient } from "@apollo/client"; import ActionDialog from "@dashboard/components/ActionDialog"; import DeleteFilterTabDialog from "@dashboard/components/DeleteFilterTabDialog"; import SaveFilterTabDialog from "@dashboard/components/SaveFilterTabDialog"; import { CategoryBulkDeleteMutation, + CategoryChildrenDocument, + CategoryChildrenQuery, + CategoryChildrenQueryVariables, + CategoryFragment, useCategoryBulkDeleteMutation, useRootCategoriesQuery, } from "@dashboard/graphql"; @@ -23,8 +28,9 @@ import { mapEdgesToItems } from "@dashboard/utils/maps"; import { getSortParams } from "@dashboard/utils/sort"; import { Box } from "@saleor/macaw-ui-next"; import isEqual from "lodash/isEqual"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; +import { useLocation } from "react-router"; import { CategoryListPage } from "../../components/CategoryListPage/CategoryListPage"; import { @@ -40,7 +46,28 @@ interface CategoryListProps { params: CategoryListUrlQueryParams; } -const CategoryList = ({ params }: CategoryListProps) => { +const DEFAULT_SUBCATEGORIES_PAGE_SIZE = 50; +const MIN_SUBCATEGORIES_PAGE_SIZE = 1; +const MAX_SUBCATEGORIES_PAGE_SIZE = 200; + +const collectDescendantIds = ( + parentId: string, + getChildren: (parentId: string) => CategoryFragment[], +): string[] => { + const children = getChildren(parentId); + + return children.flatMap(child => [child.id, ...collectDescendantIds(child.id, getChildren)]); +}; + +interface CategoryListRow { + category: CategoryFragment; + depth: number; + parentId: string | null; +} + +const CategoryList = ({ params }: CategoryListProps): JSX.Element => { + const client = useApolloClient(); + const location = useLocation(); const navigate = useNavigator(); const intl = useIntl(); const notify = useNotifier(); @@ -51,6 +78,7 @@ const CategoryList = ({ params }: CategoryListProps) => { setSelectedRowIds, clearRowSelection, setClearDatagridRowSelectionCallback, + excludeFromSelected, } = useRowSelection(params); const { hasPresetsChanged, @@ -94,7 +122,7 @@ const CategoryList = ({ params }: CategoryListProps) => { paginationState, queryString: params, }); - const changeFilterField = (filter: CategoryListUrlFilters) => { + const changeFilterField = (filter: CategoryListUrlFilters): void => { clearRowSelection(); navigate( categoryListUrl({ @@ -104,54 +132,414 @@ const CategoryList = ({ params }: CategoryListProps) => { }), ); }; - const handleCategoryBulkDeleteOnComplete = (data: CategoryBulkDeleteMutation) => { - if (data?.categoryBulkDelete?.errors.length === 0) { - navigate(categoryListUrl(), { replace: true }); - refetch(); + const [expandedIds, setExpandedIds] = useState>(() => new Set()); + const [loadingChildrenIds, setLoadingChildrenIds] = useState>(() => new Set()); + const [loadedChildrenIds, setLoadedChildrenIds] = useState>(() => new Set()); + const [subcategoryPageSize, setSubcategoryPageSize] = useState(DEFAULT_SUBCATEGORIES_PAGE_SIZE); + const deletingCategoryIdsRef = useRef([]); + + const invalidateCache = useCallback(() => { + setLoadedChildrenIds(new Set()); + setExpandedIds(new Set()); + setLoadingChildrenIds(new Set()); + clearRowSelection(); + }, [clearRowSelection]); + + useEffect(() => { + invalidateCache(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname]); + + const handleSubcategoryPageSizeChange = useCallback( + (nextPageSize: number) => { + const normalizedPageSize = Math.min( + MAX_SUBCATEGORIES_PAGE_SIZE, + Math.max(MIN_SUBCATEGORIES_PAGE_SIZE, nextPageSize), + ); + + if (normalizedPageSize === subcategoryPageSize) { + return; + } + + setSubcategoryPageSize(normalizedPageSize); + setLoadedChildrenIds(new Set()); + setExpandedIds(new Set()); + setLoadingChildrenIds(new Set()); clearRowSelection(); - notify({ - status: "success", - text: intl.formatMessage({ - id: "G5ETO0", - defaultMessage: "Categories deleted", - }), + }, + [clearRowSelection, subcategoryPageSize], + ); + + const getCachedChildrenByParentId = useCallback( + (parentId: string): CategoryFragment[] => { + try { + const cached = client.readQuery({ + query: CategoryChildrenDocument, + variables: { + id: parentId, + first: subcategoryPageSize, + after: null, + }, + }); + + return mapEdgesToItems(cached?.category?.children) ?? []; + } catch { + return []; + } + }, + [client, subcategoryPageSize], + ); + + const setCategoryChildrenLoading = useCallback((categoryId: string, loading: boolean) => { + setLoadingChildrenIds(prev => { + const next = new Set(prev); + + if (loading) { + next.add(categoryId); + } else { + next.delete(categoryId); + } + + return next; + }); + }, []); + + const isCategoryChildrenLoading = useCallback( + (categoryId: string) => loadingChildrenIds.has(categoryId), + [loadingChildrenIds], + ); + const getSelectedWithLoadedDescendants = useCallback( + (ids: string[]) => { + const selectedWithDescendants = new Set(ids); + + ids.forEach(id => { + collectDescendantIds(id, getCachedChildrenByParentId).forEach(descendantId => { + selectedWithDescendants.add(descendantId); + }); }); - } - }; - const handleSetSelectedCategoryIds = useCallback( - (rows: number[], clearSelection: () => void) => { - if (!categories) { + + return Array.from(selectedWithDescendants); + }, + [getCachedChildrenByParentId], + ); + const removeDescendantsFromDeselectedParents = useCallback( + (ids: string[]) => { + const incomingIds = new Set(ids); + const descendantsToRemove = new Set( + selectedRowIds + .filter(selectedId => !incomingIds.has(selectedId)) + .flatMap(selectedId => collectDescendantIds(selectedId, getCachedChildrenByParentId)), + ); + + return ids.filter(id => !descendantsToRemove.has(id)); + }, + [getCachedChildrenByParentId, selectedRowIds], + ); + + const toggleExpanded = useCallback( + async (categoryId: string) => { + const isExpanded = expandedIds.has(categoryId); + + if (isExpanded) { + const hiddenIds = new Set(collectDescendantIds(categoryId, getCachedChildrenByParentId)); + const hasHiddenSelectedRows = selectedRowIds.some(id => hiddenIds.has(id)); + + if (hasHiddenSelectedRows) { + excludeFromSelected([...hiddenIds, categoryId]); + // clearRowSelection(); + } + + setExpandedIds(prev => { + const next = new Set(prev); + + next.delete(categoryId); + + return next; + }); + return; } - const rowsIds = rows.map(row => categories[row].id); - const haveSaveValues = isEqual(rowsIds, selectedRowIds); + if (!loadedChildrenIds.has(categoryId) && !loadingChildrenIds.has(categoryId)) { + setCategoryChildrenLoading(categoryId, true); + + let hasCachedData = false; + + try { + try { + const cachedData = client.readQuery< + CategoryChildrenQuery, + CategoryChildrenQueryVariables + >({ + query: CategoryChildrenDocument, + variables: { + id: categoryId, + first: subcategoryPageSize, + after: null, + }, + }); + + if (cachedData?.category?.children) { + setLoadedChildrenIds(prev => { + const next = new Set(prev); + + next.add(categoryId); + + return next; + }); + hasCachedData = true; + } + } catch (e) { + console.warn("Cache miss", e); + } + + try { + await client.query({ + query: CategoryChildrenDocument, + variables: { + id: categoryId, + first: subcategoryPageSize, + after: null, + }, + fetchPolicy: "network-only", + }); + + setLoadedChildrenIds(prev => { + const next = new Set(prev); - if (!haveSaveValues) { - setSelectedRowIds(rowsIds); + next.add(categoryId); + + return next; + }); + } catch (networkError) { + console.error("Network Error:", networkError); + + if (hasCachedData) { + console.warn("Cached data, update failed."); + } else { + throw networkError; + } + } + } catch (finalError) { + console.error("Critical failure loading children", finalError); + } finally { + setCategoryChildrenLoading(categoryId, false); + } } + setExpandedIds(prev => { + const next = new Set(prev); + + next.add(categoryId); + + return next; + }); + }, + [ + expandedIds, + getCachedChildrenByParentId, + selectedRowIds, + loadingChildrenIds, + client, + loadedChildrenIds, + subcategoryPageSize, + setCategoryChildrenLoading, + excludeFromSelected, + ], + ); + + const visibleRows = useMemo(() => { + const rows: CategoryListRow[] = []; + + const appendRows = ( + nodes: CategoryFragment[], + depth: number, + parentId: string | null, + ): void => { + nodes.forEach(node => { + rows.push({ category: node, depth, parentId }); + + if (expandedIds.has(node.id)) { + appendRows(getCachedChildrenByParentId(node.id), depth + 1, node.id); + } + }); + }; + + appendRows(categories ?? [], 0, null); + + return rows; + }, [categories, expandedIds, getCachedChildrenByParentId]); + + const visibleCategories = useMemo(() => visibleRows.map(row => row.category), [visibleRows]); + const isCategoryExpanded = useCallback( + (categoryId: string) => expandedIds.has(categoryId), + [expandedIds], + ); + + const depthByCategoryId = useMemo(() => { + return visibleRows.reduce>((acc, row) => { + acc[row.category.id] = row.depth; + + return acc; + }, {}); + }, [visibleRows]); + + const getCategoryDepth = useCallback( + (categoryId: string) => depthByCategoryId[categoryId] ?? 0, + [depthByCategoryId], + ); + + const handleSelectedCategoryIdsChange = useCallback( + (ids: string[]) => { + const idsWithoutDeselectedParentsDescendants = removeDescendantsFromDeselectedParents(ids); + const nextSelectedIds = getSelectedWithLoadedDescendants( + idsWithoutDeselectedParentsDescendants, + ); + + if (!isEqual(nextSelectedIds, selectedRowIds)) { + setSelectedRowIds(nextSelectedIds); + } + }, + [ + getSelectedWithLoadedDescendants, + removeDescendantsFromDeselectedParents, + selectedRowIds, + setSelectedRowIds, + ], + ); + + useEffect(() => { + const nextSelectedIds = getSelectedWithLoadedDescendants(selectedRowIds); + + if (!isEqual(nextSelectedIds, selectedRowIds)) { + setSelectedRowIds(nextSelectedIds); + } + }, [getSelectedWithLoadedDescendants, selectedRowIds, setSelectedRowIds]); + + const handleSetSelectedCategoryIds = useCallback( + (rows: number[], clearSelection: () => void) => { + const rowsIds = rows + .map(rowIndex => visibleRows[rowIndex]?.category.id) + .filter((id): id is string => !!id); + + handleSelectedCategoryIdsChange(rowsIds); setClearDatagridRowSelectionCallback(clearSelection); }, - [categories, setClearDatagridRowSelectionCallback, selectedRowIds, setSelectedRowIds], + [visibleRows, setClearDatagridRowSelectionCallback, handleSelectedCategoryIdsChange], + ); + const handleCategoryBulkDeleteOnComplete = useCallback( + (data: CategoryBulkDeleteMutation) => { + if (data?.categoryBulkDelete?.errors.length === 0) { + const deletedIds = new Set(deletingCategoryIdsRef.current); + const deletedIdsWithDescendants = new Set(deletedIds); + + deletedIds.forEach(deletedId => { + collectDescendantIds(deletedId, getCachedChildrenByParentId).forEach(descendantId => { + deletedIdsWithDescendants.add(descendantId); + }); + }); + + const parentByCategoryId = visibleRows.reduce>((acc, row) => { + acc[row.category.id] = row.parentId; + + return acc; + }, {}); + const parentIdsToInvalidate = new Set(); + + deletedIdsWithDescendants.forEach(deletedId => { + let parentId = parentByCategoryId[deletedId]; + + while (parentId) { + parentIdsToInvalidate.add(parentId); + parentId = parentByCategoryId[parentId]; + } + }); + + setExpandedIds(prev => { + const next = new Set(prev); + + deletedIdsWithDescendants.forEach(id => next.delete(id)); + + return next; + }); + setLoadingChildrenIds(prev => { + const next = new Set(prev); + + deletedIdsWithDescendants.forEach(id => next.delete(id)); + + return next; + }); + setLoadedChildrenIds(prev => { + const next = new Set(prev); + + deletedIdsWithDescendants.forEach(id => next.delete(id)); + parentIdsToInvalidate.forEach(id => next.delete(id)); + + return next; + }); + + const parentIdsToRefetch = Array.from(parentIdsToInvalidate); + + if (parentIdsToRefetch.length > 0) { + void Promise.allSettled( + parentIdsToRefetch.map(parentId => + client.query({ + query: CategoryChildrenDocument, + variables: { + id: parentId, + first: subcategoryPageSize, + after: null, + }, + fetchPolicy: "network-only", + }), + ), + ); + } + + navigate(categoryListUrl(), { replace: true }); + refetch(); + clearRowSelection(); + deletingCategoryIdsRef.current = []; + notify({ + status: "success", + text: intl.formatMessage({ + id: "G5ETO0", + defaultMessage: "Categories deleted", + }), + }); + } + }, + [ + clearRowSelection, + client, + getCachedChildrenByParentId, + intl, + navigate, + notify, + refetch, + subcategoryPageSize, + visibleRows, + ], ); const [categoryBulkDelete, categoryBulkDeleteOpts] = useCategoryBulkDeleteMutation({ onCompleted: handleCategoryBulkDeleteOnComplete, }); const handleCategoryBulkDelete = useCallback(async () => { + deletingCategoryIdsRef.current = [...selectedRowIds]; + await categoryBulkDelete({ variables: { ids: selectedRowIds, }, }); clearRowSelection(); - }, [selectedRowIds]); + }, [categoryBulkDelete, clearRowSelection, selectedRowIds]); return ( changeFilterField({ query })} @@ -174,7 +562,14 @@ const CategoryList = ({ params }: CategoryListProps) => { }} selectedCategoriesIds={selectedRowIds} onSelectCategoriesIds={handleSetSelectedCategoryIds} + onSelectedCategoriesIdsChange={handleSelectedCategoryIdsChange} + isCategoryChildrenLoading={isCategoryChildrenLoading} onCategoriesDelete={() => openModal("delete")} + isCategoryExpanded={isCategoryExpanded} + onCategoryExpandToggle={toggleExpanded} + getCategoryDepth={getCategoryDepth} + subcategoryPageSize={subcategoryPageSize} + onSubcategoryPageSizeChange={handleSubcategoryPageSizeChange} /> { id="Pp/7T7" defaultMessage="{counter,plural,one{Are you sure you want to delete this category?} other{Are you sure you want to delete {displayQuantity} categories?}}" values={{ - counter: params?.ids?.length, - displayQuantity: {params?.ids?.length}, + counter: selectedRowIds.length, + displayQuantity: {selectedRowIds.length}, }} /> @@ -235,4 +630,5 @@ const CategoryList = ({ params }: CategoryListProps) => { ); }; +// eslint-disable-next-line import/no-default-export export default CategoryList; diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 47f2fc9e849..6e3057c7d69 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -4620,6 +4620,54 @@ export function useCategoryDetailsLazyQuery(baseOptions?: ApolloReactHooks.LazyQ export type CategoryDetailsQueryHookResult = ReturnType; export type CategoryDetailsLazyQueryHookResult = ReturnType; export type CategoryDetailsQueryResult = Apollo.QueryResult; +export const CategoryChildrenDocument = gql` + query CategoryChildren($id: ID!, $first: Int!, $after: String) { + category(id: $id) { + id + children(first: $first, after: $after) { + edges { + node { + ...Category + } + } + pageInfo { + ...PageInfo + } + } + } +} + ${CategoryFragmentDoc} +${PageInfoFragmentDoc}`; + +/** + * __useCategoryChildrenQuery__ + * + * To run a query within a React component, call `useCategoryChildrenQuery` and pass it any options that fit your needs. + * When your component renders, `useCategoryChildrenQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useCategoryChildrenQuery({ + * variables: { + * id: // value for 'id' + * first: // value for 'first' + * after: // value for 'after' + * }, + * }); + */ +export function useCategoryChildrenQuery(baseOptions: ApolloReactHooks.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return ApolloReactHooks.useQuery(CategoryChildrenDocument, options); + } +export function useCategoryChildrenLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return ApolloReactHooks.useLazyQuery(CategoryChildrenDocument, options); + } +export type CategoryChildrenQueryHookResult = ReturnType; +export type CategoryChildrenLazyQueryHookResult = ReturnType; +export type CategoryChildrenQueryResult = Apollo.QueryResult; export const ChannelCreateDocument = gql` mutation ChannelCreate($input: ChannelCreateInput!) { channelCreate(input: $input) { diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index 8107bd1f443..70eb2e57740 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -9044,6 +9044,15 @@ export type CategoryDetailsQueryVariables = Exact<{ export type CategoryDetailsQuery = { __typename: 'Query', category: { __typename: 'Category', id: string, name: string, slug: string, description: any | null, seoDescription: string | null, seoTitle: string | null, children: { __typename: 'CategoryCountableConnection', edges: Array<{ __typename: 'CategoryCountableEdge', node: { __typename: 'Category', id: string, name: string, children: { __typename: 'CategoryCountableConnection', totalCount: number | null } | null, products: { __typename: 'ProductCountableConnection', totalCount: number | null } | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null, products: { __typename: 'ProductCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'ProductCountableEdge', cursor: string, node: { __typename: 'Product', id: string, name: string, thumbnail: { __typename: 'Image', url: string } | null } }> } | null, backgroundImage: { __typename: 'Image', alt: string | null, url: string } | null, parent: { __typename: 'Category', id: string } | null, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> } | null }; +export type CategoryChildrenQueryVariables = Exact<{ + id: Scalars['ID']; + first: Scalars['Int']; + after?: InputMaybe; +}>; + + +export type CategoryChildrenQuery = { __typename: 'Query', category: { __typename: 'Category', id: string, children: { __typename: 'CategoryCountableConnection', edges: Array<{ __typename: 'CategoryCountableEdge', node: { __typename: 'Category', id: string, name: string, children: { __typename: 'CategoryCountableConnection', totalCount: number | null } | null, products: { __typename: 'ProductCountableConnection', totalCount: number | null } | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null } | null }; + export type ChannelCreateMutationVariables = Exact<{ input: ChannelCreateInput; }>; From 5fad13a1b16ce5a43c6c7629967f6dbc5fe4c0ba Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:52:25 +0000 Subject: [PATCH 04/15] chore(vite): allow overriding dev server port via PORT_DEVSERVER --- vite.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vite.config.js b/vite.config.js index 69640b21133..10f9ec82770 100644 --- a/vite.config.js +++ b/vite.config.js @@ -62,6 +62,7 @@ export default defineConfig(({ command, mode }) => { FF_USE_STAGING_SCHEMA, npm_package_version, + PORT_DEVSERVER, } = env; const base = STATIC_URL ?? "/"; @@ -128,7 +129,7 @@ export default defineConfig(({ command, mode }) => { base, envDir: "..", server: { - port: 9000, + port: PORT_DEVSERVER || 9000, fs: { allow: [searchForWorkspaceRoot(process.cwd()), "../.."], }, From 9307297610dfbebd99f23b7a87fcfe9a968f83a5 Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:54:04 +0000 Subject: [PATCH 05/15] chore(deps): bump eslint-plugin-storybook to ^10.2.7 --- package.json | 2 +- pnpm-lock.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 8e125d98671..932e7a4b8fb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1cae81624c..9421b7b30ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -421,8 +421,8 @@ importers: specifier: ^12.1.1 version: 12.1.1(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-storybook: - specifier: 10.2.4 - version: 10.2.4(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@8.20.1)(prettier@3.6.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + specifier: ^10.2.7 + version: 10.2.7(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@8.20.1)(prettier@3.6.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) eslint-plugin-unicorn: specifier: ^61.0.2 version: 61.0.2(eslint@9.39.2(jiti@2.6.1)) @@ -7986,14 +7986,14 @@ packages: peerDependencies: eslint: ">=5.0.0" - eslint-plugin-storybook@10.2.4: + eslint-plugin-storybook@10.2.7: resolution: { - integrity: sha512-D8a6Y+iun2MSOpgps0Vd/t8y9Y5ZZ7O2VeKqw2PCv2+b7yInqogOS2VBMSRZVfP8TTGQgDpbUK67k7KZEUC7Ng==, + integrity: sha512-111gI3lUuan/zfDZ79isC4YZ75A9Ro7knqa2lNHv9PQRBWGKe6gvH2gDCYMioqT8aZo7UTzXfsyW7HHlEWxpQA==, } peerDependencies: eslint: ">=8" - storybook: ^10.2.4 + storybook: ^10.2.7 eslint-plugin-unicorn@61.0.2: resolution: @@ -19308,7 +19308,7 @@ snapshots: dependencies: eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-storybook@10.2.4(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@8.20.1)(prettier@3.6.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3): + eslint-plugin-storybook@10.2.7(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@8.20.1)(prettier@3.6.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3): dependencies: "@typescript-eslint/utils": 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.2(jiti@2.6.1) From 197e017c3761e729f9b7ce162455907146a6545c Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:05:10 +0000 Subject: [PATCH 06/15] chore: update translations --- locale/defaultMessages.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 2dd04f745d5..312834dd6fb 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -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" From 8fd6fbc01f7fd8f9812a0ca194fa912884fbbbaa Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:13:19 +0000 Subject: [PATCH 07/15] Add changeset --- .changeset/short-teeth-film.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/short-teeth-film.md diff --git a/.changeset/short-teeth-film.md b/.changeset/short-teeth-film.md new file mode 100644 index 00000000000..1b69e5e5c6a --- /dev/null +++ b/.changeset/short-teeth-film.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": minor +--- + +Added expandable rows to the Category list with lazy loading of subcategories and improved nested selection logic. From f5ccc287986daac96cff30321062c4ba9a1790a7 Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:56:04 +0000 Subject: [PATCH 08/15] feat(categories): add ripple for expandable rows --- .../CategoryListPage/CategoryListPage.tsx | 3 +++ src/categories/ripples/expandedSubcategories.ts | 15 +++++++++++++++ src/ripples/allRipples.ts | 4 ++++ 3 files changed, 22 insertions(+) create mode 100644 src/categories/ripples/expandedSubcategories.ts diff --git a/src/categories/components/CategoryListPage/CategoryListPage.tsx b/src/categories/components/CategoryListPage/CategoryListPage.tsx index f2a223ce4d0..0c17ef4d496 100644 --- a/src/categories/components/CategoryListPage/CategoryListPage.tsx +++ b/src/categories/components/CategoryListPage/CategoryListPage.tsx @@ -1,3 +1,4 @@ +import { rippleExpandedSubcategories } from "@dashboard/categories/ripples/expandedSubcategories"; import { categoryAddUrl, CategoryListUrlSortField } from "@dashboard/categories/urls"; import SearchInput from "@dashboard/components/AppLayout/ListFilters/components/SearchInput"; import { TopNav } from "@dashboard/components/AppLayout/TopNav"; @@ -15,6 +16,7 @@ import { useExtensions } from "@dashboard/extensions/hooks/useExtensions"; import { CategoryFragment } from "@dashboard/graphql"; import useNavigator from "@dashboard/hooks/useNavigator"; import { sectionNames } from "@dashboard/intl"; +import { Ripple } from "@dashboard/ripples/components/Ripple"; import { PageListProps, SearchPageProps, SortPage, TabPageProps } from "@dashboard/types"; import { Box, Button, Input, Text } from "@saleor/macaw-ui-next"; import { ChangeEvent, useCallback, useState } from "react"; @@ -160,6 +162,7 @@ export const CategoryListPage = ({ + Date: Tue, 10 Feb 2026 11:27:18 +0000 Subject: [PATCH 09/15] style(categories): replace ASCII arrows with Unicode chevrons --- src/categories/components/CategoryListDatagrid/datagrid.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/categories/components/CategoryListDatagrid/datagrid.ts b/src/categories/components/CategoryListDatagrid/datagrid.ts index f6b0537fc31..6860abc13a5 100644 --- a/src/categories/components/CategoryListDatagrid/datagrid.ts +++ b/src/categories/components/CategoryListDatagrid/datagrid.ts @@ -82,7 +82,7 @@ export const createGetCellContent = const isExpanded = isCategoryExpanded?.(rowData.id) ?? false; - return readonlyTextCell(isExpanded ? "v" : ">"); + return readonlyTextCell(isExpanded ? "▾" : "▸"); } case "name": const depth = getCategoryDepth?.(rowData.id) ?? 0; From f4cc146fbaa90803db4c9c32790fa7536dffdedf Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:15:20 +0000 Subject: [PATCH 10/15] feat(categories): persist expanded tree state and add collapse-all action --- locale/defaultMessages.json | 3 + .../CategoryListDatagrid/datagrid.ts | 2 +- .../CategoryListPage/CategoryListPage.tsx | 12 +++ .../components/CategoryListPage/messages.ts | 4 + .../views/CategoryList/CategoryList.tsx | 98 ++++++++++++++++++- .../views/CategoryList/expandedIdsStorage.ts | 14 +++ 6 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/categories/views/CategoryList/expandedIdsStorage.ts diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 312834dd6fb..bda03458f96 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -10324,6 +10324,9 @@ "context": "consent to send gift card to different address checkbox label", "string": "Yes, I want to send gift card to different address" }, + "v0fBOU": { + "string": "Collapse all" + }, "v17Lly": { "context": "label", "string": "Max Delivery Time" diff --git a/src/categories/components/CategoryListDatagrid/datagrid.ts b/src/categories/components/CategoryListDatagrid/datagrid.ts index 6860abc13a5..db8b34963b5 100644 --- a/src/categories/components/CategoryListDatagrid/datagrid.ts +++ b/src/categories/components/CategoryListDatagrid/datagrid.ts @@ -82,7 +82,7 @@ export const createGetCellContent = const isExpanded = isCategoryExpanded?.(rowData.id) ?? false; - return readonlyTextCell(isExpanded ? "▾" : "▸"); + return readonlyTextCell(isExpanded ? "⯆" : "⯈"); } case "name": const depth = getCategoryDepth?.(rowData.id) ?? 0; diff --git a/src/categories/components/CategoryListPage/CategoryListPage.tsx b/src/categories/components/CategoryListPage/CategoryListPage.tsx index 0c17ef4d496..6551e6d6c02 100644 --- a/src/categories/components/CategoryListPage/CategoryListPage.tsx +++ b/src/categories/components/CategoryListPage/CategoryListPage.tsx @@ -44,6 +44,8 @@ interface CategoryTableProps getCategoryDepth?: (categoryId: string) => number; subcategoryPageSize: number; onSubcategoryPageSizeChange: (value: number) => void; + hasExpandedSubcategories: boolean; + onCollapseAllSubcategories: () => void; } export const CategoryListPage = ({ @@ -69,6 +71,8 @@ export const CategoryListPage = ({ getCategoryDepth, subcategoryPageSize, onSubcategoryPageSizeChange, + hasExpandedSubcategories, + onCollapseAllSubcategories, ...listProps }: CategoryTableProps): JSX.Element => { const navigate = useNavigator(); @@ -175,6 +179,14 @@ export const CategoryListPage = ({ data-test-id="subcategory-page-size-input" /> + {selectedCategoriesIds.length > 0 && ( diff --git a/src/categories/components/CategoryListPage/messages.ts b/src/categories/components/CategoryListPage/messages.ts index b7518fdf2f0..197f2897f9c 100644 --- a/src/categories/components/CategoryListPage/messages.ts +++ b/src/categories/components/CategoryListPage/messages.ts @@ -22,4 +22,8 @@ export const messages = defineMessages({ id: "8zbcLs", defaultMessage: "№ of subcategories", }, + collapseAllSubcategories: { + id: "v0fBOU", + defaultMessage: "Collapse all", + }, }); diff --git a/src/categories/views/CategoryList/CategoryList.tsx b/src/categories/views/CategoryList/CategoryList.tsx index 9328ed4bea0..a608850c463 100644 --- a/src/categories/views/CategoryList/CategoryList.tsx +++ b/src/categories/views/CategoryList/CategoryList.tsx @@ -13,6 +13,7 @@ import { } from "@dashboard/graphql"; import { useFilterPresets } from "@dashboard/hooks/useFilterPresets"; import useListSettings from "@dashboard/hooks/useListSettings"; +import useLocalStorage from "@dashboard/hooks/useLocalStorage"; import useNavigator from "@dashboard/hooks/useNavigator"; import { useNotifier } from "@dashboard/hooks/useNotifier"; import { usePaginationReset } from "@dashboard/hooks/usePaginationReset"; @@ -39,6 +40,11 @@ import { CategoryListUrlFilters, CategoryListUrlQueryParams, } from "../../urls"; +import { + CATEGORY_LIST_EXPANDED_IDS_STORAGE_KEY, + normalizeStoredExpandedIds, + serializeExpandedIds, +} from "./expandedIdsStorage"; import { getActiveFilters, getFilterVariables, storageUtils } from "./filter"; import { getSortQueryVariables } from "./sort"; @@ -122,6 +128,10 @@ const CategoryList = ({ params }: CategoryListProps): JSX.Element => { paginationState, queryString: params, }); + const [storedExpandedIds, setStoredExpandedIds] = useLocalStorage( + CATEGORY_LIST_EXPANDED_IDS_STORAGE_KEY, + storedIds => normalizeStoredExpandedIds(storedIds), + ); const changeFilterField = (filter: CategoryListUrlFilters): void => { clearRowSelection(); navigate( @@ -132,15 +142,15 @@ const CategoryList = ({ params }: CategoryListProps): JSX.Element => { }), ); }; - const [expandedIds, setExpandedIds] = useState>(() => new Set()); + const [expandedIds, setExpandedIds] = useState>(() => new Set(storedExpandedIds)); const [loadingChildrenIds, setLoadingChildrenIds] = useState>(() => new Set()); const [loadedChildrenIds, setLoadedChildrenIds] = useState>(() => new Set()); const [subcategoryPageSize, setSubcategoryPageSize] = useState(DEFAULT_SUBCATEGORIES_PAGE_SIZE); const deletingCategoryIdsRef = useRef([]); + const hasRestoredExpandedIdsRef = useRef(false); const invalidateCache = useCallback(() => { setLoadedChildrenIds(new Set()); - setExpandedIds(new Set()); setLoadingChildrenIds(new Set()); clearRowSelection(); }, [clearRowSelection]); @@ -204,6 +214,83 @@ const CategoryList = ({ params }: CategoryListProps): JSX.Element => { }); }, []); + useEffect(() => { + const serializedExpandedIds = serializeExpandedIds(expandedIds); + + if (!isEqual(serializedExpandedIds, storedExpandedIds)) { + setStoredExpandedIds(serializedExpandedIds); + } + }, [expandedIds, setStoredExpandedIds, storedExpandedIds]); + + useEffect(() => { + if (hasRestoredExpandedIdsRef.current) { + return; + } + + hasRestoredExpandedIdsRef.current = true; + + const idsToRestore = [...expandedIds]; + + if (idsToRestore.length === 0) { + return; + } + + void Promise.allSettled( + idsToRestore.map(async categoryId => { + if (loadingChildrenIds.has(categoryId)) { + return; + } + + setCategoryChildrenLoading(categoryId, true); + + try { + const response = await client.query< + CategoryChildrenQuery, + CategoryChildrenQueryVariables + >({ + query: CategoryChildrenDocument, + variables: { + id: categoryId, + first: subcategoryPageSize, + after: null, + }, + fetchPolicy: "network-only", + }); + + if (!response.data?.category) { + setExpandedIds(prev => { + const next = new Set(prev); + + next.delete(categoryId); + + return next; + }); + + return; + } + + setLoadedChildrenIds(prev => { + const next = new Set(prev); + + next.add(categoryId); + + return next; + }); + } catch { + setExpandedIds(prev => { + const next = new Set(prev); + + next.delete(categoryId); + + return next; + }); + } finally { + setCategoryChildrenLoading(categoryId, false); + } + }), + ); + }, [client, expandedIds, loadingChildrenIds, setCategoryChildrenLoading, subcategoryPageSize]); + const isCategoryChildrenLoading = useCallback( (categoryId: string) => loadingChildrenIds.has(categoryId), [loadingChildrenIds], @@ -347,6 +434,11 @@ const CategoryList = ({ params }: CategoryListProps): JSX.Element => { excludeFromSelected, ], ); + const handleCollapseAllSubcategories = useCallback(() => { + setExpandedIds(new Set()); + setLoadingChildrenIds(new Set()); + clearRowSelection(); + }, [clearRowSelection]); const visibleRows = useMemo(() => { const rows: CategoryListRow[] = []; @@ -570,6 +662,8 @@ const CategoryList = ({ params }: CategoryListProps): JSX.Element => { getCategoryDepth={getCategoryDepth} subcategoryPageSize={subcategoryPageSize} onSubcategoryPageSizeChange={handleSubcategoryPageSizeChange} + hasExpandedSubcategories={expandedIds.size > 0} + onCollapseAllSubcategories={handleCollapseAllSubcategories} /> { + if (!Array.isArray(storedExpandedIds)) { + return []; + } + + return Array.from( + new Set(storedExpandedIds.filter((id): id is string => typeof id === "string")), + ); +}; + +export const serializeExpandedIds = (expandedIds: Set): string[] => + Array.from(expandedIds).sort(); From 82d7af6f10ddc6520102977b1085430109032742 Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:24:58 +0000 Subject: [PATCH 11/15] test(categories): add test coverage for expandable category list behavior --- .../CategoryListDatagrid.test.tsx | 161 +++++++++++++++ .../CategoryListDatagrid/datagrid.test.ts | 107 ++++++++++ .../CategoryListPage.test.tsx | 190 ++++++++++++++++++ .../ripples/expandedSubcategories.test.ts | 20 ++ .../CategoryList/expandedIdsStorage.test.ts | 39 ++++ src/ripples/allRipples.test.ts | 18 ++ 6 files changed, 535 insertions(+) create mode 100644 src/categories/components/CategoryListDatagrid/CategoryListDatagrid.test.tsx create mode 100644 src/categories/components/CategoryListDatagrid/datagrid.test.ts create mode 100644 src/categories/components/CategoryListPage/CategoryListPage.test.tsx create mode 100644 src/categories/ripples/expandedSubcategories.test.ts create mode 100644 src/categories/views/CategoryList/expandedIdsStorage.test.ts create mode 100644 src/ripples/allRipples.test.ts diff --git a/src/categories/components/CategoryListDatagrid/CategoryListDatagrid.test.tsx b/src/categories/components/CategoryListDatagrid/CategoryListDatagrid.test.tsx new file mode 100644 index 00000000000..741c25b21f6 --- /dev/null +++ b/src/categories/components/CategoryListDatagrid/CategoryListDatagrid.test.tsx @@ -0,0 +1,161 @@ +import { categoryUrl } from "@dashboard/categories/urls"; +import { CategoryFragment } from "@dashboard/graphql"; +import { CompactSelection, GridSelection } from "@glideapps/glide-data-grid"; +import { render } from "@testing-library/react"; + +import { CategoryListDatagrid } from "./CategoryListDatagrid"; + +const navigateMock = jest.fn(); +let datagridProps: Record = {}; + +jest.mock("@dashboard/hooks/useNavigator", () => () => navigateMock); +jest.mock("@dashboard/hooks/useBackLinkWithState", () => ({ + getPrevLocationState: () => undefined, +})); +jest.mock("@dashboard/components/Datagrid/ColumnPicker/ColumnPicker", () => ({ + ColumnPicker: () => null, +})); +jest.mock("@dashboard/components/TablePagination", () => ({ + DatagridPagination: () => null, +})); +jest.mock("@dashboard/components/Datagrid/Datagrid", () => ({ + __esModule: true, + default: (props: Record) => { + datagridProps = props; + + return null; + }, +})); +jest.mock("@dashboard/components/Datagrid/ColumnPicker/useColumns", () => ({ + useColumns: () => ({ + handlers: { + onMove: jest.fn(), + onResize: jest.fn(), + onToggle: jest.fn(), + }, + selectedColumns: [], + staticColumns: [], + visibleColumns: [ + { id: "name", title: "Name", width: 320 }, + { id: "subcategories", title: "Subcategories", width: 160 }, + { id: "products", title: "Products", width: 160 }, + ], + }), +})); +jest.mock("react-router", () => ({ + useLocation: () => ({ + pathname: "/categories/", + search: "", + hash: "", + state: undefined, + }), +})); + +const createCategory = (id: string, childrenCount: number): CategoryFragment => + ({ + __typename: "Category", + id, + name: `Category ${id}`, + children: { + __typename: "CategoryCountableConnection", + totalCount: childrenCount, + }, + products: { + __typename: "ProductCountableConnection", + totalCount: 10, + }, + }) as CategoryFragment; + +const baseProps = { + disabled: false, + settings: { + rowNumber: 20, + columns: [], + }, + categories: [createCategory("cat-1", 1), createCategory("cat-2", 0), createCategory("cat-3", 2)], + onSelectCategoriesIds: jest.fn(), + onUpdateListSettings: jest.fn(), +}; + +describe("CategoryListDatagrid", () => { + beforeEach(() => { + datagridProps = {}; + navigateMock.mockReset(); + }); + + it("should map selected category ids to controlled row selection", () => { + // Arrange + render( + , + ); + + // Act + const selectedRows = datagridProps.controlledSelection.rows.toArray(); + + // Assert + expect(selectedRows).toEqual([1]); + expect(typeof datagridProps.onControlledSelectionChange).toBe("function"); + }); + + it("should map controlled row selection back to category ids", () => { + // Arrange + const onSelectedCategoriesIdsChange = jest.fn(); + + render( + , + ); + + const selection: GridSelection = { + columns: CompactSelection.empty(), + rows: CompactSelection.empty().add(0).add(2), + }; + + // Act + datagridProps.onControlledSelectionChange(selection); + + // Assert + expect(onSelectedCategoriesIdsChange).toHaveBeenCalledWith(["cat-1", "cat-3"]); + }); + + it("should trigger expand toggle only for expandable non-loading rows", () => { + // Arrange + const onCategoryExpandToggle = jest.fn(); + + render( + false} + isCategoryChildrenLoading={categoryId => categoryId === "cat-3"} + onCategoryExpandToggle={onCategoryExpandToggle} + />, + ); + + // Act + datagridProps.onRowClick([0, 0]); + datagridProps.onRowClick([0, 1]); + datagridProps.onRowClick([0, 2]); + + // Assert + expect(onCategoryExpandToggle).toHaveBeenCalledTimes(1); + expect(onCategoryExpandToggle).toHaveBeenCalledWith("cat-1"); + }); + + it("should navigate to details when clicking non-expand columns", () => { + // Arrange + render(); + + // Act + datagridProps.onRowClick([0, 0]); + + // Assert + expect(navigateMock).toHaveBeenCalledWith(categoryUrl("cat-1")); + }); +}); diff --git a/src/categories/components/CategoryListDatagrid/datagrid.test.ts b/src/categories/components/CategoryListDatagrid/datagrid.test.ts new file mode 100644 index 00000000000..bf6a85d0a14 --- /dev/null +++ b/src/categories/components/CategoryListDatagrid/datagrid.test.ts @@ -0,0 +1,107 @@ +import { CategoryFragment } from "@dashboard/graphql"; +import { GridCellKind } from "@glideapps/glide-data-grid"; + +import { categoryListExpandColumn, createGetCellContent } from "./datagrid"; + +const nameColumn = { id: "name", title: "Name", width: 320 }; +const subcategoriesColumn = { id: "subcategories", title: "Subcategories", width: 180 }; +const productsColumn = { id: "products", title: "Products", width: 180 }; + +const makeCategory = (id: string, childrenCount: number, name = "Category"): CategoryFragment => + ({ + __typename: "Category", + id, + name, + children: { + __typename: "CategoryCountableConnection", + totalCount: childrenCount, + }, + products: { + __typename: "ProductCountableConnection", + totalCount: 10, + }, + }) as CategoryFragment; + +describe("CategoryListDatagrid createGetCellContent", () => { + it("should return empty expand cell when category has no subcategories", () => { + // Arrange + const columns = [categoryListExpandColumn, nameColumn, subcategoriesColumn, productsColumn]; + const categories: CategoryFragment[] = [makeCategory("cat-1", 0)]; + const getCellContent = createGetCellContent(categories, columns); + + // Act + const result = getCellContent([0, 0]); + + // Assert + expect(result).toMatchObject({ + kind: GridCellKind.Text, + data: "", + cursor: "default", + readonly: true, + }); + }); + + it("should return loading cell for subcategories while children are loading", () => { + // Arrange + const columns = [categoryListExpandColumn, nameColumn, subcategoriesColumn, productsColumn]; + const categories: CategoryFragment[] = [makeCategory("cat-1", 3)]; + const getCellContent = createGetCellContent(categories, columns, { + isCategoryChildrenLoading: categoryId => categoryId === "cat-1", + }); + + // Act + const result = getCellContent([0, 0]); + + // Assert + expect(result).toMatchObject({ + kind: GridCellKind.Custom, + data: { + kind: "spinner-cell", + }, + }); + }); + + it("should return expanded and collapsed chevrons", () => { + // Arrange + const columns = [categoryListExpandColumn, nameColumn, subcategoriesColumn, productsColumn]; + const categories: CategoryFragment[] = [makeCategory("cat-1", 2)]; + const getCollapsedCell = createGetCellContent(categories, columns, { + isCategoryExpanded: () => false, + }); + const getExpandedCell = createGetCellContent(categories, columns, { + isCategoryExpanded: () => true, + }); + + // Act + const collapsedCell = getCollapsedCell([0, 0]); + const expandedCell = getExpandedCell([0, 0]); + + // Assert + expect(collapsedCell).toMatchObject({ + kind: GridCellKind.Text, + data: "⯈", + }); + expect(expandedCell).toMatchObject({ + kind: GridCellKind.Text, + data: "⯆", + }); + }); + + it("should indent category name based on depth", () => { + // Arrange + const columns = [nameColumn, subcategoriesColumn, productsColumn]; + const categories: CategoryFragment[] = [makeCategory("cat-1", 0, "Phones")]; + const getCellContent = createGetCellContent(categories, columns, { + getCategoryDepth: () => 2, + }); + + // Act + const result = getCellContent([0, 0]); + + // Assert + expect(result).toMatchObject({ + kind: GridCellKind.Text, + data: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Phones", + }); + }); +}); diff --git a/src/categories/components/CategoryListPage/CategoryListPage.test.tsx b/src/categories/components/CategoryListPage/CategoryListPage.test.tsx new file mode 100644 index 00000000000..f59aac4e7e5 --- /dev/null +++ b/src/categories/components/CategoryListPage/CategoryListPage.test.tsx @@ -0,0 +1,190 @@ +import { rippleExpandedSubcategories } from "@dashboard/categories/ripples/expandedSubcategories"; +import { CategoryListUrlSortField } from "@dashboard/categories/urls"; +import { CategoryFragment } from "@dashboard/graphql"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ComponentProps, ReactNode } from "react"; + +import { CategoryListPage } from "./CategoryListPage"; + +const navigateMock = jest.fn(); +const rippleModelSpy = jest.fn(); + +jest.mock("@dashboard/hooks/useNavigator", () => () => navigateMock); +jest.mock("@dashboard/extensions/extensionMountPoints", () => ({ + extensionMountPoints: { + CATEGORY_LIST: "CATEGORY_LIST", + }, +})); +jest.mock("@dashboard/extensions/getExtensionsItems", () => ({ + getExtensionItemsForOverviewCreate: () => [], + getExtensionsItemsForCategoryOverviewActions: () => [], +})); +jest.mock("@dashboard/extensions/hooks/useExtensions", () => ({ + useExtensions: () => ({ + CATEGORY_OVERVIEW_CREATE: [], + CATEGORY_OVERVIEW_MORE_ACTIONS: [], + }), +})); +jest.mock("@dashboard/intl", () => ({ + sectionNames: { + categories: { + defaultMessage: "Categories", + }, + }, +})); +jest.mock("@dashboard/components/AppLayout/ListFilters/components/SearchInput", () => () => null); +jest.mock("@dashboard/components/AppLayout/TopNav", () => { + const TopNav = ({ children }: { children: ReactNode }) =>
{children}
; + const TopNavMenu = (): null => null; + + TopNav.Menu = TopNavMenu; + + return { TopNav }; +}); +jest.mock("@dashboard/components/BulkDeleteButton", () => ({ + BulkDeleteButton: ({ children, onClick }: { children: ReactNode; onClick: () => void }) => ( + + ), +})); +jest.mock("@dashboard/components/ButtonGroupWithDropdown", () => ({ + ButtonGroupWithDropdown: ({ children }: { children: ReactNode }) =>
{children}
, +})); +jest.mock("@dashboard/components/Card", () => ({ + DashboardCard: ({ children }: { children: ReactNode }) =>
{children}
, +})); +jest.mock("@dashboard/components/FilterPresetsSelect", () => ({ + FilterPresetsSelect: () => null, +})); +jest.mock("@dashboard/components/Layouts", () => ({ + ListPageLayout: ({ children }: { children: ReactNode }) =>
{children}
, +})); +jest.mock("@dashboard/ripples/components/Ripple", () => ({ + Ripple: ({ model }: { model: unknown }) => { + rippleModelSpy(model); + + return
; + }, +})); +jest.mock("../CategoryListDatagrid", () => ({ + CategoryListDatagrid: () => null, +})); +jest.mock("@saleor/macaw-ui-next", () => ({ + Box: ({ children }: { children: ReactNode }) =>
{children}
, + Button: ({ + children, + disabled, + onClick, + }: { + children: ReactNode; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), + Input: ({ value, onChange }: { value: number; onChange: (event: any) => void }) => ( + + ), + Text: ({ children }: { children: ReactNode }) => {children}, +})); + +const categoriesFixture: CategoryFragment[] = [ + { + __typename: "Category", + id: "cat-1", + name: "Category 1", + children: { + __typename: "CategoryCountableConnection", + totalCount: 0, + }, + products: { + __typename: "ProductCountableConnection", + totalCount: 0, + }, + } as CategoryFragment, +]; + +const createProps = (overrides: Partial> = {}) => ({ + categories: categoriesFixture, + currentTab: undefined, + disabled: false, + initialSearch: "", + tabs: [], + onAll: jest.fn(), + onSearchChange: jest.fn(), + onTabChange: jest.fn(), + onTabDelete: jest.fn(), + onTabSave: jest.fn(), + onTabUpdate: jest.fn(), + hasPresetsChanged: false, + onCategoriesDelete: jest.fn(), + onSelectCategoriesIds: jest.fn(), + selectedCategoriesIds: [], + sort: { + sort: CategoryListUrlSortField.name, + asc: true, + }, + onSort: jest.fn(), + settings: { + rowNumber: 20, + columns: [], + }, + onUpdateListSettings: jest.fn(), + subcategoryPageSize: 50, + onSubcategoryPageSizeChange: jest.fn(), + hasExpandedSubcategories: false, + onCollapseAllSubcategories: jest.fn(), + ...overrides, +}); + +describe("CategoryListPage", () => { + beforeEach(() => { + navigateMock.mockReset(); + rippleModelSpy.mockReset(); + }); + + it("should disable collapse all button when there are no expanded subcategories", () => { + // Arrange + render(); + + // Act + const collapseButton = screen.getByRole("button", { name: "Collapse all" }); + + // Assert + expect(collapseButton).toBeDisabled(); + }); + + it("should call collapse all callback when collapse button is clicked", () => { + // Arrange + const onCollapseAllSubcategories = jest.fn(); + + render( + , + ); + + const collapseButton = screen.getByRole("button", { name: "Collapse all" }); + + // Act + fireEvent.click(collapseButton); + + // Assert + expect(onCollapseAllSubcategories).toHaveBeenCalledTimes(1); + }); + + it("should render ripple with expandable subcategories model", () => { + // Arrange + render(); + + // Act + const renderedRippleModel = rippleModelSpy.mock.calls[0][0]; + + // Assert + expect(renderedRippleModel).toBe(rippleExpandedSubcategories); + }); +}); diff --git a/src/categories/ripples/expandedSubcategories.test.ts b/src/categories/ripples/expandedSubcategories.test.ts new file mode 100644 index 00000000000..9637e3375f1 --- /dev/null +++ b/src/categories/ripples/expandedSubcategories.test.ts @@ -0,0 +1,20 @@ +import { rippleExpandedSubcategories } from "@dashboard/categories/ripples/expandedSubcategories"; + +describe("rippleExpandedSubcategories", () => { + it("should define expandable subcategories feature ripple", () => { + // Arrange + // Act + const ripple = rippleExpandedSubcategories; + + // Assert + expect(ripple).toMatchObject({ + type: "feature", + ID: "expandable-subcategories", + TTL_seconds: 60 * 60 * 24 * 7, + content: { + oneLiner: "Expandable subcategories in category list", + }, + }); + expect(ripple.dateAdded).toEqual(new Date(2026, 1, 8)); + }); +}); diff --git a/src/categories/views/CategoryList/expandedIdsStorage.test.ts b/src/categories/views/CategoryList/expandedIdsStorage.test.ts new file mode 100644 index 00000000000..b5349706ed4 --- /dev/null +++ b/src/categories/views/CategoryList/expandedIdsStorage.test.ts @@ -0,0 +1,39 @@ +import { + normalizeStoredExpandedIds, + serializeExpandedIds, +} from "@dashboard/categories/views/CategoryList/expandedIdsStorage"; + +describe("expandedIdsStorage", () => { + it("should normalize stored ids to unique string values", () => { + // Arrange + const storedValue: unknown = ["cat-2", 10, "cat-1", "cat-2", null]; + + // Act + const result = normalizeStoredExpandedIds(storedValue); + + // Assert + expect(result).toEqual(["cat-2", "cat-1"]); + }); + + it("should return empty array for invalid storage payload", () => { + // Arrange + const storedValue: unknown = { ids: ["cat-1"] }; + + // Act + const result = normalizeStoredExpandedIds(storedValue); + + // Assert + expect(result).toEqual([]); + }); + + it("should serialize expanded ids as sorted array", () => { + // Arrange + const expandedIds = new Set(["cat-3", "cat-1", "cat-2"]); + + // Act + const result = serializeExpandedIds(expandedIds); + + // Assert + expect(result).toEqual(["cat-1", "cat-2", "cat-3"]); + }); +}); diff --git a/src/ripples/allRipples.test.ts b/src/ripples/allRipples.test.ts new file mode 100644 index 00000000000..838165520cb --- /dev/null +++ b/src/ripples/allRipples.test.ts @@ -0,0 +1,18 @@ +import { rippleExpandedSubcategories } from "@dashboard/categories/ripples/expandedSubcategories"; +import { allRipples } from "@dashboard/ripples/allRipples"; + +describe("allRipples", () => { + it("should include expandable subcategories ripple exactly once", () => { + // Arrange + const matchingRipples = allRipples.filter( + ripple => ripple.ID === rippleExpandedSubcategories.ID, + ); + + // Act + const count = matchingRipples.length; + + // Assert + expect(count).toBe(1); + expect(matchingRipples[0]).toBe(rippleExpandedSubcategories); + }); +}); From 145d2797b8e7d1b85bff407f4c8f2862b07292ac Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:26:07 +0000 Subject: [PATCH 12/15] test(core): cover row selection helpers and vite dev server port override --- src/hooks/useRowSelection.test.ts | 57 +++++++++++++++++++++++++++++++ src/vite.config.test.ts | 52 ++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/hooks/useRowSelection.test.ts create mode 100644 src/vite.config.test.ts diff --git a/src/hooks/useRowSelection.test.ts b/src/hooks/useRowSelection.test.ts new file mode 100644 index 00000000000..088eb2b4056 --- /dev/null +++ b/src/hooks/useRowSelection.test.ts @@ -0,0 +1,57 @@ +import { act } from "@testing-library/react"; +import { renderHook } from "@testing-library/react-hooks"; + +import { useRowSelection } from "./useRowSelection"; + +describe("useRowSelection", () => { + it("should set selected rows with setSelectedRows", () => { + // Arrange + const { result } = renderHook(() => useRowSelection()); + const nextSelectedIds = ["cat-1", "cat-2"]; + + // Act + act(() => { + result.current.setSelectedRows(nextSelectedIds); + }); + + // Assert + expect(result.current.selectedRowIds).toEqual(nextSelectedIds); + }); + + it("should exclude selected rows with excludeFromSelected", () => { + // Arrange + const { result } = renderHook(() => useRowSelection()); + + act(() => { + result.current.setSelectedRows(["cat-1", "cat-2", "cat-3"]); + }); + + // Act + act(() => { + result.current.excludeFromSelected(["cat-2", "cat-4"]); + }); + + // Assert + expect(result.current.selectedRowIds).toEqual(["cat-1", "cat-3"]); + }); + + it("should call datagrid clear callback and clear selected ids", () => { + // Arrange + const clearCallback = jest.fn(); + const { result } = renderHook(() => useRowSelection()); + + act(() => { + result.current.setSelectedRows(["cat-1"]); + result.current.setClearDatagridRowSelectionCallback(clearCallback); + }); + + // Act + act(() => { + result.current.clearRowSelection(); + }); + + // Assert + expect(clearCallback).toHaveBeenCalledTimes(1); + expect(result.current.selectedRowIds).toEqual([]); + }); +}); diff --git a/src/vite.config.test.ts b/src/vite.config.test.ts new file mode 100644 index 00000000000..381f20c8821 --- /dev/null +++ b/src/vite.config.test.ts @@ -0,0 +1,52 @@ +const loadEnvMock = jest.fn(); +const searchForWorkspaceRootMock = jest.fn((_root: string) => "/workspace"); + +jest.mock("@sentry/vite-plugin", () => ({ + sentryVitePlugin: jest.fn(() => ({ name: "sentry" })), +})); +jest.mock("@vitejs/plugin-react-swc", () => jest.fn(() => ({ name: "react" }))); +jest.mock("code-inspector-plugin", () => ({ + CodeInspectorPlugin: jest.fn(() => ({ name: "code-inspector" })), +})); +jest.mock("rollup-plugin-polyfill-node", () => jest.fn(() => ({ name: "node-polyfills" }))); +jest.mock("vite-plugin-html", () => ({ + createHtmlPlugin: jest.fn(() => ({ name: "html" })), +})); +jest.mock("vite", () => ({ + defineConfig: (factory: (args: { command: string; mode: string }) => unknown) => factory, + loadEnv: (...args: [string, string, string]) => loadEnvMock(args[0], args[1], args[2]), + searchForWorkspaceRoot: (root: string) => searchForWorkspaceRootMock(root), +})); + +import viteConfigFactory from "../vite.config.js"; + +describe("vite config", () => { + beforeEach(() => { + loadEnvMock.mockReset(); + searchForWorkspaceRootMock.mockClear(); + }); + + it("should use PORT_DEVSERVER when provided", () => { + // Arrange + loadEnvMock.mockReturnValue({ + PORT_DEVSERVER: "9123", + }); + + // Act + const config = viteConfigFactory({ command: "serve", mode: "development" }); + + // Assert + expect(config.server.port).toBe("9123"); + }); + + it("should fallback to default dev server port when PORT_DEVSERVER is missing", () => { + // Arrange + loadEnvMock.mockReturnValue({}); + + // Act + const config = viteConfigFactory({ command: "serve", mode: "development" }); + + // Assert + expect(config.server.port).toBe(9000); + }); +}); From 7f9b95bfef245fb7cc9aa13dffce10d77dcb9e29 Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Wed, 11 Feb 2026 07:24:30 +0000 Subject: [PATCH 13/15] feat(categories): replace expand glyphs with custom datagrid chevron cell --- .../CategoryListDatagrid/datagrid.test.ts | 14 ++++-- .../CategoryListDatagrid/datagrid.ts | 8 ++- .../Datagrid/customCells/ChevronCell.ts | 50 +++++++++++++++++++ src/components/Datagrid/customCells/cells.ts | 21 ++++++++ .../customCells/useCustomCellRenderers.ts | 2 + 5 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 src/components/Datagrid/customCells/ChevronCell.ts diff --git a/src/categories/components/CategoryListDatagrid/datagrid.test.ts b/src/categories/components/CategoryListDatagrid/datagrid.test.ts index bf6a85d0a14..c393b231785 100644 --- a/src/categories/components/CategoryListDatagrid/datagrid.test.ts +++ b/src/categories/components/CategoryListDatagrid/datagrid.test.ts @@ -78,12 +78,18 @@ describe("CategoryListDatagrid createGetCellContent", () => { // Assert expect(collapsedCell).toMatchObject({ - kind: GridCellKind.Text, - data: "⯈", + kind: GridCellKind.Custom, + data: { + kind: "chevron-cell", + direction: "right", + }, }); expect(expandedCell).toMatchObject({ - kind: GridCellKind.Text, - data: "⯆", + kind: GridCellKind.Custom, + data: { + kind: "chevron-cell", + direction: "down", + }, }); }); diff --git a/src/categories/components/CategoryListDatagrid/datagrid.ts b/src/categories/components/CategoryListDatagrid/datagrid.ts index db8b34963b5..3b75ec2687c 100644 --- a/src/categories/components/CategoryListDatagrid/datagrid.ts +++ b/src/categories/components/CategoryListDatagrid/datagrid.ts @@ -1,5 +1,9 @@ import { CategoryListUrlSortField } from "@dashboard/categories/urls"; -import { loadingCell, readonlyTextCell } from "@dashboard/components/Datagrid/customCells/cells"; +import { + chevronCell, + 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"; @@ -82,7 +86,7 @@ export const createGetCellContent = const isExpanded = isCategoryExpanded?.(rowData.id) ?? false; - return readonlyTextCell(isExpanded ? "⯆" : "⯈"); + return chevronCell(isExpanded); } case "name": const depth = getCategoryDepth?.(rowData.id) ?? 0; diff --git a/src/components/Datagrid/customCells/ChevronCell.ts b/src/components/Datagrid/customCells/ChevronCell.ts new file mode 100644 index 00000000000..a2f37d14b49 --- /dev/null +++ b/src/components/Datagrid/customCells/ChevronCell.ts @@ -0,0 +1,50 @@ +import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons"; +import { CustomRenderer, GridCellKind } from "@glideapps/glide-data-grid"; + +import { ChevronCell } from "./cells"; + +const lucideChevronPoints = { + down: [ + [6, 9], + [12, 15], + [18, 9], + ], + right: [ + [9, 6], + [15, 12], + [9, 18], + ], +} as const; + +export const chevronCellRenderer: CustomRenderer = { + kind: GridCellKind.Custom, + isMatch: (cell): cell is ChevronCell => (cell.data as { kind?: string }).kind === "chevron-cell", + draw: (args, cell) => { + const { ctx, rect, theme } = args; + const cellData = cell.data; + const points = lucideChevronPoints[cellData.direction]; + const size = iconSize.small; + const strokeWidth = iconStrokeWidthBySize.small; + const scale = size / 24; + const originX = rect.x + (rect.width - size) / 2; + const originY = rect.y + (rect.height - size) / 2; + const [firstPoint, ...restPoints] = points; + + ctx.save(); + ctx.beginPath(); + ctx.strokeStyle = theme.textDark; + ctx.lineWidth = strokeWidth; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.moveTo(originX + firstPoint[0] * scale, originY + firstPoint[1] * scale); + + restPoints.forEach(([x, y]) => { + ctx.lineTo(originX + x * scale, originY + y * scale); + }); + + ctx.stroke(); + ctx.restore(); + + return true; + }, +}; diff --git a/src/components/Datagrid/customCells/cells.ts b/src/components/Datagrid/customCells/cells.ts index cc32bef8771..80caf26b7bc 100644 --- a/src/components/Datagrid/customCells/cells.ts +++ b/src/components/Datagrid/customCells/cells.ts @@ -45,6 +45,27 @@ export function readonlyTextCell( }; } +interface ChevronCellData { + kind: "chevron-cell"; + direction: "down" | "right"; +} + +export type ChevronCell = CustomCell; + +export function chevronCell(expanded: boolean, hasCursorPointer = true): ChevronCell { + return { + cursor: hasCursorPointer ? "pointer" : "default", + allowOverlay: false, + readonly: true, + kind: GridCellKind.Custom, + copyData: "", + data: { + kind: "chevron-cell", + direction: expanded ? "down" : "right", + }, + }; +} + export function tagsCell( tags: Array<{ tag: string; color: string }>, selectedTags: string[], diff --git a/src/components/Datagrid/customCells/useCustomCellRenderers.ts b/src/components/Datagrid/customCells/useCustomCellRenderers.ts index efef63d4bfa..a88678e91fe 100644 --- a/src/components/Datagrid/customCells/useCustomCellRenderers.ts +++ b/src/components/Datagrid/customCells/useCustomCellRenderers.ts @@ -4,6 +4,7 @@ import { useExtraCells } from "@glideapps/glide-data-grid-cells"; import { useTheme } from "@saleor/macaw-ui-next"; import { useMemo } from "react"; +import { chevronCellRenderer } from "./ChevronCell"; import { dropdownCellRenderer } from "./DropdownCell"; import { moneyCellRenderer } from "./Money/MoneyCell"; import { moneyDiscountedCellRenderer } from "./Money/MoneyDiscountedCell"; @@ -24,6 +25,7 @@ export function useCustomCellRenderers() { moneyDiscountedCellRenderer(), numberCellRenderer(locale), dateCellRenderer(locale), + chevronCellRenderer, dropdownCellRenderer, thumbnailCellRenderer, ...customRenderers, From aa7641c8538bd32e666b76bcfe9e1b4fbbe1cef5 Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:02:58 +0000 Subject: [PATCH 14/15] chore: add type declaration for vite.config.js --- src/vite-env.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index e1af839d022..5241963528d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -9,3 +9,9 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +declare module "*/vite.config.js" { + const content: any; + // eslint-disable-next-line import/no-default-export + export default content; +} From 21981f2ea4274f603a6b5f4047648de0ae8ac7bf Mon Sep 17 00:00:00 2001 From: Yevhenii Bodanov <124465640+EugenBodanov@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:21:55 +0000 Subject: [PATCH 15/15] test(categories): add test coverage for ChevronCell --- .../Datagrid/customCells/ChevronCell.test.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/components/Datagrid/customCells/ChevronCell.test.ts diff --git a/src/components/Datagrid/customCells/ChevronCell.test.ts b/src/components/Datagrid/customCells/ChevronCell.test.ts new file mode 100644 index 00000000000..25b82b2101f --- /dev/null +++ b/src/components/Datagrid/customCells/ChevronCell.test.ts @@ -0,0 +1,117 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; + +import { chevronCell } from "./cells"; +import { chevronCellRenderer } from "./ChevronCell"; + +type ChevronDrawArgs = Parameters>[0]; + +interface ContextMock { + beginPath: jest.Mock; + lineCap: CanvasLineCap; + lineJoin: CanvasLineJoin; + lineTo: jest.Mock; + lineWidth: number; + moveTo: jest.Mock; + restore: jest.Mock; + save: jest.Mock; + stroke: jest.Mock; + strokeStyle: string; +} + +const createContextMock = (): ContextMock => ({ + save: jest.fn(), + beginPath: jest.fn(), + strokeStyle: "", + lineWidth: 0, + lineCap: "butt", + lineJoin: "miter", + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn(), + restore: jest.fn(), +}); + +describe("ChevronCell", () => { + it("should create collapsed chevron cell with pointer cursor", () => { + // Arrange + const expanded = false; + + // Act + const result = chevronCell(expanded); + + // Assert + expect(result).toMatchObject({ + cursor: "pointer", + allowOverlay: false, + readonly: true, + kind: GridCellKind.Custom, + data: { + kind: "chevron-cell", + direction: "right", + }, + }); + }); + + it("should create expanded chevron cell without pointer cursor", () => { + // Arrange + const expanded = true; + + // Act + const result = chevronCell(expanded, false); + + // Assert + expect(result).toMatchObject({ + cursor: "default", + data: { + kind: "chevron-cell", + direction: "down", + }, + }); + }); + + it("should match only chevron custom cells", () => { + // Arrange + const chevron = chevronCell(false); + const nonChevron = { + ...chevronCell(false), + data: { + kind: "spinner-cell", + }, + }; + + // Act + const chevronMatch = chevronCellRenderer.isMatch(chevron); + const nonChevronMatch = chevronCellRenderer.isMatch(nonChevron); + + // Assert + expect(chevronMatch).toBe(true); + expect(nonChevronMatch).toBe(false); + }); + + it("should draw a right chevron path for collapsed cell", () => { + // Arrange + const ctx = createContextMock(); + const args = { + ctx, + rect: { x: 0, y: 0, width: 32, height: 32 }, + theme: { textDark: "#111111" }, + } as unknown as ChevronDrawArgs; + + // Act + const result = chevronCellRenderer.draw(args, chevronCell(false)); + + // Assert + expect(result).toBe(true); + expect(ctx.save).toHaveBeenCalledTimes(1); + expect(ctx.beginPath).toHaveBeenCalledTimes(1); + expect(ctx.moveTo).toHaveBeenCalledWith(14, 12); + expect(ctx.lineTo).toHaveBeenNthCalledWith(1, 18, 16); + expect(ctx.lineTo).toHaveBeenNthCalledWith(2, 14, 20); + expect(ctx.strokeStyle).toBe("#111111"); + expect(ctx.lineWidth).toBe(2); + expect(ctx.lineCap).toBe("round"); + expect(ctx.lineJoin).toBe("round"); + expect(ctx.stroke).toHaveBeenCalledTimes(1); + expect(ctx.restore).toHaveBeenCalledTimes(1); + }); +});