Introduce usePresetFilters and useRowSelction hooks (#3836)

This commit is contained in:
Paweł Chyła 2023-07-03 13:02:17 +02:00 committed by GitHub
parent 5eb6888c42
commit 158b22d1ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 429 additions and 27 deletions

View file

@ -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

View file

@ -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,
}),
);

View file

@ -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,

View file

@ -0,0 +1 @@
export * from "./useFilterPresets";

View file

@ -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`,
);
});
});

View file

@ -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<string>;
}) => {
const navigate = useNavigator();
const baseUrl = getUrl();
const [presetIdToDelete, setPresetIdToDelete] = useState<number | null>(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,
};
};

View file

@ -0,0 +1,32 @@
import { Pagination } from "@dashboard/types";
import { useEffect, useRef, useState } from "react";
export const useRowSelection = (paginationParams?: Pagination) => {
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
// 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,
};
};

View file

@ -114,12 +114,12 @@ export const OrderList: React.FC<OrderListProps> = ({ 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<OrderListProps> = ({ 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);
};

View file

@ -298,25 +298,25 @@ export const ProductList: React.FC<ProductListProps> = ({ 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<ProductListProps> = ({ 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) !== ""
);
};

View file

@ -7,7 +7,7 @@ describe("Filters: preapreQS", () => {
);
expect(qs).toEqual({
activeTab: "1",
paresedQs: {
parsedQs: {
category: "1",
channel: "usa",
},

View file

@ -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,
};
};

View file

@ -59,8 +59,16 @@ function deleteFilterTab(id: number, key: string) {
JSON.stringify([...userFilters.slice(0, id - 1), ...userFilters.slice(id)]),
);
}
export interface StorageUtils<TUrlFilters> {
getFilterTabs: () => GetFilterTabsOutput<TUrlFilters>;
deleteFilterTab: (id: number) => void;
saveFilterTab: (name: string, data: TUrlFilters) => void;
updateFilterTab: (name: string, data: TUrlFilters) => void;
}
function createFilterTabUtils<TUrlFilters>(key: string) {
function createFilterTabUtils<TUrlFilters>(
key: string,
): StorageUtils<TUrlFilters> {
return {
deleteFilterTab: (id: number) => deleteFilterTab(id, key),
getFilterTabs: () => getFilterTabs<TUrlFilters>(key),