diff --git a/.changeset/loud-bags-behave.md b/.changeset/loud-bags-behave.md new file mode 100644 index 000000000..9d8122471 --- /dev/null +++ b/.changeset/loud-bags-behave.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": minor +--- + +Introduce two new hook to handle datagrid, useFilterPresets to handle CRUD operation on filter presets and useRowSelection to handle datagrid row selection diff --git a/src/hooks/useFilterHandlers.test.ts b/src/hooks/useFilterHandlers.test.ts index cdb55fc57..cfd593672 100644 --- a/src/hooks/useFilterHandlers.test.ts +++ b/src/hooks/useFilterHandlers.test.ts @@ -249,7 +249,7 @@ describe("useFilterHandlers", () => { query: "query", }, defaultSortField: "", - keepActiveTab: true, + keepActiveTab: false, }), ); @@ -287,7 +287,7 @@ describe("useFilterHandlers", () => { }, hasSortWithRank: true, defaultSortField: "", - keepActiveTab: true, + keepActiveTab: false, }), ); diff --git a/src/hooks/useFilterHandlers.ts b/src/hooks/useFilterHandlers.ts index 9c8740472..3a6cb7a65 100644 --- a/src/hooks/useFilterHandlers.ts +++ b/src/hooks/useFilterHandlers.ts @@ -120,7 +120,7 @@ export const useFilterHandlers = < ...params, after: undefined, before: undefined, - activeTab: getActiveTabValue(checkIfParamsEmpty(params) && hasQuery), + activeTab: getActiveTabValue(checkIfParamsEmpty(params) && !hasQuery), query: hasQuery ? trimmedQuery : undefined, ...(hasSortWithRank && { sort: hasQuery ? sortWithQuery : sortWithoutQuery, diff --git a/src/hooks/useFilterPresets/index.ts b/src/hooks/useFilterPresets/index.ts new file mode 100644 index 000000000..b9247d519 --- /dev/null +++ b/src/hooks/useFilterPresets/index.ts @@ -0,0 +1 @@ +export * from "./useFilterPresets"; diff --git a/src/hooks/useFilterPresets/useFilterPresets.test.ts b/src/hooks/useFilterPresets/useFilterPresets.test.ts new file mode 100644 index 000000000..83a2dbb1c --- /dev/null +++ b/src/hooks/useFilterPresets/useFilterPresets.test.ts @@ -0,0 +1,232 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { useFilterPresets } from "./useFilterPresets"; + +const mockNavigate = jest.fn(); +jest.mock("@dashboard/hooks/useNavigator", () => () => mockNavigate); + +const baseUrl = "http://localhost"; + +describe("useFilterPresets", () => { + const originalWindowLocation = window.location; + + beforeEach(() => { + Object.defineProperty(window, "location", { + configurable: true, + enumerable: true, + value: new URL(window.location.href), + }); + }); + + afterEach(() => { + Object.defineProperty(window, "location", { + configurable: true, + enumerable: true, + value: originalWindowLocation, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should return saved filter presets from storage", () => { + // Arrange && Act + const presets = [ + { name: "preset1", data: "data1" }, + { name: "preset2", data: "data2" }, + ]; + + const { result } = renderHook(() => + useFilterPresets({ + getUrl: jest.fn(), + params: {}, + reset: jest.fn(), + storageUtils: { + deleteFilterTab: jest.fn(), + getFilterTabs: jest.fn(() => presets), + saveFilterTab: jest.fn(), + updateFilterTab: jest.fn(), + }, + }), + ); + + // Assert + expect(result.current.presets).toEqual(presets); + }); + + it("should return selected preset index when activeTab param", () => { + // Arrange & Act + const { result } = renderHook(() => + useFilterPresets({ + getUrl: jest.fn(), + params: { + activeTab: "1", + }, + reset: jest.fn(), + storageUtils: { + deleteFilterTab: jest.fn(), + getFilterTabs: jest.fn(() => []), + saveFilterTab: jest.fn(), + updateFilterTab: jest.fn(), + }, + }), + ); + + // Assert + expect(result.current.selectedPreset).toEqual(1); + }); + + it("should handle active filter preset change", () => { + // Arrange + const savedPreset = { name: "preset1", data: "query=John" }; + const { result } = renderHook(() => + useFilterPresets({ + getUrl: jest.fn(() => baseUrl), + params: {}, + reset: jest.fn(), + storageUtils: { + deleteFilterTab: jest.fn(), + getFilterTabs: jest.fn(() => [savedPreset]), + saveFilterTab: jest.fn(), + updateFilterTab: jest.fn(), + }, + }), + ); + + // Act + result.current.onPresetChange(1); + + // Assert + expect(mockNavigate).toHaveBeenCalledWith( + `${baseUrl}?${savedPreset.data}&activeTab=1`, + ); + }); + + it("should handle preset delete and navigate to base url when active preset is equal deleting preset", () => { + // Arrange + const mockDeleteStorage = jest.fn(); + const mockReset = jest.fn(); + + const { result } = renderHook(() => + useFilterPresets({ + getUrl: jest.fn(() => baseUrl), + params: { + action: "delete", + activeTab: "1", + }, + reset: mockReset, + storageUtils: { + deleteFilterTab: mockDeleteStorage, + getFilterTabs: jest.fn(), + saveFilterTab: jest.fn(), + updateFilterTab: jest.fn(), + }, + }), + ); + + // Act( + result.current.setPresetIdToDelete(1); + result.current.onPresetDelete(); + + // Assert + expect(mockDeleteStorage).toHaveBeenCalledWith(1); + expect(mockReset).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(baseUrl); + }); + + it("should handle preset delete and navigate to active preset when preset to delete is different that preset to delete", () => { + // Arrange + const mockDeleteStorage = jest.fn(); + const mockReset = jest.fn(); + + const { result } = renderHook(() => + useFilterPresets({ + getUrl: jest.fn(() => baseUrl), + params: { + action: "delete", + activeTab: "2", + }, + reset: mockReset, + storageUtils: { + deleteFilterTab: mockDeleteStorage, + getFilterTabs: jest.fn(), + saveFilterTab: jest.fn(), + updateFilterTab: jest.fn(), + }, + }), + ); + + // Act( + result.current.setPresetIdToDelete(1); + result.current.onPresetDelete(); + + // Assert + expect(mockDeleteStorage).toHaveBeenCalledWith(1); + expect(mockReset).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(baseUrl + "?activeTab=1"); + }); + + it("should handle save new filter preset", () => { + // Arrange + const mockSaveStorage = jest.fn(); + window.location.search = "?query=John"; + + const { result } = renderHook(() => + useFilterPresets({ + getUrl: jest.fn(() => baseUrl), + params: {}, + reset: jest.fn(), + storageUtils: { + deleteFilterTab: jest.fn(), + getFilterTabs: jest.fn(() => []), + saveFilterTab: mockSaveStorage, + updateFilterTab: jest.fn(), + }, + }), + ); + + // Act + result.current.onPresetSave({ name: "new-preset" }); + + // Assert + expect(mockSaveStorage).toHaveBeenCalledWith("new-preset", "query=John"); + expect(mockNavigate).toHaveBeenCalledWith(`${baseUrl}?activeTab=1`); + }); + + it("should handle update existing filter preset", () => { + // Arrange + const mockUpdateStorage = jest.fn(); + window.location.search = "?query=JoeDoe"; + + const { result } = renderHook(() => + useFilterPresets({ + getUrl: jest.fn(() => baseUrl), + params: {}, + reset: jest.fn(), + storageUtils: { + deleteFilterTab: jest.fn(), + getFilterTabs: jest.fn(() => [ + { + name: "current-preset", + data: "query=John", + }, + ]), + saveFilterTab: jest.fn(), + updateFilterTab: mockUpdateStorage, + }, + }), + ); + + // Act + result.current.onPresetUpdate("current-preset"); + + // Assert + expect(mockUpdateStorage).toHaveBeenCalledWith( + "current-preset", + "query=JoeDoe", + ); + expect(mockNavigate).toHaveBeenCalledWith( + `${baseUrl}?query=John&activeTab=1`, + ); + }); +}); diff --git a/src/hooks/useFilterPresets/useFilterPresets.ts b/src/hooks/useFilterPresets/useFilterPresets.ts new file mode 100644 index 000000000..6995b94e7 --- /dev/null +++ b/src/hooks/useFilterPresets/useFilterPresets.ts @@ -0,0 +1,124 @@ +import { SaveFilterTabDialogFormData } from "@dashboard/components/SaveFilterTabDialog"; +import useNavigator from "@dashboard/hooks/useNavigator"; +import { + getActiveTabIndexAfterTabDelete, + getNextUniqueTabName, +} from "@dashboard/products/views/ProductList/utils"; +import { StorageUtils } from "@dashboard/utils/filters"; +import { prepareQs } from "@dashboard/utils/filters/qs"; +import { stringify } from "qs"; +import { useState } from "react"; + +export const useFilterPresets = < + T extends { activeTab?: string; action?: string }, +>({ + params, + reset, + storageUtils, + getUrl, +}: { + params: T; + reset: () => void; + getUrl: () => string; + storageUtils: StorageUtils; +}) => { + const navigate = useNavigator(); + const baseUrl = getUrl(); + const [presetIdToDelete, setPresetIdToDelete] = useState(null); + + const presets = storageUtils.getFilterTabs(); + + const selectedPreset = + params.activeTab !== undefined && typeof params.activeTab === "string" + ? parseInt(params.activeTab, 10) + : undefined; + + const onPresetChange = (index: number) => { + reset(); + const currentPresets = storageUtils.getFilterTabs(); + const qs = new URLSearchParams(currentPresets[index - 1]?.data ?? ""); + qs.append("activeTab", index.toString()); + + navigate( + baseUrl.endsWith("?") + ? baseUrl + qs.toString() + : baseUrl + "?" + qs.toString(), + ); + }; + + const onPresetDelete = () => { + if (!presetIdToDelete) { + return; + } + + storageUtils.deleteFilterTab(presetIdToDelete); + reset(); + + // When deleting the current tab, navigate to the All products + if (presetIdToDelete === selectedPreset || !selectedPreset) { + navigate(baseUrl); + } else { + const currentParams = { ...params }; + // When deleting a tab that is not the current one, only remove the action param from the query + delete currentParams.action; + // When deleting a tab that is before the current one, decrease the activeTab param by 1 + currentParams.activeTab = getActiveTabIndexAfterTabDelete( + selectedPreset, + presetIdToDelete, + ); + navigate( + baseUrl.endsWith("?") + ? baseUrl + stringify(currentParams) + : baseUrl + "?" + stringify(currentParams), + ); + } + }; + + const onPresetSave = (data: SaveFilterTabDialogFormData) => { + const { parsedQs } = prepareQs(location.search); + + storageUtils.saveFilterTab( + getNextUniqueTabName( + data.name, + presets.map(tab => tab.name), + ), + stringify(parsedQs), + ); + onPresetChange(presets.length + 1); + }; + + const onPresetUpdate = (tabName: string) => { + const { parsedQs } = prepareQs(location.search); + + storageUtils.updateFilterTab(tabName, stringify(parsedQs)); + onPresetChange(presets.findIndex(tab => tab.name === tabName) + 1); + }; + + const hasPresetsChange = () => { + const { parsedQs } = prepareQs(location.search); + + if (!selectedPreset) { + return location.search !== "" && stringify(parsedQs) !== ""; + } + + const activeTab = presets[selectedPreset - 1]; + + return ( + activeTab?.data !== stringify(parsedQs) && + location.search !== "" && + stringify(parsedQs) !== "" + ); + }; + + return { + presetIdToDelete, + setPresetIdToDelete, + presets, + selectedPreset, + onPresetChange, + onPresetDelete, + onPresetSave, + onPresetUpdate, + hasPresetsChange, + }; +}; diff --git a/src/hooks/useRowSelection.ts b/src/hooks/useRowSelection.ts new file mode 100644 index 000000000..b00939da5 --- /dev/null +++ b/src/hooks/useRowSelection.ts @@ -0,0 +1,32 @@ +import { Pagination } from "@dashboard/types"; +import { useEffect, useRef, useState } from "react"; + +export const useRowSelection = (paginationParams?: Pagination) => { + const [selectedRowIds, setSelectedRowIds] = useState([]); + + // Keep reference to clear datagrid selection function + const clearDatagridRowSelectionCallback = useRef<(() => void) | null>(null); + + const clearRowSelection = () => { + setSelectedRowIds([]); + if (clearDatagridRowSelectionCallback.current) { + clearDatagridRowSelectionCallback.current(); + } + }; + + const setClearDatagridRowSelectionCallback = (callback: () => void) => { + clearDatagridRowSelectionCallback.current = callback; + }; + + // Whenever pagination change we need to clear datagrid selection + useEffect(() => { + clearRowSelection(); + }, [paginationParams?.after, paginationParams?.before]); + + return { + selectedRowIds, + setSelectedRowIds, + clearRowSelection, + setClearDatagridRowSelectionCallback, + }; +}; diff --git a/src/orders/views/OrderList/OrderList.tsx b/src/orders/views/OrderList/OrderList.tsx index 8a576edbb..308f4a094 100644 --- a/src/orders/views/OrderList/OrderList.tsx +++ b/src/orders/views/OrderList/OrderList.tsx @@ -114,12 +114,12 @@ export const OrderList: React.FC = ({ params }) => { const hasPresetsChanged = () => { const activeTab = tabs[currentTab - 1]; - const { paresedQs } = prepareQs(location.search); + const { parsedQs } = prepareQs(location.search); return ( - activeTab?.data !== stringify(paresedQs) && + activeTab?.data !== stringify(parsedQs) && location.search !== "" && - stringify(paresedQs) !== "" + stringify(parsedQs) !== "" ); }; @@ -150,23 +150,23 @@ export const OrderList: React.FC = ({ params }) => { }; const hanleFilterTabUpdate = (tabName: string) => { - const { paresedQs, activeTab } = prepareQs(location.search); + const { parsedQs, activeTab } = prepareQs(location.search); - updateFilterTab(tabName, stringify(paresedQs)); - paresedQs.activeTab = activeTab; + updateFilterTab(tabName, stringify(parsedQs)); + parsedQs.activeTab = activeTab; - navigate(orderListUrl() + "?" + stringify(paresedQs)); + navigate(orderListUrl() + "?" + stringify(parsedQs)); }; const handleFilterTabSave = (data: SaveFilterTabDialogFormData) => { - const { paresedQs } = prepareQs(location.search); + const { parsedQs } = prepareQs(location.search); saveFilterTab( getNextUniqueTabName( data.name, tabs.map(tab => tab.name), ), - stringify(paresedQs), + stringify(parsedQs), ); handleTabChange(tabs.length + 1); }; diff --git a/src/products/views/ProductList/ProductList.tsx b/src/products/views/ProductList/ProductList.tsx index 00abe0210..996c4acc8 100644 --- a/src/products/views/ProductList/ProductList.tsx +++ b/src/products/views/ProductList/ProductList.tsx @@ -298,25 +298,25 @@ export const ProductList: React.FC = ({ params }) => { }; const handleFilterTabSave = (data: SaveFilterTabDialogFormData) => { - const { paresedQs } = prepareQs(location.search); + const { parsedQs } = prepareQs(location.search); saveFilterTab( getNextUniqueTabName( data.name, tabs.map(tab => tab.name), ), - stringify(paresedQs), + stringify(parsedQs), ); handleTabChange(tabs.length + 1); }; const hanleFilterTabUpdate = (tabName: string) => { - const { paresedQs, activeTab } = prepareQs(location.search); + const { parsedQs, activeTab } = prepareQs(location.search); - updateFilterTab(tabName, stringify(paresedQs)); - paresedQs.activeTab = activeTab; + updateFilterTab(tabName, stringify(parsedQs)); + parsedQs.activeTab = activeTab; - navigate(productListUrl() + stringify(paresedQs)); + navigate(productListUrl() + stringify(parsedQs)); }; const handleSort = (field: ProductListUrlSortField, attributeId?: string) => @@ -442,12 +442,12 @@ export const ProductList: React.FC = ({ params }) => { const hasPresetsChanged = () => { const activeTab = tabs[currentTab - 1]; - const { paresedQs } = prepareQs(location.search); + const { parsedQs } = prepareQs(location.search); return ( - activeTab?.data !== stringify(paresedQs) && + activeTab?.data !== stringify(parsedQs) && location.search !== "" && - stringify(paresedQs) !== "" + stringify(parsedQs) !== "" ); }; diff --git a/src/utils/filters/qs.test.ts b/src/utils/filters/qs.test.ts index 22622f8a9..449fc4434 100644 --- a/src/utils/filters/qs.test.ts +++ b/src/utils/filters/qs.test.ts @@ -7,7 +7,7 @@ describe("Filters: preapreQS", () => { ); expect(qs).toEqual({ activeTab: "1", - paresedQs: { + parsedQs: { category: "1", channel: "usa", }, diff --git a/src/utils/filters/qs.ts b/src/utils/filters/qs.ts index 3dbf27301..b6e209a74 100644 --- a/src/utils/filters/qs.ts +++ b/src/utils/filters/qs.ts @@ -3,17 +3,17 @@ import { parse } from "qs"; const paramsToRemove = ["activeTab", "action", "sort", "asc"]; export const prepareQs = (searchQuery: string) => { - const paresedQs = parse( + const parsedQs = parse( searchQuery.startsWith("?") ? searchQuery.slice(1) : searchQuery, ); - const activeTab = paresedQs.activeTab; + const activeTab = parsedQs.activeTab; paramsToRemove.forEach(param => { - delete paresedQs[param]; + delete parsedQs[param]; }); return { activeTab, - paresedQs, + parsedQs, }; }; diff --git a/src/utils/filters/storage.ts b/src/utils/filters/storage.ts index 38edc6416..cdc2a50e9 100644 --- a/src/utils/filters/storage.ts +++ b/src/utils/filters/storage.ts @@ -59,8 +59,16 @@ function deleteFilterTab(id: number, key: string) { JSON.stringify([...userFilters.slice(0, id - 1), ...userFilters.slice(id)]), ); } +export interface StorageUtils { + getFilterTabs: () => GetFilterTabsOutput; + deleteFilterTab: (id: number) => void; + saveFilterTab: (name: string, data: TUrlFilters) => void; + updateFilterTab: (name: string, data: TUrlFilters) => void; +} -function createFilterTabUtils(key: string) { +function createFilterTabUtils( + key: string, +): StorageUtils { return { deleteFilterTab: (id: number) => deleteFilterTab(id, key), getFilterTabs: () => getFilterTabs(key),