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. diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index b832aec332a..007926ed809 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" @@ -10319,6 +10322,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/package.json b/package.json index 388e2907c49..76961a4fa12 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,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 7834f8c2e17..8d03aebb22d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -424,8 +424,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)) @@ -8042,14 +8042,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: @@ -19444,7 +19444,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) 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/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={() => ( + ({ + __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.Custom, + data: { + kind: "chevron-cell", + direction: "right", + }, + }); + expect(expandedCell).toMatchObject({ + kind: GridCellKind.Custom, + data: { + kind: "chevron-cell", + direction: "down", + }, + }); + }); + + 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/CategoryListDatagrid/datagrid.ts b/src/categories/components/CategoryListDatagrid/datagrid.ts index 2b72c4410d7..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 { 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"; @@ -9,6 +13,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, @@ -34,8 +51,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 +73,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 chevronCell(isExpanded); + } 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.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/components/CategoryListPage/CategoryListPage.tsx b/src/categories/components/CategoryListPage/CategoryListPage.tsx index 458b01be760..6551e6d6c02 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,9 +16,10 @@ 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 } 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 +37,15 @@ 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; + hasExpandedSubcategories: boolean; + onCollapseAllSubcategories: () => void; } export const CategoryListPage = ({ @@ -51,9 +62,19 @@ export const CategoryListPage = ({ onTabUpdate, hasPresetsChanged, onCategoriesDelete, + onSelectCategoriesIds, + onSelectedCategoriesIdsChange, selectedCategoriesIds, + isCategoryExpanded, + isCategoryChildrenLoading, + onCategoryExpandToggle, + getCategoryDepth, + subcategoryPageSize, + onSubcategoryPageSizeChange, + hasExpandedSubcategories, + onCollapseAllSubcategories, ...listProps -}: CategoryTableProps) => { +}: CategoryTableProps): JSX.Element => { const navigate = useNavigator(); const intl = useIntl(); @@ -67,6 +88,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 +161,50 @@ 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..197f2897f9c 100644 --- a/src/categories/components/CategoryListPage/messages.ts +++ b/src/categories/components/CategoryListPage/messages.ts @@ -18,4 +18,12 @@ export const messages = defineMessages({ defaultMessage: "Delete categories", id: "FiO/W/", }, + subcategoriesPageSizeLabel: { + id: "8zbcLs", + defaultMessage: "№ of subcategories", + }, + collapseAllSubcategories: { + id: "v0fBOU", + defaultMessage: "Collapse all", + }, }); 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/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/ripples/expandedSubcategories.ts b/src/categories/ripples/expandedSubcategories.ts new file mode 100644 index 00000000000..6af0615c233 --- /dev/null +++ b/src/categories/ripples/expandedSubcategories.ts @@ -0,0 +1,15 @@ +import { Ripple } from "@dashboard/ripples/types"; + +export const rippleExpandedSubcategories: Ripple = { + type: "feature", + ID: "expandable-subcategories", + TTL_seconds: 60 * 60 * 24 * 7, // 7 days + dateAdded: new Date(2026, 1, 8), + content: { + oneLiner: "Expandable subcategories in category list", + contextual: + "Expand category rows to browse subcategories in place. Adjust how many subcategories are loaded with this control.", + global: + "The categories list now supports expandable rows with lazy loading for child categories. You can review nested structures without leaving the list and control how many subcategories are fetched per parent.", + }, +}; diff --git a/src/categories/views/CategoryList/CategoryList.tsx b/src/categories/views/CategoryList/CategoryList.tsx index cb67d2eba72..a608850c463 100644 --- a/src/categories/views/CategoryList/CategoryList.tsx +++ b/src/categories/views/CategoryList/CategoryList.tsx @@ -1,13 +1,19 @@ +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"; 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"; @@ -23,8 +29,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 { @@ -33,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"; @@ -40,7 +52,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 +84,7 @@ const CategoryList = ({ params }: CategoryListProps) => { setSelectedRowIds, clearRowSelection, setClearDatagridRowSelectionCallback, + excludeFromSelected, } = useRowSelection(params); const { hasPresetsChanged, @@ -94,7 +128,11 @@ const CategoryList = ({ params }: CategoryListProps) => { paginationState, queryString: params, }); - const changeFilterField = (filter: CategoryListUrlFilters) => { + const [storedExpandedIds, setStoredExpandedIds] = useLocalStorage( + CATEGORY_LIST_EXPANDED_IDS_STORAGE_KEY, + storedIds => normalizeStoredExpandedIds(storedIds), + ); + const changeFilterField = (filter: CategoryListUrlFilters): void => { clearRowSelection(); navigate( categoryListUrl({ @@ -104,54 +142,496 @@ 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(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()); + 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; + }); + }, []); + + useEffect(() => { + const serializedExpandedIds = serializeExpandedIds(expandedIds); + + if (!isEqual(serializedExpandedIds, storedExpandedIds)) { + setStoredExpandedIds(serializedExpandedIds); } - }; - const handleSetSelectedCategoryIds = useCallback( - (rows: number[], clearSelection: () => void) => { - if (!categories) { + }, [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], + ); + const getSelectedWithLoadedDescendants = useCallback( + (ids: string[]) => { + const selectedWithDescendants = new Set(ids); + + ids.forEach(id => { + collectDescendantIds(id, getCachedChildrenByParentId).forEach(descendantId => { + selectedWithDescendants.add(descendantId); + }); + }); + + 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); - if (!haveSaveValues) { - setSelectedRowIds(rowsIds); + 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); + + 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 handleCollapseAllSubcategories = useCallback(() => { + setExpandedIds(new Set()); + setLoadingChildrenIds(new Set()); + clearRowSelection(); + }, [clearRowSelection]); + + 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 +654,16 @@ 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} + hasExpandedSubcategories={expandedIds.size > 0} + onCollapseAllSubcategories={handleCollapseAllSubcategories} /> { 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 +724,5 @@ const CategoryList = ({ params }: CategoryListProps) => { ); }; +// eslint-disable-next-line import/no-default-export export default CategoryList; 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/categories/views/CategoryList/expandedIdsStorage.ts b/src/categories/views/CategoryList/expandedIdsStorage.ts new file mode 100644 index 00000000000..04392ed5199 --- /dev/null +++ b/src/categories/views/CategoryList/expandedIdsStorage.ts @@ -0,0 +1,14 @@ +export const CATEGORY_LIST_EXPANDED_IDS_STORAGE_KEY = "categoryListExpandedIds:v1"; + +export const normalizeStoredExpandedIds = (storedExpandedIds: unknown): string[] => { + 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(); 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) => { 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); + }); +}); 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, 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; }>; 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/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, }; 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); + }); +}); diff --git a/src/ripples/allRipples.ts b/src/ripples/allRipples.ts index 04ba423293c..d309d4c115c 100644 --- a/src/ripples/allRipples.ts +++ b/src/ripples/allRipples.ts @@ -1,4 +1,5 @@ import { rippleAttributeValuesSearch } from "@dashboard/attributes/ripples/attributeValuesSearch"; +import { rippleExpandedSubcategories } from "@dashboard/categories/ripples/expandedSubcategories"; import { checkoutAutocompleteSettings } from "@dashboard/channels/ripples/checkoutAutocompleteSettings"; import { rippleCloudEnvLink } from "@dashboard/components/Sidebar/ripples/cloudEnvLink"; import { ripplePagesAreModels } from "@dashboard/modeling/ripples/pagesAreModels"; @@ -18,6 +19,9 @@ export const allRipples: Ripple[] = [ // Modelling / pages ripplePagesAreModels, + // Categories + rippleExpandedSubcategories, + // Orders rippleNewRefundReasons, rippleOrderMetadata, 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; +} 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); + }); +}); 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()), "../.."], },