This commit is contained in:
Stefan Pejcic
2024-11-07 19:03:37 +01:00
parent c6df945ed5
commit 09f9f9502d
2472 changed files with 620417 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
import { waitFor } from "@testing-library/react";
import { asyncDebounce } from ".";
describe("asyncDebounce", () => {
it("should debounce the function", async () => {
const fn = jest.fn((num: number) => Promise.resolve(num));
const debounced = asyncDebounce(fn, 1000);
const result1 = debounced(1);
const result2 = debounced(2);
const result3 = debounced(3);
expect(fn).not.toHaveBeenCalled();
await Promise.allSettled([result1, result2, result3]);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith(3);
});
it("should flush the debounced function", async () => {
jest.useRealTimers();
const fn = jest.fn((num: number) => Promise.resolve(num));
const catcher = jest.fn();
const debounced = asyncDebounce(fn, 1000);
debounced(0).catch(catcher);
const result1 = debounced(1);
debounced.flush();
const result2 = debounced(2);
await Promise.allSettled([result1, result2]);
expect(catcher).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenCalledWith(1);
expect(fn).toHaveBeenCalledWith(2);
});
it("should cancel the debounced function", async () => {
const fn = jest.fn((num: number) => Promise.resolve(num));
const catcher = jest.fn();
const debounced = asyncDebounce(fn, 1000);
debounced(1).catch(catcher);
debounced.cancel();
expect(fn).not.toHaveBeenCalled();
await waitFor(() => expect(catcher).toHaveBeenCalledTimes(1));
});
it("should respect the wait time", async () => {
jest.useFakeTimers();
const fn = jest.fn((num: number) => Promise.resolve(num));
const catcher = jest.fn();
const debounced = asyncDebounce(fn, 2000);
debounced(1).catch(catcher);
jest.advanceTimersByTime(1000);
debounced(2).catch(catcher);
jest.advanceTimersByTime(2000);
await waitFor(() => expect(fn).toHaveBeenCalledTimes(1));
await waitFor(() => expect(fn).toHaveBeenCalledWith(2));
await waitFor(() => expect(catcher).toHaveBeenCalledTimes(1));
jest.useRealTimers();
});
it("should debounce non-promises", async () => {
const fn = jest.fn((num: number) => num);
const catcher = jest.fn();
const debounced = asyncDebounce(fn, 1000);
const result1 = debounced(1).catch(catcher);
const result2 = debounced(2).catch(catcher);
const result3 = debounced(3).catch(catcher);
expect(fn).not.toHaveBeenCalled();
await Promise.allSettled([result1, result2, result3]);
await waitFor(() => expect(fn).toHaveBeenCalledTimes(1));
await waitFor(() => expect(fn).toHaveBeenCalledWith(3));
await waitFor(() => expect(catcher).toHaveBeenCalledTimes(2));
});
it("should reject by cancel reason", async () => {
const fn = jest.fn((num: number) => Promise.resolve(num));
const catcher = jest.fn();
const debounced = asyncDebounce(fn, 1000, "canceled");
debounced(1).catch(catcher);
debounced(2).catch(catcher);
await waitFor(() => expect(catcher).toHaveBeenCalledTimes(1));
await waitFor(() => expect(catcher).toHaveBeenCalledWith("canceled"));
debounced.cancel();
await waitFor(() => expect(catcher).toHaveBeenCalledTimes(2));
await waitFor(() => expect(catcher).toHaveBeenCalledWith("canceled"));
});
it("should call the correct callback in long awaits", async () => {
const resolvedMock = jest.fn();
const fn = jest.fn(
(num: number) =>
new Promise((res) => {
setTimeout(() => {
resolvedMock(num);
res(num);
}, 2000);
}),
);
const catcher = jest.fn();
const resolver = jest.fn();
const nextResolver = jest.fn();
const debounced = asyncDebounce(fn, 1000, "canceled");
const options = { timeout: 3000 };
debounced(1).catch(catcher);
debounced(2).catch(catcher);
debounced(3).then(resolver).catch(catcher);
await waitFor(() => expect(catcher).toHaveBeenCalledTimes(2));
// wait for function to be triggered
await waitFor(() => expect(fn).toHaveBeenCalledTimes(1));
// but not resolved yet
await waitFor(() => expect(resolvedMock).not.toBeCalled());
// call the debounced again
debounced(4).then(nextResolver).catch(catcher);
// next call should not interrupt the previous call and successfully resolve with `resolver`
await waitFor(() => expect(resolver).toBeCalledTimes(1), options);
await waitFor(() => expect(resolvedMock).toBeCalledWith(3));
// next call should not be resolved
await waitFor(() => expect(nextResolver).not.toBeCalled());
// wait for second call to be made
await waitFor(() => expect(fn).toHaveBeenCalledTimes(2), options);
// wait for it to be resolved
await waitFor(() => expect(nextResolver).toBeCalledTimes(1), options);
await waitFor(() => expect(resolvedMock).toBeCalledWith(4));
// fourth call should not reject the third because it's already resolved
await waitFor(() => expect(catcher).toHaveBeenCalledTimes(2));
// third call should not be resolved again
await waitFor(() => expect(resolver).toHaveBeenCalledTimes(1));
});
it("should cancel previous and reject last if errored", async () => {
const fn = jest.fn((num: number) => {
if (num === 3) {
return Promise.reject("error");
}
return Promise.resolve(num);
});
const catcher = jest.fn();
const debounced = asyncDebounce(fn, 1000, "canceled");
debounced(1).catch(catcher);
debounced(2).catch(catcher);
await waitFor(() => expect(catcher).toHaveBeenCalledTimes(1));
await waitFor(() => expect(catcher).toHaveBeenCalledWith("canceled"));
debounced(3).catch(catcher);
await waitFor(() => expect(catcher).toHaveBeenCalledTimes(2));
await waitFor(() => expect(catcher).toHaveBeenCalledWith("canceled"));
await waitFor(() => expect(catcher).toHaveBeenCalledTimes(3));
await waitFor(() => expect(catcher).toHaveBeenCalledWith("error"));
});
});

View File

@@ -0,0 +1,63 @@
import debounce from "lodash/debounce";
type Callbacks<T extends (...args: any) => any> = {
resolve?: (value: Awaited<ReturnType<T>>) => void;
reject?: (reason?: any) => void;
};
type DebouncedFunction<T extends (...args: any) => any> = {
(...args: Parameters<T>): Promise<Awaited<ReturnType<T>>>;
flush: () => void;
cancel: () => void;
};
/**
* Debounces sync and async functions with given wait time. The debounced function returns a promise which can be awaited or catched.
* Only the last call of the debounced function will resolve or reject.
* Previous calls will be rejected with the given cancelReason.
*
* The original debounce function doesn't work well with async functions,
* It won't return a promise to resolve/reject and therefore it's not possible to await the result.
* This will always return a promise to handle and await the result.
* Previous calls will be rejected immediately after a new call made.
*/
export const asyncDebounce = <T extends (...args: any[]) => any>(
func: T,
wait = 1000,
cancelReason?: string,
): DebouncedFunction<T> => {
let callbacks: Array<Callbacks<T>> = [];
const cancelPrevious = () => {
callbacks.forEach((cb) => cb.reject?.(cancelReason));
callbacks = [];
};
const debouncedFunc = debounce((...args: Parameters<T>) => {
const { resolve, reject } = callbacks.pop() || {};
Promise.resolve(func(...args))
.then(resolve)
.catch(reject);
}, wait);
const runner = (...args: Parameters<T>) => {
return new Promise<Awaited<ReturnType<T>>>((resolve, reject) => {
cancelPrevious();
callbacks.push({
resolve,
reject,
});
debouncedFunc(...args);
});
};
runner.flush = () => debouncedFunc.flush();
runner.cancel = () => {
debouncedFunc.cancel();
cancelPrevious();
};
return runner;
};

View File

@@ -0,0 +1,13 @@
import { mockLegacyRouterProvider, mockRouterProvider } from "@test/index";
import { checkRouterPropMisuse } from ".";
describe("checkRouterPropMisuse", () => {
it("should return false when pass routerBindings", () => {
expect(checkRouterPropMisuse(mockRouterProvider())).toBeFalsy();
});
it("should return true when pass legacyRouterProvider", () => {
expect(checkRouterPropMisuse(mockLegacyRouterProvider())).toBeTruthy();
});
});

View File

@@ -0,0 +1,31 @@
import type { LegacyRouterProvider } from "../../../contexts/router/legacy/types";
import type { RouterProvider } from "../../../contexts/router/types";
export const checkRouterPropMisuse = (
value: LegacyRouterProvider | RouterProvider,
) => {
// check if `routerProvider` prop is passed with legacy properties.
// If yes, console.warn the user to use `legacyRuterProvider` prop instead.
const bindings = ["go", "parse", "back", "Link"];
// check if `value` contains properties other than `bindings`
const otherProps = Object.keys(value).filter(
(key) => !bindings.includes(key),
);
const hasOtherProps = otherProps.length > 0;
if (hasOtherProps) {
console.warn(
`Unsupported properties are found in \`routerProvider\` prop. You provided \`${otherProps.join(
", ",
)}\`. Supported properties are \`${bindings.join(
", ",
)}\`. You may wanted to use \`legacyRouterProvider\` prop instead.`,
);
return true;
}
return false;
};

View File

@@ -0,0 +1,32 @@
import { waitFor } from "@testing-library/react";
import { deferExecution } from ".";
describe("deferExecution", () => {
beforeEach(() => {
jest.useRealTimers();
});
afterEach(() => {
jest.useFakeTimers();
});
it("should defer the call after caller returns", async () => {
const array: number[] = [];
const fn = () => {
array.push(1);
deferExecution(() => {
array.push(3);
});
array.push(2);
};
fn();
await waitFor(() => {
expect(array).toEqual([1, 2, 3]);
});
});
});

View File

@@ -0,0 +1,10 @@
/**
* Delays the execution of a callback function asynchronously.
* This utility function is used to defer the execution of the provided
* callback, allowing the current call stack to clear before the callback
* is invoked. It is particularly useful for ensuring non-blocking behavior
* and providing a clear intent when a 0 ms timeout is used.
*/
export const deferExecution = (fn: Function) => {
setTimeout(fn, 0);
};

View File

@@ -0,0 +1,24 @@
export const downloadInBrowser = (
filename: string,
content: string,
type?: string,
) => {
if (typeof window === "undefined") {
return;
}
const blob = new Blob([content], { type });
const link = document.createElement("a");
link.setAttribute("visibility", "hidden");
link.download = filename;
const blobUrl = URL.createObjectURL(blob);
link.href = blobUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// As per documentation, call URL.revokeObjectURL to remove the blob from memory.
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
});
};

View File

@@ -0,0 +1,95 @@
import { flattenObjectKeys } from ".";
describe("flattenObjectKeys", () => {
it("should flatten an object with nested objects and arrays", () => {
const obj = {
a: 1,
b: {
c: 2,
d: [3, 4],
},
e: {
f: {
g: 5,
},
},
};
const flattenedObj = flattenObjectKeys(obj);
expect(flattenedObj).toEqual({
a: 1,
b: {
c: 2,
d: [3, 4],
},
"b.c": 2,
"b.d": [3, 4],
"b.d.0": 3,
"b.d.1": 4,
e: {
f: {
g: 5,
},
},
"e.f": {
g: 5,
},
"e.f.g": 5,
});
});
it("should flatten an object with empty nested objects and arrays", () => {
const obj = {
a: 1,
b: {},
c: [],
};
const flattenedObj = flattenObjectKeys(obj);
expect(flattenedObj).toEqual({
a: 1,
b: {},
c: [],
});
});
it("should flatten an object with nested objects and arrays with custom prefix", () => {
const obj = {
a: 1,
b: {
c: 2,
d: [3, 4],
},
e: {
f: {
g: 5,
},
},
};
const flattenedObj = flattenObjectKeys(obj, "prefix");
expect(flattenedObj).toEqual({
"prefix.a": 1,
"prefix.b": {
c: 2,
d: [3, 4],
},
"prefix.b.c": 2,
"prefix.b.d": [3, 4],
"prefix.b.d.0": 3,
"prefix.b.d.1": 4,
"prefix.e": {
f: {
g: 5,
},
},
"prefix.e.f": {
g: 5,
},
"prefix.e.f.g": 5,
});
});
});

View File

@@ -0,0 +1,35 @@
const isNested = (obj: any) => typeof obj === "object" && obj !== null;
const isArray = (obj: any) => Array.isArray(obj);
export const flattenObjectKeys = (obj: any, prefix = "") => {
if (!isNested(obj)) {
return {
[prefix]: obj,
};
}
return Object.keys(obj).reduce(
(acc, key) => {
const currentPrefix = prefix.length ? `${prefix}.` : "";
if (isNested(obj[key]) && Object.keys(obj[key]).length) {
if (isArray(obj[key]) && obj[key].length) {
obj[key].forEach((item: unknown[], index: number) => {
Object.assign(
acc,
flattenObjectKeys(item, `${currentPrefix + key}.${index}`),
);
});
} else {
Object.assign(acc, flattenObjectKeys(obj[key], currentPrefix + key));
}
// Even if it's a nested object, it should be treated as a key as well
acc[currentPrefix + key] = obj[key];
} else {
acc[currentPrefix + key] = obj[key];
}
return acc;
},
{} as Record<string, unknown>,
);
};

View File

@@ -0,0 +1,114 @@
import { generateDefaultDocumentTitle } from ".";
import * as UseRefineContext from "../../../hooks/refine/useRefineContext";
import { defaultRefineOptions } from "@contexts/refine";
const translateMock = jest.fn(
(key: string, options?: any, defaultMessage?: string | undefined) => {
return defaultMessage ?? options;
},
);
describe("generateDocumentTitle", () => {
jest.spyOn(UseRefineContext, "useRefineContext").mockReturnValue({
options: defaultRefineOptions,
} as any);
beforeEach(() => {
translateMock.mockClear();
});
it("should return the default title when resource is undefined", () => {
expect(generateDefaultDocumentTitle(translateMock)).toBe("Refine");
});
it("should return `resource name` when action is `list`", () => {
expect(
generateDefaultDocumentTitle(translateMock, { name: "posts" }, "list"),
).toBe("Posts | Refine");
});
it("should return the label of the resource when it is provided", () => {
expect(
generateDefaultDocumentTitle(
translateMock,
{ name: "posts", label: "Posts Label" },
"list",
),
).toBe("Posts Label | Refine");
});
it("should return the meta.label of the resource when it is provided", () => {
expect(
generateDefaultDocumentTitle(
translateMock,
{
name: "posts",
label: undefined,
meta: { label: "Meta Label" },
},
"list",
),
).toBe("Meta Label | Refine");
});
it("should return `Create new resource name` when action is `create`", () => {
expect(
generateDefaultDocumentTitle(translateMock, { name: "posts" }, "create"),
).toBe("Create new Post | Refine");
});
it("should return `#id Clone resource name` when action is `clone`", () => {
expect(
generateDefaultDocumentTitle(
translateMock,
{ name: "posts" },
"clone",
"1",
),
).toBe("#1 Clone Post | Refine");
});
it("should return `#id Edit resource name` when action is `edit`", () => {
expect(
generateDefaultDocumentTitle(
translateMock,
{ name: "posts" },
"edit",
"1",
),
).toBe("#1 Edit Post | Refine");
});
it("should return `#id Show resource name` when action is `show`", () => {
expect(
generateDefaultDocumentTitle(
translateMock,
{ name: "posts" },
"show",
"1",
),
).toBe("#1 Show Post | Refine");
});
it("should pass `id` to `translate` function", () => {
generateDefaultDocumentTitle(translateMock, { name: "posts" }, "show", "1");
expect(translateMock).toHaveBeenCalledWith(
"documentTitle.posts.show",
{ id: "1" },
"#1 Show Post | Refine",
);
});
it("should use the fallback value when `translate` returns the key", () => {
translateMock.mockReturnValueOnce("documentTitle.posts.show");
expect(
generateDefaultDocumentTitle(
translateMock,
{ name: "posts" },
"show",
"1",
),
).toBe("#1 Show Post | Refine");
});
});

View File

@@ -0,0 +1,57 @@
import type { useTranslate } from "@hooks/i18n";
import type { IResourceItem } from "../../../contexts/resource/types";
import { safeTranslate } from "../safe-translate";
import { userFriendlyResourceName } from "../userFriendlyResourceName";
/**
* Generates document title for the given resource and action.
*/
export function generateDefaultDocumentTitle(
translate: ReturnType<typeof useTranslate>,
resource?: IResourceItem,
action?: string,
id?: string,
resourceName?: string,
) {
const actionPrefixMatcher = {
create: "Create new ",
clone: `#${id ?? ""} Clone `,
edit: `#${id ?? ""} Edit `,
show: `#${id ?? ""} Show `,
list: "",
};
const identifier = resource?.identifier ?? resource?.name;
const resourceNameFallback =
resource?.label ??
resource?.meta?.label ??
userFriendlyResourceName(
identifier,
action === "list" ? "plural" : "singular",
);
const resourceNameWithFallback = resourceName ?? resourceNameFallback;
const defaultTitle = safeTranslate(
translate,
"documentTitle.default",
"Refine",
);
const suffix = safeTranslate(translate, "documentTitle.suffix", " | Refine");
let autoGeneratedTitle = defaultTitle;
if (action && identifier) {
autoGeneratedTitle = safeTranslate(
translate,
`documentTitle.${identifier}.${action}`,
`${
actionPrefixMatcher[action as keyof typeof actionPrefixMatcher] ?? ""
}${resourceNameWithFallback}${suffix}`,
{ id },
);
}
return autoGeneratedTitle;
}

View File

@@ -0,0 +1,12 @@
import { handleMultiple } from ".";
describe("handleMultiple", () => {
it("should be resolve multiple promise", () => {
expect(
handleMultiple([
Promise.resolve({ data: 1 }),
Promise.resolve({ data: 2 }),
]),
).toEqual(Promise.resolve({ data: [1, 2] }));
});
});

View File

@@ -0,0 +1,7 @@
export const handleMultiple = async <TData = unknown>(
promises: Promise<{ data: TData }>[],
): Promise<{ data: TData[] }> => {
return {
data: (await Promise.all(promises)).map((res) => res.data),
};
};

View File

@@ -0,0 +1,73 @@
import { handlePaginationParams } from ".";
describe("handlePaginationParams", () => {
it("should return default pagination", () => {
expect(handlePaginationParams()).toEqual({
current: 1,
pageSize: 10,
mode: "server",
});
});
it("should return pagination from `pagination` prop", () => {
expect(
handlePaginationParams({
pagination: { current: 2, pageSize: 20, mode: "client" },
}),
).toEqual({
current: 2,
pageSize: 20,
mode: "client",
});
});
it("should return pagination from `config` prop", () => {
expect(
handlePaginationParams({
configPagination: { current: 3, pageSize: 30 },
}),
).toEqual({
current: 3,
pageSize: 30,
mode: "server",
});
});
it("should return pagination from `pagination` prop if config is defined", () => {
expect(
handlePaginationParams({
pagination: { current: 2, pageSize: 20, mode: "client" },
configPagination: { current: 3, pageSize: 30 },
}),
).toEqual({
current: 2,
pageSize: 20,
mode: "client",
});
});
it("if `mode` is not defined in `pagination` prop, should return according to `hasPagination` prop", () => {
expect(
handlePaginationParams({
hasPagination: false,
}),
).toEqual({
current: 1,
pageSize: 10,
mode: "off",
});
});
it("if both `hasPagination` and `pagination.mode` are defined, should return according to `pagination` prop", () => {
expect(
handlePaginationParams({
hasPagination: true,
pagination: { mode: "client" },
}),
).toEqual({
current: 1,
pageSize: 10,
mode: "client",
});
});
});

View File

@@ -0,0 +1,29 @@
import type { Pagination } from "../../../contexts/data/types";
import { pickNotDeprecated } from "../pickNotDeprecated";
type HandlePaginationParamsProps = {
hasPagination?: boolean;
pagination?: Pagination;
configPagination?: Pagination;
};
export const handlePaginationParams = ({
hasPagination,
pagination,
configPagination,
}: HandlePaginationParamsProps = {}): Required<Pagination> => {
const hasPaginationString = hasPagination === false ? "off" : "server";
const mode = pagination?.mode ?? hasPaginationString;
const current =
pickNotDeprecated(pagination?.current, configPagination?.current) ?? 1;
const pageSize =
pickNotDeprecated(pagination?.pageSize, configPagination?.pageSize) ?? 10;
return {
current,
pageSize,
mode,
};
};

View File

@@ -0,0 +1,292 @@
import { QueryClient } from "@tanstack/react-query";
import { defaultRefineOptions } from "@contexts/refine";
import { handleRefineOptions } from ".";
import type { IRefineOptions } from "../../../contexts/refine/types";
describe("handleRefineOptions", () => {
it("should return the default options if no options are provided", () => {
const { optionsWithDefaults } = handleRefineOptions();
expect(optionsWithDefaults).toEqual(defaultRefineOptions);
});
it("should return the options if they are provided", () => {
const options: IRefineOptions = {
mutationMode: "optimistic",
disableTelemetry: true,
liveMode: "auto",
reactQuery: {
clientConfig: {
defaultOptions: { queries: { enabled: false } },
},
devtoolConfig: false,
},
undoableTimeout: 1000,
syncWithLocation: true,
warnWhenUnsavedChanges: true,
redirect: {
afterClone: "show",
afterCreate: "edit",
afterEdit: "show",
},
breadcrumb: false,
};
const {
optionsWithDefaults,
disableTelemetryWithDefault,
reactQueryWithDefaults,
} = handleRefineOptions({ options });
expect(optionsWithDefaults).toEqual({
liveMode: "auto",
mutationMode: "optimistic",
syncWithLocation: true,
undoableTimeout: 1000,
warnWhenUnsavedChanges: true,
redirect: {
afterClone: "show",
afterCreate: "edit",
afterEdit: "show",
},
breadcrumb: false,
overtime: {
interval: 1000,
},
textTransformers: {
humanize: expect.any(Function),
plural: expect.any(Function),
singular: expect.any(Function),
},
disableServerSideValidation: false,
title: expect.objectContaining({
icon: expect.any(Object),
text: "Refine Project",
}),
});
expect(disableTelemetryWithDefault).toBe(true);
expect(reactQueryWithDefaults).toEqual({
clientConfig: {
defaultOptions: { queries: { enabled: false } },
},
devtoolConfig: false,
});
});
it("should return the options if they are provided both in options and passed directly <Refine>", () => {
const options: IRefineOptions = {
mutationMode: "optimistic",
disableTelemetry: true,
liveMode: "auto",
reactQuery: {
clientConfig: {
defaultOptions: { queries: { enabled: false } },
},
devtoolConfig: false,
},
undoableTimeout: 1000,
syncWithLocation: true,
warnWhenUnsavedChanges: true,
};
const {
optionsWithDefaults,
disableTelemetryWithDefault,
reactQueryWithDefaults,
} = handleRefineOptions({
options,
mutationMode: "pessimistic",
disableTelemetry: false,
liveMode: "manual",
reactQueryClientConfig: {
defaultOptions: { queries: { enabled: true } },
},
reactQueryDevtoolConfig: {
position: "bottom-left",
},
syncWithLocation: false,
undoableTimeout: 2000,
warnWhenUnsavedChanges: false,
});
expect(optionsWithDefaults).toEqual({
liveMode: "auto",
mutationMode: "optimistic",
syncWithLocation: true,
undoableTimeout: 1000,
warnWhenUnsavedChanges: true,
redirect: {
afterClone: "list",
afterCreate: "list",
afterEdit: "list",
},
overtime: {
interval: 1000,
},
textTransformers: {
humanize: expect.any(Function),
plural: expect.any(Function),
singular: expect.any(Function),
},
disableServerSideValidation: false,
title: expect.objectContaining({
icon: expect.any(Object),
text: "Refine Project",
}),
});
expect(disableTelemetryWithDefault).toBe(true);
expect(reactQueryWithDefaults).toEqual({
clientConfig: {
defaultOptions: { queries: { enabled: false } },
},
devtoolConfig: false,
});
});
it("should return directly passed <Refine> options if options are not provided", () => {
const {
optionsWithDefaults,
disableTelemetryWithDefault,
reactQueryWithDefaults,
} = handleRefineOptions({
mutationMode: "pessimistic",
disableTelemetry: true,
liveMode: "manual",
reactQueryClientConfig: {
defaultOptions: { queries: { enabled: false } },
},
reactQueryDevtoolConfig: {
position: "bottom-right",
},
syncWithLocation: false,
undoableTimeout: 2000,
warnWhenUnsavedChanges: false,
});
expect(optionsWithDefaults).toEqual({
liveMode: "manual",
mutationMode: "pessimistic",
syncWithLocation: false,
undoableTimeout: 2000,
warnWhenUnsavedChanges: false,
redirect: {
afterClone: "list",
afterCreate: "list",
afterEdit: "list",
},
overtime: {
interval: 1000,
},
textTransformers: {
humanize: expect.any(Function),
plural: expect.any(Function),
singular: expect.any(Function),
},
disableServerSideValidation: false,
title: expect.objectContaining({
icon: expect.any(Object),
text: "Refine Project",
}),
});
expect(disableTelemetryWithDefault).toBe(true);
expect(reactQueryWithDefaults).toEqual({
clientConfig: {
defaultOptions: { queries: { enabled: false } },
},
devtoolConfig: {
position: "bottom-right",
},
});
});
it("if some of the redirect options are not provided, should return the default ones for those options", () => {
const { optionsWithDefaults } = handleRefineOptions({
options: {
redirect: {
afterClone: "show",
},
},
});
expect(optionsWithDefaults.redirect).toEqual({
afterClone: "show",
afterCreate: "list",
afterEdit: "list",
});
});
it("it should return provided query client", () => {
const queryClient = new QueryClient();
const options: IRefineOptions = {
reactQuery: {
clientConfig: queryClient,
devtoolConfig: false,
},
};
const { reactQueryWithDefaults } = handleRefineOptions({ options });
expect(reactQueryWithDefaults).toEqual({
clientConfig: queryClient,
devtoolConfig: false,
});
});
it("it should return projectId", () => {
const options: IRefineOptions = {
projectId: "test",
};
const { optionsWithDefaults } = handleRefineOptions({ options });
expect(optionsWithDefaults.projectId).toEqual("test");
});
it("it should return title", () => {
const options: IRefineOptions = {
title: {
icon: "My Icon",
text: "My Project",
},
};
const { optionsWithDefaults } = handleRefineOptions({ options });
expect(optionsWithDefaults.title).toEqual(
expect.objectContaining({ icon: "My Icon", text: "My Project" }),
);
});
it("it should return modified title partially", () => {
const options: IRefineOptions = {
title: {
icon: undefined,
text: "My Project",
},
};
const { optionsWithDefaults } = handleRefineOptions({ options });
expect(optionsWithDefaults.title).toEqual(
expect.objectContaining({ icon: expect.any(Object), text: "My Project" }),
);
});
it("it should accept null values for title", () => {
const options: IRefineOptions = {
title: {
icon: null,
text: "My Project",
},
};
const { optionsWithDefaults } = handleRefineOptions({ options });
expect(optionsWithDefaults.title).toEqual(
expect.objectContaining({ icon: null, text: "My Project" }),
);
});
});

View File

@@ -0,0 +1,117 @@
import type { QueryClient, QueryClientConfig } from "@tanstack/react-query";
import { defaultRefineOptions } from "@contexts/refine";
import type { MutationMode } from "../../../contexts/data/types";
import type { LiveModeProps } from "../../../contexts/live/types";
import type {
IRefineContextOptions,
IRefineOptions,
} from "../../../contexts/refine/types";
type HandleRefineOptionsProps = {
options?: IRefineOptions;
mutationMode?: MutationMode;
syncWithLocation?: boolean;
warnWhenUnsavedChanges?: boolean;
undoableTimeout?: number;
liveMode?: LiveModeProps["liveMode"];
disableTelemetry?: boolean;
reactQueryClientConfig?: QueryClientConfig;
reactQueryDevtoolConfig?: any | false;
};
type HandleRefineOptionsReturnValues = {
optionsWithDefaults: IRefineContextOptions;
disableTelemetryWithDefault: boolean;
reactQueryWithDefaults: {
clientConfig: QueryClientConfig | InstanceType<typeof QueryClient>;
devtoolConfig: false | any;
};
};
export const handleRefineOptions = ({
options,
disableTelemetry,
liveMode,
mutationMode,
reactQueryClientConfig,
reactQueryDevtoolConfig,
syncWithLocation,
undoableTimeout,
warnWhenUnsavedChanges,
}: HandleRefineOptionsProps = {}): HandleRefineOptionsReturnValues => {
const optionsWithDefaults: IRefineContextOptions = {
breadcrumb: options?.breadcrumb,
mutationMode:
options?.mutationMode ??
mutationMode ??
defaultRefineOptions.mutationMode,
undoableTimeout:
options?.undoableTimeout ??
undoableTimeout ??
defaultRefineOptions.undoableTimeout,
syncWithLocation:
options?.syncWithLocation ??
syncWithLocation ??
defaultRefineOptions.syncWithLocation,
warnWhenUnsavedChanges:
options?.warnWhenUnsavedChanges ??
warnWhenUnsavedChanges ??
defaultRefineOptions.warnWhenUnsavedChanges,
liveMode: options?.liveMode ?? liveMode ?? defaultRefineOptions.liveMode,
redirect: {
afterCreate:
options?.redirect?.afterCreate ??
defaultRefineOptions.redirect.afterCreate,
afterClone:
options?.redirect?.afterClone ??
defaultRefineOptions.redirect.afterClone,
afterEdit:
options?.redirect?.afterEdit ?? defaultRefineOptions.redirect.afterEdit,
},
overtime: options?.overtime ?? defaultRefineOptions.overtime,
textTransformers: {
humanize:
options?.textTransformers?.humanize ??
defaultRefineOptions.textTransformers.humanize,
plural:
options?.textTransformers?.plural ??
defaultRefineOptions.textTransformers.plural,
singular:
options?.textTransformers?.singular ??
defaultRefineOptions.textTransformers.singular,
},
disableServerSideValidation:
options?.disableServerSideValidation ??
defaultRefineOptions.disableServerSideValidation,
projectId: options?.projectId,
useNewQueryKeys: options?.useNewQueryKeys,
title: {
icon:
typeof options?.title?.icon === "undefined"
? defaultRefineOptions.title.icon
: options?.title?.icon,
text:
typeof options?.title?.text === "undefined"
? defaultRefineOptions.title.text
: options?.title?.text,
},
};
const disableTelemetryWithDefault =
options?.disableTelemetry ?? disableTelemetry ?? false;
const reactQueryWithDefaults = {
clientConfig:
options?.reactQuery?.clientConfig ?? reactQueryClientConfig ?? {},
devtoolConfig:
options?.reactQuery?.devtoolConfig ?? reactQueryDevtoolConfig ?? {},
};
return {
optionsWithDefaults,
disableTelemetryWithDefault,
reactQueryWithDefaults,
};
};

View File

@@ -0,0 +1,66 @@
import { handleUseParams } from ".";
const paramsWithoutId = {
resource: "posts",
};
const paramsWithId = {
resource: "posts",
id: "11",
};
const paramsWithOutId = {
resource: "posts",
id: null,
};
const paramsWithUniqueId = {
resource: "posts",
id: "hede/hede",
};
const paramsWithBase64EncodedId = {
resource: "posts",
id: "cmVmaW5lIHJ1bHo=",
};
describe("handleUseParams", () => {
it("should return params true even id does not passed", () => {
expect(handleUseParams(paramsWithoutId)).toEqual(paramsWithoutId);
});
it("should return params true with Id", () => {
expect(
handleUseParams({
...paramsWithId,
id: encodeURIComponent(paramsWithId.id),
}),
).toEqual(paramsWithId);
});
it("should return params true with unique Id", () => {
expect(
handleUseParams({
...paramsWithUniqueId,
id: encodeURIComponent(paramsWithUniqueId.id),
}),
).toEqual(paramsWithUniqueId);
});
it("should return params true with nique Id", () => {
expect(
handleUseParams({
...paramsWithBase64EncodedId,
id: encodeURIComponent(paramsWithBase64EncodedId.id),
}),
).toEqual(paramsWithBase64EncodedId);
});
it("should return params without id", () => {
expect(handleUseParams(paramsWithOutId)).toEqual(paramsWithOutId);
});
it("should return params empty object with does not passed", () => {
expect(handleUseParams()).toEqual({});
});
});

View File

@@ -0,0 +1,9 @@
export const handleUseParams = (params: any = {}): any => {
if (params?.id) {
return {
...params,
id: decodeURIComponent(params.id),
};
}
return params;
};

View File

@@ -0,0 +1,23 @@
import { hasPermission } from ".";
describe("hasPermission", () => {
it("should return true if permissions includes the action", () => {
expect(hasPermission(["create", "edit"], "create")).toBeTruthy();
});
it("should return false if permissions not includes the action", () => {
expect(hasPermission(["edit", "show"], "create")).toBeFalsy();
});
it("should return false if permissions equal to undefined", () => {
expect(hasPermission(undefined, "create")).toBeFalsy();
});
it("should return false if action equal to undefined", () => {
expect(hasPermission(["create", "edit"], undefined)).toBeFalsy();
});
it("should return false both of params equal to undefined", () => {
expect(hasPermission(undefined, undefined)).toBeFalsy();
});
});

View File

@@ -0,0 +1,9 @@
export const hasPermission = (
permissions: string[] | undefined,
action: string | undefined,
): boolean => {
if (!permissions || !action) {
return false;
}
return !!permissions.find((i) => i === action);
};

View File

@@ -0,0 +1,10 @@
import { humanizeString } from ".";
describe("humanizeString", () => {
it("should return strings for humans!", () => {
expect(humanizeString("fooBar")).toBe("Foo bar");
expect(humanizeString("foo-bar")).toBe("Foo bar");
expect(humanizeString("foo_bar")).toBe("Foo bar");
expect(humanizeString("myFOOBar")).toBe("My foo bar");
});
});

View File

@@ -0,0 +1,14 @@
export const humanizeString = (text: string): string => {
text = text.replace(/([a-z]{1})([A-Z]{1})/g, "$1-$2");
text = text.replace(/([A-Z]{1})([A-Z]{1})([a-z]{1})/g, "$1-$2$3");
text = text
.toLowerCase()
.replace(/[_-]+/g, " ")
.replace(/\s{2,}/g, " ")
.trim();
text = text.charAt(0).toUpperCase() + text.slice(1);
return text;
};
// https://www.npmjs.com/package/humanize-string

View File

@@ -0,0 +1,48 @@
import { importCSVMapper } from ".";
const rawData = [
["id", "title", "content"],
["600f5fce", "Fantastic Granite", "Ea expedita doloremque et."],
["946a03b1", "Sky blue Nepal", "Consequatur ad aut odit."],
];
const transformedData = [
{
id: "600f5fce",
title: "Fantastic Granite",
content: "Ea expedita doloremque et.",
},
{
id: "946a03b1",
title: "Sky blue Nepal",
content: "Consequatur ad aut odit.",
},
];
describe("importCSVMapper", () => {
it("should transform the given data", () => {
expect(importCSVMapper(rawData)).toEqual(transformedData);
});
it("should run the given mapper callback with correct parameters", () => {
const mapperFn = jest.fn((item) => item);
importCSVMapper(rawData, mapperFn);
expect(mapperFn).toHaveBeenCalledTimes(2);
expect(mapperFn).toHaveBeenNthCalledWith(
1,
transformedData[0],
0,
transformedData,
);
expect(mapperFn).toHaveBeenNthCalledWith(
2,
transformedData[1],
1,
transformedData,
);
});
});

View File

@@ -0,0 +1,16 @@
import fromPairs from "lodash/fromPairs";
import zip from "lodash/zip";
import type { MapDataFn } from "../../../hooks/export/types";
export const importCSVMapper = <TItem = any, TVariables = any>(
data: any[][],
mapData: MapDataFn<TItem, TVariables> = (item) => item as any,
): TVariables[] => {
const [headers, ...body] = data;
return body
.map((entry) => fromPairs(zip(headers, entry)))
.map((item: any, index, array: any) =>
mapData.call(undefined, item, index, array),
);
};

View File

@@ -0,0 +1,36 @@
export { userFriendlySecond } from "./userFriendlySeconds";
export { importCSVMapper } from "./importCSVMapper";
export { userFriendlyResourceName } from "./userFriendlyResourceName";
export { handleUseParams } from "./handleUseParams";
export { queryKeys, queryKeysReplacement } from "./queryKeys";
export { hasPermission } from "./hasPermission";
export { routeGenerator } from "./routeGenerator";
export { createTreeView } from "./treeView/createTreeView";
export { humanizeString } from "./humanizeString";
export { handleRefineOptions } from "./handleRefineOptions";
export { redirectPage } from "./redirectPage";
export { sequentialPromises } from "./sequentialPromises";
export { pickDataProvider } from "./pickDataProvider";
export { handleMultiple } from "./handleMultiple";
export {
getNextPageParam,
getPreviousPageParam,
} from "./useInfinitePagination";
export { pickNotDeprecated } from "./pickNotDeprecated";
export { legacyResourceTransform } from "./legacy-resource-transform";
export { matchResourceFromRoute } from "./router/match-resource-from-route";
export { getActionRoutesFromResource } from "./router";
export { composeRoute } from "./router/compose-route";
export { useActiveAuthProvider } from "./useActiveAuthProvider";
export { handlePaginationParams } from "./handlePaginationParams";
export { useMediaQuery } from "./useMediaQuery";
export { generateDefaultDocumentTitle } from "./generateDocumentTitle";
export { useUserFriendlyName } from "./useUserFriendlyName";
export { keys, stripUndefined } from "./keys";
export { KeyBuilder } from "./keys";
export { flattenObjectKeys } from "./flatten-object-keys";
export { propertyPathToArray } from "./property-path-to-array";
export { downloadInBrowser } from "./downloadInBrowser";
export { deferExecution } from "./defer-execution";
export { asyncDebounce } from "./async-debounce";
export { prepareQueryContext } from "./prepare-query-context";

View File

@@ -0,0 +1,438 @@
import { arrayReplace, arrayFindIndex, stripUndefined, keys } from ".";
import { queryKeys } from "../queryKeys";
describe("keys", () => {
describe("keys()", () => {
it("keys().get() === []", () => {
const keyBuilder = keys();
expect(keyBuilder.get()).toEqual([]);
});
it("keys().get(true) === []", () => {
const keyBuilder = keys();
expect(keyBuilder.legacy()).toEqual([]);
});
it("keys().get() === []", () => {
const keyBuilder = keys();
expect(keyBuilder.get()).toEqual([]);
});
});
describe("keys().auth()", () => {
it("keys().auth().get() === [auth]", () => {
const keyBuilder = keys().auth();
expect(keyBuilder.get()).toEqual(["auth"]);
});
it("keys().auth().get(true) === [auth]", () => {
const keyBuilder = keys().auth();
expect(keyBuilder.legacy()).toEqual(["auth"]);
});
it("keys().auth().get() === [auth]", () => {
const keyBuilder = keys().auth();
expect(keyBuilder.get()).toEqual(["auth"]);
});
it("keys().auth().action(login).get() === [auth, login]", () => {
const keyBuilder = keys().auth().action("login");
expect(keyBuilder.get()).toEqual(["auth", "login"]);
});
it("keys().auth().action(login).params({ username: 'test' }) === [auth, login, { username: test }]", () => {
const keyBuilder = keys().auth().action("login").params({
username: "test",
});
expect(keyBuilder.get()).toEqual(["auth", "login", { username: "test" }]);
});
});
describe("keys().data()", () => {
it("keys().data().get() === [data, default]", () => {
const keyBuilder = keys().data();
expect(keyBuilder.get()).toEqual(["data", "default"]);
});
it("keys().data('my-data-provider').get() === [data, my-data-provider]", () => {
const keyBuilder = keys().data("my-data-provider");
expect(keyBuilder.get()).toEqual(["data", "my-data-provider"]);
});
it("keys().data().resource(users).get() === [data, default, users]", () => {
const keyBuilder = keys().data().resource("users");
expect(keyBuilder.get()).toEqual(["data", "default", "users"]);
});
it("keys().data().resource(posts).action(list).get() === [data, default, posts, list]", () => {
const keyBuilder = keys().data().resource("posts").action("list");
expect(keyBuilder.get()).toEqual(["data", "default", "posts", "list"]);
});
it("keys().data().resource(posts).action(list).params({ page: 1 }).get() === [data, default, posts, list, { page: 1 }]", () => {
const keyBuilder = keys()
.data()
.resource("posts")
.action("list")
.params({ page: 1 });
expect(keyBuilder.get()).toEqual([
"data",
"default",
"posts",
"list",
{ page: 1 },
]);
});
it("keys().data().resource(posts).action(one).id(1).params({ foo: bar }).get() === [data, default, posts, one, 1, { foo: bar }]", () => {
const keyBuilder = keys()
.data()
.resource("posts")
.action("one")
.id(1)
.params({ foo: "bar" });
expect(keyBuilder.get()).toEqual([
"data",
"default",
"posts",
"one",
"1",
{ foo: "bar" },
]);
});
it("keys().data().mutation(update).params({ foo: bar }).get() === [data, update, { foo: bar }]", () => {
const keyBuilder = keys()
.data()
.mutation("update")
.params({ foo: "bar" });
expect(keyBuilder.get()).toEqual(["data", "update", { foo: "bar" }]);
});
it("keys().data().mutation(custom).params({ foo: bar }).get() === [data, default, custom, { foo: bar }]", () => {
const keyBuilder = keys()
.data()
.mutation("custom")
.params({ foo: "bar" });
expect(keyBuilder.get()).toEqual([
"data",
"default",
"custom",
{ foo: "bar" },
]);
});
});
describe("keys().access()", () => {
it("keys().access().get() === [access]", () => {
const keyBuilder = keys().access();
expect(keyBuilder.get()).toEqual(["access"]);
});
it("keys().access().get(true) === [access]", () => {
const keyBuilder = keys().access();
expect(keyBuilder.get(true)).toEqual(["access"]);
});
it("keys().access().get() === [access]", () => {
const keyBuilder = keys().access();
expect(keyBuilder.get()).toEqual(["access"]);
});
it("keys().access().resource(users).get() === [access, users]", () => {
const keyBuilder = keys().access().resource("users");
expect(keyBuilder.get()).toEqual(["access", "users"]);
});
it("keys().access().resource(users).action(create).get() === [access, users, create]", () => {
const keyBuilder = keys().access().resource("users").action("create");
expect(keyBuilder.get()).toEqual(["access", "users", "create"]);
});
it("keys().access().resource(users).action(create).params({ foo: bar }).get() === [access, users, create, { foo: bar }]", () => {
const keyBuilder = keys()
.access()
.resource("users")
.action("create")
.params({ foo: "bar" });
expect(keyBuilder.get()).toEqual([
"access",
"users",
"create",
{ foo: "bar" },
]);
});
});
describe("keys().audit()", () => {
it("keys().audit().get() === [audit]", () => {
const keyBuilder = keys().audit();
expect(keyBuilder.get()).toEqual(["audit"]);
});
it("keys().audit().get(true) === [audit]", () => {
const keyBuilder = keys().audit();
expect(keyBuilder.get(true)).toEqual(["audit"]);
});
it("keys().audit().get() === [audit]", () => {
const keyBuilder = keys().audit();
expect(keyBuilder.get()).toEqual(["audit"]);
});
it("keys().audit().action(rename).get() === [audit, rename]", () => {
const keyBuilder = keys().audit().action("rename");
expect(keyBuilder.get()).toEqual(["audit", "rename"]);
});
it("keys().audit().resource(posts).action(list).params({ foo: bar }).get() === [audit, posts, list, { foo: bar }]", () => {
const keyBuilder = keys()
.audit()
.resource("posts")
.action("list")
.params({ foo: "bar" });
expect(keyBuilder.get()).toEqual([
"audit",
"posts",
"list",
{ foo: "bar" },
]);
});
});
});
describe("array-replace", () => {
it("arrayReplace([1, 2, 3, 4, 5], [2, 3], [6, 7]) === [1, 6, 7, 4, 5]", () => {
expect(arrayReplace([1, 2, 3, 4, 5], [2, 3], [6, 7])).toEqual([
1, 6, 7, 4, 5,
]);
});
it("arrayReplace([1, 2, 3, 4, 5], [2, 3], [6, 7, 8]) === [1, 6, 7, 8, 4, 5]", () => {
expect(arrayReplace([1, 2, 3, 4, 5], [2, 3], [6, 7, 8])).toEqual([
1, 6, 7, 8, 4, 5,
]);
});
it("arrayReplace([1, 2, 3, 4, 5], [2, 3], [6]) === [1, 6, 4, 5]", () => {
expect(arrayReplace([1, 2, 3, 4, 5], [2, 3], [6])).toEqual([1, 6, 4, 5]);
});
it("arrayReplace([1, 2, 3, 4, 5], [2, 3], [6, 7, 8, 9]) === [1, 6, 7, 8, 9, 4, 5]", () => {
expect(arrayReplace([1, 2, 3, 4, 5], [2, 3], [6, 7, 8, 9])).toEqual([
1, 6, 7, 8, 9, 4, 5,
]);
});
it("arrayReplace([1, 2, 3, 4, 5], [2, 3], [6, 7, 8, 9, 10]) === [1, 6, 7, 8, 9, 10, 4, 5]", () => {
expect(arrayReplace([1, 2, 3, 4, 5], [2, 3], [6, 7, 8, 9, 10])).toEqual([
1, 6, 7, 8, 9, 10, 4, 5,
]);
});
});
describe("array-find-index", () => {
it("arrayFindIndex([1, 2, 3, 4, 5], [2, 3]) === 1", () => {
expect(arrayFindIndex([1, 2, 3, 4, 5], [2, 3])).toEqual(1);
});
it("arrayFindIndex([1, 2, 3, 4, 5], [2, 3, 4]) === 1", () => {
expect(arrayFindIndex([1, 2, 3, 4, 5], [2, 3, 4])).toEqual(1);
});
it("arrayFindIndex([1, 2, 3, 4, 5], [ 3, 4, 5]) === 2", () => {
expect(arrayFindIndex([1, 2, 3, 4, 5], [3, 4, 5])).toEqual(2);
});
it("arrayFindIndex([1, 2, 3, 4, 5], [2, 3, 4, 5, 6]) === -1", () => {
expect(arrayFindIndex([1, 2, 3, 4, 5], [2, 3, 4, 5, 6])).toEqual(-1);
});
it("arrayFindIndex([1, 2, 3, 4, 5], [4, 3]) === -1", () => {
expect(arrayFindIndex([1, 2, 3, 4, 5], [4, 3])).toEqual(-1);
});
});
describe("strip-undefined", () => {
it("stripUndefined([1, undefined, 2, undefined, 3]) === [1, 2, 3]", () => {
expect(stripUndefined([1, undefined, 2, undefined, 3])).toEqual([1, 2, 3]);
});
});
describe("Legacy keys are identical with `queryKeys`", () => {
describe("queryKeys(posts, undefined, { foo: bar }) matches with all properties", () => {
const legacyQueryKeys = queryKeys("posts", undefined, { foo: "bar" });
it("all === keys().data()", () => {
expect(keys().data().get(true)).toEqual(legacyQueryKeys.all);
});
it("resourceAll === keys().data().resource(posts)", () => {
expect(keys().data().resource("posts").get(true)).toEqual(
legacyQueryKeys.resourceAll,
);
});
it("logList === keys().audit().resource(posts).action(list).params({ foo: bar })", () => {
expect(
keys()
.audit()
.resource("posts")
.action("list")
.params({ foo: "bar" })
.get(true),
).toEqual(legacyQueryKeys.logList({ foo: "bar" }));
});
it("detail(1) === keys().data().resource(posts).action(one).id(1).params({ foo: bar })", () => {
expect(
keys()
.data()
.resource("posts")
.action("one")
.id(1)
.params({ foo: "bar" })
.get(true),
).toEqual(legacyQueryKeys.detail(1));
});
it("many([1, 2, 3]) === keys().data().resource(posts).action(many).ids(1,2,3).params({ foo: bar })", () => {
expect(
keys()
.data()
.resource("posts")
.action("many")
.ids(1, 2, 3)
.params({ foo: "bar" })
.get(true),
).toEqual(legacyQueryKeys.many([1, 2, 3]));
});
it("list({ pagination: { current: 1, pageSize: 5 }) === keys().data().resource(posts).action(list).params({ pagination: { current: 1, pageSize: 5 }, foo: bar })", () => {
expect(
keys()
.data()
.resource("posts")
.action("list")
.params({
pagination: { current: 1, pageSize: 5 },
foo: "bar",
})
.get(true),
).toEqual(
legacyQueryKeys.list({
pagination: { current: 1, pageSize: 5 },
}),
);
});
});
it("logList matches the legacy", () => {
const metaData = {};
const meta = { foo: "bar" };
const resource = "posts";
const legacy = queryKeys(resource, undefined, metaData).logList(meta);
const newKey = keys()
.audit()
.resource(resource)
.action("list")
.params(meta);
expect(newKey.get(true)).toEqual(legacy);
});
it("useCustom matches the legacy key", () => {
const dataProviderName = "default";
const method = "GET";
const url = "/posts";
const preferredMeta = { foo: "bar" };
const config = { enabled: true };
const legacyKey = [
dataProviderName,
"custom",
method,
url,
{ ...config, ...(preferredMeta || {}) },
];
const newKey = keys()
.data(dataProviderName)
.mutation("custom")
.params({
method,
url,
...config,
...(preferredMeta || {}),
});
expect(newKey.get(true)).toEqual(legacyKey);
});
});
describe("Legacy keys are matching auth hooks", () => {
it("keys().auth().action(login).params({ username: 'test' }).get(true) === [useLogin]", () => {
expect(
keys().auth().action("login").params({ username: "test" }).get(true),
).toEqual(["useLogin"]);
});
it("keys().auth().action(check).params({ foo: bar }) === [useAuthenticated, { foo: bar }]", () => {
expect(
keys().auth().action("check").params({ foo: "bar" }).get(true),
).toEqual(["useAuthenticated", { foo: "bar" }]);
});
});
describe("Legacy keys are matching access hooks", () => {
it("keys().access().resource().action(list).params({ params: { foo: bar }, enabled: true }) === [useCan, { resource: undefined, action: list, params: { foo: bar }, enabled: true } }]", () => {
expect(
keys()
.access()
.resource()
.action("list")
.params({ params: { foo: "bar" }, enabled: true })
.get(true),
).toEqual([
"useCan",
{
resource: undefined,
action: "list",
params: { foo: "bar" },
enabled: true,
},
]);
});
it("generates the same key for legacy", () => {
const action = "list";
const resource = "posts";
const paramsRest = { foo: "bar" };
const queryOptions = { enabled: true };
const sanitizedResource = { name: "posts" };
const legacyKey = [
"useCan",
{
action,
resource,
params: { ...paramsRest, resource: sanitizedResource },
enabled: queryOptions?.enabled,
},
];
const newKey = keys()
.access()
.resource(resource)
.action(action)
.params({
params: { ...paramsRest, resource: sanitizedResource },
enabled: queryOptions?.enabled,
})
.legacy();
expect(newKey).toEqual(legacyKey);
});
});

View File

@@ -0,0 +1,283 @@
import type { BaseKey } from "../../../contexts/data/types";
type ParametrizedDataActions = "list" | "infinite";
type IdRequiredDataActions = "one";
type IdsRequiredDataActions = "many";
type DataMutationActions =
| "custom"
| "customMutation"
| "create"
| "createMany"
| "update"
| "updateMany"
| "delete"
| "deleteMany";
type AuthActionType =
| "login"
| "logout"
| "identity"
| "register"
| "forgotPassword"
| "check"
| "onError"
| "permissions"
| "updatePassword";
type AuditActionType = "list" | "log" | "rename";
type IdType = BaseKey;
type IdsType = IdType[];
type ParamsType = any;
type KeySegment = string | IdType | IdsType | ParamsType;
export function arrayFindIndex<T>(array: T[], slice: T[]): number {
return array.findIndex(
(item, index) =>
index <= array.length - slice.length &&
slice.every(
(sliceItem, sliceIndex) => array[index + sliceIndex] === sliceItem,
),
);
}
export function arrayReplace<T>(
array: T[],
partToBeReplaced: T[],
newPart: T[],
): T[] {
const newArray: T[] = [...array];
const startIndex = arrayFindIndex(array, partToBeReplaced);
if (startIndex !== -1) {
newArray.splice(startIndex, partToBeReplaced.length, ...newPart);
}
return newArray;
}
export function stripUndefined(segments: KeySegment[]) {
return segments.filter((segment) => segment !== undefined);
}
function convertToLegacy(segments: KeySegment[]) {
// for `list`, `many` and `one`
if (segments[0] === "data") {
// [data, dpName, resource, action, ...];
const newSegments = segments.slice(1);
if (newSegments[2] === "many") {
newSegments[2] = "getMany";
} else if (newSegments[2] === "infinite") {
newSegments[2] = "list";
} else if (newSegments[2] === "one") {
newSegments[2] = "detail";
} else if (newSegments[1] === "custom") {
const newParams = {
...newSegments[2],
};
delete newParams.method;
delete newParams.url;
return [
newSegments[0],
newSegments[1],
newSegments[2].method,
newSegments[2].url,
newParams,
];
}
return newSegments;
}
// for `audit` -> `logList`
if (segments[0] === "audit") {
// [audit, resource, action, params] (for log and list)
// or
// [audit, action, params] (for rename)
if (segments[2] === "list") {
return ["logList", segments[1], segments[3]];
}
}
// for `access` -> `useCan`
if (segments[0] === "access") {
// [access, resource, action, params]
if (segments.length === 4) {
return [
"useCan",
{
resource: segments[1],
action: segments[2],
...segments[3], // params: { params, enabled }
},
];
}
}
// for `auth`
if (segments[0] === "auth") {
if (arrayFindIndex(segments, ["auth", "login"]) !== -1) {
return ["useLogin"];
}
if (arrayFindIndex(segments, ["auth", "logout"]) !== -1) {
return ["useLogout"];
}
if (arrayFindIndex(segments, ["auth", "identity"]) !== -1) {
return ["getUserIdentity"];
}
if (arrayFindIndex(segments, ["auth", "register"]) !== -1) {
return ["useRegister"];
}
if (arrayFindIndex(segments, ["auth", "forgotPassword"]) !== -1) {
return ["useForgotPassword"];
}
if (arrayFindIndex(segments, ["auth", "check"]) !== -1) {
return ["useAuthenticated", segments[2]]; // [auth, check, params]
}
if (arrayFindIndex(segments, ["auth", "onError"]) !== -1) {
return ["useCheckError"];
}
if (arrayFindIndex(segments, ["auth", "permissions"]) !== -1) {
return ["usePermissions"];
}
if (arrayFindIndex(segments, ["auth", "updatePassword"]) !== -1) {
return ["useUpdatePassword"];
}
}
return segments;
}
class BaseKeyBuilder {
segments: KeySegment[] = [];
constructor(segments: KeySegment[] = []) {
this.segments = segments;
}
key() {
return this.segments;
}
legacy() {
return convertToLegacy(this.segments);
}
get(legacy?: boolean) {
return legacy ? this.legacy() : this.segments;
}
}
class ParamsKeyBuilder extends BaseKeyBuilder {
params(paramsValue?: ParamsType) {
return new BaseKeyBuilder([...this.segments, paramsValue]);
}
}
class DataIdRequiringKeyBuilder extends BaseKeyBuilder {
id(idValue?: IdType) {
return new ParamsKeyBuilder([
...this.segments,
idValue ? String(idValue) : undefined,
]);
}
}
class DataIdsRequiringKeyBuilder extends BaseKeyBuilder {
ids(...idsValue: IdsType) {
return new ParamsKeyBuilder([
...this.segments,
...(idsValue.length ? [idsValue.map((el) => String(el))] : []),
]);
}
}
class DataResourceKeyBuilder extends BaseKeyBuilder {
action(actionType: ParametrizedDataActions): ParamsKeyBuilder;
action(actionType: IdRequiredDataActions): DataIdRequiringKeyBuilder;
action(actionType: IdsRequiredDataActions): DataIdsRequiringKeyBuilder;
action(
actionType:
| ParametrizedDataActions
| IdRequiredDataActions
| IdsRequiredDataActions,
): ParamsKeyBuilder | DataIdRequiringKeyBuilder | DataIdsRequiringKeyBuilder {
if (actionType === "one") {
return new DataIdRequiringKeyBuilder([...this.segments, actionType]);
}
if (actionType === "many") {
return new DataIdsRequiringKeyBuilder([...this.segments, actionType]);
}
if (["list", "infinite"].includes(actionType)) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
throw new Error("Invalid action type");
}
}
class DataKeyBuilder extends BaseKeyBuilder {
resource(resourceName?: string) {
return new DataResourceKeyBuilder([...this.segments, resourceName]);
}
mutation(mutationName: DataMutationActions) {
return new ParamsKeyBuilder([
...(mutationName === "custom" ? this.segments : [this.segments[0]]),
mutationName,
]);
}
}
class AuthKeyBuilder extends BaseKeyBuilder {
action(actionType: AuthActionType) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
}
class AccessResourceKeyBuilder extends BaseKeyBuilder {
action(resourceName: string) {
return new ParamsKeyBuilder([...this.segments, resourceName]);
}
}
class AccessKeyBuilder extends BaseKeyBuilder {
resource(resourceName?: string) {
return new AccessResourceKeyBuilder([...this.segments, resourceName]);
}
}
class AuditActionKeyBuilder extends BaseKeyBuilder {
action(actionType: Extract<AuditActionType, "list">) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
}
class AuditKeyBuilder extends BaseKeyBuilder {
resource(resourceName?: string) {
return new AuditActionKeyBuilder([...this.segments, resourceName]);
}
action(actionType: Extract<AuditActionType, "rename" | "log">) {
return new ParamsKeyBuilder([...this.segments, actionType]);
}
}
export class KeyBuilder extends BaseKeyBuilder {
data(name?: string) {
return new DataKeyBuilder(["data", name || "default"]);
}
auth() {
return new AuthKeyBuilder(["auth"]);
}
access() {
return new AccessKeyBuilder(["access"]);
}
audit() {
return new AuditKeyBuilder(["audit"]);
}
}
export const keys = () => new KeyBuilder([]);

View File

@@ -0,0 +1,43 @@
import { legacyResourceTransform } from ".";
describe("legacyResourceTransform", () => {
it("should return the legacy resource", () => {
expect(
legacyResourceTransform([
{
name: "posts",
meta: {
label: "Custom Post Label",
},
},
{
name: "categories",
options: {
label: "Custom Category Label",
},
},
]),
).toEqual([
{
name: "posts",
meta: { label: "Custom Post Label" },
label: "Custom Post Label",
route: "/posts",
canCreate: false,
canEdit: false,
canShow: false,
canDelete: undefined,
},
{
name: "categories",
options: { label: "Custom Category Label" },
label: "Custom Category Label",
route: "/categories",
canCreate: false,
canEdit: false,
canShow: false,
canDelete: undefined,
},
]);
});
});

View File

@@ -0,0 +1,29 @@
import type {
IResourceItem,
ResourceProps,
} from "../../../contexts/resource/types";
import { routeGenerator } from "../routeGenerator";
/**
* For the legacy definition of resources, we did a basic transformation for provided resources
* - This is meant to provide an easier way to access properties.
* - In the new definition, we don't need to do transformations and properties can be accessed via helpers or manually.
* This is kept for backward compability
*/
export const legacyResourceTransform = (resources: ResourceProps[]) => {
const _resources: IResourceItem[] = [];
resources.forEach((resource) => {
_resources.push({
...resource,
label: resource.meta?.label ?? resource.options?.label,
route: routeGenerator(resource, resources),
canCreate: !!resource.create,
canEdit: !!resource.edit,
canShow: !!resource.show,
canDelete: resource.canDelete,
});
});
return _resources;
};

View File

@@ -0,0 +1,48 @@
import { createResourceKey } from "../create-resource-key";
describe("createResourceKey", () => {
it("should return only name when no parents exist", () => {
expect(createResourceKey({ name: "posts" }, [{ name: "posts" }])).toBe(
"/posts",
);
});
it("should return the key with parent name", () => {
expect(
createResourceKey({ name: "posts", meta: { parent: "cms" } }, [
{ name: "posts" },
]),
).toBe("/cms/posts");
});
it("should return the key with multiple parents", () => {
expect(
createResourceKey({ name: "posts", meta: { parent: "orgs" } }, [
{ name: "orgs", meta: { parent: "cms" } },
{ name: "cms" },
]),
).toBe("/cms/orgs/posts");
});
it("should return the key with identifier", () => {
expect(
createResourceKey({ name: "posts", identifier: "foo" }, [
{ name: "posts", identifier: "foo" },
]),
).toBe("/foo");
});
it("should return custom route with legacy", () => {
expect(
createResourceKey(
{
name: "posts",
meta: { parent: "orgs" },
route: "custom-route",
},
[{ name: "orgs", meta: { parent: "cms" } }, { name: "cms" }],
true,
),
).toBe("/cms/orgs/custom-route");
});
});

View File

@@ -0,0 +1,166 @@
import { createTree } from "../create-tree";
describe("createTree", () => {
it("should return an empty array if no resources are provided", () => {
expect(createTree([])).toEqual([]);
});
it("should return a tree with a single resource", () => {
const resources = [
{
name: "posts",
},
];
expect(createTree(resources)).toEqual([
{
name: "posts",
key: "/posts",
children: [],
},
]);
});
it("should return two items", () => {
const resources = [
{
name: "posts",
},
{
name: "comments",
},
];
expect(createTree(resources)).toEqual([
{
name: "posts",
key: "/posts",
children: [],
},
{
name: "comments",
key: "/comments",
children: [],
},
]);
});
it("should return nested items", () => {
const resources = [
{
name: "posts",
},
{
name: "categories",
},
{
name: "comments",
meta: { parent: "posts" },
},
];
expect(createTree(resources)).toEqual([
{
name: "posts",
key: "/posts",
children: [
{
name: "comments",
key: "/posts/comments",
meta: { parent: "posts" },
children: [],
},
],
},
{
name: "categories",
key: "/categories",
children: [],
},
]);
});
it("should return double nested items", () => {
const resources = [
{
name: "posts",
meta: { parent: "cms" },
},
{
name: "categories",
},
{
name: "comments",
meta: { parent: "posts" },
},
];
expect(createTree(resources)).toEqual([
{
name: "cms",
key: "/cms",
children: [
{
name: "posts",
key: "/cms/posts",
meta: { parent: "cms" },
children: [
{
name: "comments",
key: "/cms/posts/comments",
meta: { parent: "posts" },
children: [],
},
],
},
],
},
{
name: "categories",
key: "/categories",
children: [],
},
]);
});
it("should define a key based on the identifier", () => {
const resources = [
{
name: "posts",
},
{
name: "posts",
identifier: "recent-posts",
meta: { parent: "posts" },
},
{
name: "posts",
identifier: "featured-posts",
meta: { parent: "posts" },
},
];
expect(createTree(resources)).toEqual([
{
name: "posts",
key: "/posts",
children: [
{
name: "posts",
key: "/posts/recent-posts",
identifier: "recent-posts",
meta: { parent: "posts" },
children: [],
},
{
name: "posts",
key: "/posts/featured-posts",
identifier: "featured-posts",
meta: { parent: "posts" },
children: [],
},
],
},
]);
});
});

View File

@@ -0,0 +1,30 @@
import type { IResourceItem } from "../../../contexts/resource/types";
import {
getParentResource,
removeLeadingTrailingSlashes,
} from "../../helpers/router";
export const createResourceKey = (
resource: IResourceItem,
resources: IResourceItem[],
legacy = false,
) => {
const parents: IResourceItem[] = [];
let currentParentResource = getParentResource(resource, resources);
while (currentParentResource) {
parents.push(currentParentResource);
currentParentResource = getParentResource(currentParentResource, resources);
}
parents.reverse();
const key = [...parents, resource]
.map((r) =>
removeLeadingTrailingSlashes(
(legacy ? r.route : undefined) ?? r.identifier ?? r.name,
),
)
.join("/");
return `/${key.replace(/^\//, "")}`;
};

View File

@@ -0,0 +1,85 @@
import type { IResourceItem } from "../../../contexts/resource/types";
import { getParentResource } from "../router";
import { createResourceKey } from "./create-resource-key";
export type Tree = {
item: IResourceItem;
children: { [key: string]: Tree };
};
export type FlatTreeItem = IResourceItem & {
key: string;
children: FlatTreeItem[];
};
export const createTree = (
resources: IResourceItem[],
legacy = false,
): FlatTreeItem[] => {
const root: Tree = {
item: {
name: "__root__",
},
children: {},
};
resources.forEach((resource) => {
const parents: IResourceItem[] = [];
let currentParent = getParentResource(resource, resources);
while (currentParent) {
parents.push(currentParent);
currentParent = getParentResource(currentParent, resources);
}
parents.reverse();
let currentTree = root;
parents.forEach((parent) => {
const key =
(legacy ? parent.route : undefined) ?? parent.identifier ?? parent.name;
if (!currentTree.children[key]) {
currentTree.children[key] = {
item: parent,
children: {},
};
}
currentTree = currentTree.children[key];
});
const key =
(legacy ? resource.route : undefined) ??
resource.identifier ??
resource.name;
if (!currentTree.children[key]) {
currentTree.children[key] = {
item: resource,
children: {},
};
}
});
const flatten = (tree: Tree): FlatTreeItem[] => {
const items: FlatTreeItem[] = [];
Object.keys(tree.children).forEach((key) => {
const itemKey = createResourceKey(
tree.children[key].item,
resources,
legacy,
);
const item: FlatTreeItem = {
...tree.children[key].item,
key: itemKey,
children: flatten(tree.children[key]),
};
items.push(item);
});
return items;
};
return flatten(root);
};

View File

@@ -0,0 +1,69 @@
import { pickResource } from ".";
import { legacyResourceTransform } from "../legacy-resource-transform";
describe("pickResource", () => {
it("should pick by name", () => {
const resource = pickResource("name", [
{ name: "name", options: { route: "route" } },
]);
expect(resource?.name).toBe("name");
});
it("should match by identifier", () => {
const resource = pickResource("identifier", [
{
name: "name",
identifier: "identifier",
options: { route: "route" },
},
]);
expect(resource?.identifier).toBe("identifier");
});
it("should not match by identifier if legacy", () => {
const resource = pickResource(
"identifier",
[
{
name: "name",
identifier: "identifier",
options: { route: "route" },
},
],
true,
);
expect(resource).toBeUndefined();
});
it("should return undefined if name and identifier does not match", () => {
const resource = pickResource("users", [
{ name: "name", identifier: "identifier" },
]);
expect(resource).toBeUndefined();
});
it("should match by route first if legacy", () => {
const resource = pickResource(
"route",
legacyResourceTransform([
{ name: "name", options: { route: "route" } },
{
name: "route",
},
]),
true,
);
expect(resource?.name).toBe("name");
});
it("should return undefined if `identifier` is not defined", () => {
const resource = pickResource(undefined, [{ name: "name" }], true);
expect(resource).toBeUndefined();
});
});

View File

@@ -0,0 +1,41 @@
import type { IResourceItem } from "../../../contexts/resource/types";
import { removeLeadingTrailingSlashes } from "../router/remove-leading-trailing-slashes";
/**
* Picks the resource based on the provided identifier.
* Identifier fallbacks to `name` if `identifier` is not explicitly provided to the resource.
* If legacy is true, then resource is matched by `route` first and then by `name`.
*/
export const pickResource = (
identifier?: string,
resources: IResourceItem[] = [],
/**
* If true, the identifier will be checked for `route` and `name` properties
*/
legacy = false,
): IResourceItem | undefined => {
if (!identifier) {
return undefined;
}
if (legacy) {
const resourceByRoute = resources.find(
(r) =>
removeLeadingTrailingSlashes(r.route ?? "") ===
removeLeadingTrailingSlashes(identifier),
);
const resource = resourceByRoute
? resourceByRoute
: resources.find((r) => r.name === identifier);
return resource;
}
let resource = resources.find((r) => r.identifier === identifier);
if (!resource) {
resource = resources.find((r) => r.name === identifier);
}
return resource;
};

View File

@@ -0,0 +1,41 @@
import { pickDataProvider } from ".";
import type { IResourceItem } from "../../../contexts/resource/types";
describe("pickDataProvider", () => {
it("should return the dataProvider from the params", () => {
expect(pickDataProvider("resourceName", "custom-provider", [])).toBe(
"custom-provider",
);
});
it("should return default if resource is not found in resources and no dataprovidername is provided", () => {
expect(pickDataProvider("resourceName", undefined, [])).toBe("default");
});
it("should return resource's dataprovidername", () => {
const resources: IResourceItem[] = [
{
name: "resourceName",
meta: {
dataProviderName: "custom-provider",
},
},
];
expect(pickDataProvider("resourceName", undefined, resources)).toBe(
"custom-provider",
);
});
it("should return dataprovidername from params even if resource matches", () => {
const resources: IResourceItem[] = [
{
name: "resourceName",
meta: {
dataProviderName: "custom-provider",
},
},
];
expect(
pickDataProvider("resourceName", "other-custom-provider", resources),
).toBe("other-custom-provider");
});
});

View File

@@ -0,0 +1,29 @@
import type { IResourceItem } from "../../../contexts/resource/types";
import { pickResource } from "../pick-resource";
import { pickNotDeprecated } from "../pickNotDeprecated";
/**
* Picks the data provider name based on the provided name or fallbacks to resource definition, or `default`.
*/
export const pickDataProvider = (
resourceName?: string,
dataProviderName?: string,
resources?: IResourceItem[],
) => {
if (dataProviderName) {
return dataProviderName;
}
/**
* In this helper, we don't do `route` based matching therefore there's no need to check for `legacy` behaviors.
*/
const resource = pickResource(resourceName, resources);
const meta = pickNotDeprecated(resource?.meta, resource?.options);
if (meta?.dataProviderName) {
return meta.dataProviderName;
}
return "default";
};

View File

@@ -0,0 +1,28 @@
import { pickNotDeprecated } from ".";
describe("pickNotDeprecated", () => {
test("should returns a first value that is not undefined", () => {
const testCases = [
{
args: [undefined, 1, undefined],
expected: 1,
},
{
args: [undefined, 0, true],
expected: 0,
},
{
args: [false, {}],
expected: false,
},
{
args: [{ id: 1 }, undefined],
expected: { id: 1 },
},
];
testCases.forEach(({ args, expected }) => {
expect(pickNotDeprecated(...args)).toEqual(expected);
});
});
});

View File

@@ -0,0 +1,9 @@
/*
* Returns first value that is not undefined.
* @internal This is an internal helper function. Please do not use externally.
*/
export const pickNotDeprecated = <T extends unknown[]>(
...args: T
): T[never] => {
return args.find((arg) => typeof arg !== "undefined");
};

View File

@@ -0,0 +1,19 @@
import type { QueryFunctionContext, QueryKey } from "@tanstack/react-query";
export const prepareQueryContext = (
context: QueryFunctionContext<QueryKey, any>,
): Omit<QueryFunctionContext<QueryKey, any>, "meta"> => {
const queryContext = {
queryKey: context.queryKey,
pageParam: context.pageParam,
};
Object.defineProperty(queryContext, "signal", {
enumerable: true,
get: () => {
return context.signal;
},
});
return queryContext;
};

View File

@@ -0,0 +1,21 @@
import { propertyPathToArray } from ".";
describe("propertyPathToArray", () => {
it("should return an array of strings when given a string of dot-separated property names", () => {
const propertyPath = "foo.bar.baz";
const result = propertyPathToArray(propertyPath);
expect(result).toEqual(["foo", "bar", "baz"]);
});
it("should return an array of numbers and strings when given a string of dot-separated property names that include numbers", () => {
const propertyPath = "foo.1.bar.2.baz";
const result = propertyPathToArray(propertyPath);
expect(result).toEqual(["foo", 1, "bar", 2, "baz"]);
});
it("should return an array with a single element when given a string with no dots", () => {
const propertyPath = "foo";
const result = propertyPathToArray(propertyPath);
expect(result).toEqual(["foo"]);
});
});

View File

@@ -0,0 +1,5 @@
export const propertyPathToArray = (propertyPath: string) => {
return propertyPath
.split(".")
.map((item) => (!Number.isNaN(Number(item)) ? Number(item) : item));
};

View File

@@ -0,0 +1,584 @@
import { queryKeys, queryKeysReplacement } from ".";
describe("queryKeys", () => {
describe("all", () => {
it("should return default data-provider", () => {
expect(queryKeys().all).toEqual(["default"]);
});
it("should return custom data-provider", () => {
expect(queryKeys(undefined, "custom-data-provider").all).toEqual([
"custom-data-provider",
]);
});
});
describe("resourceAll", () => {
it("should return without data provider", () => {
expect(queryKeys("post").resourceAll).toEqual(["default", "post"]);
});
it("should return with data provider", () => {
expect(queryKeys("post", "custom-data-provider").resourceAll).toEqual([
"custom-data-provider",
"post",
]);
});
it("should return without resource", () => {
expect(queryKeys(undefined, "custom-data-provider").resourceAll).toEqual([
"custom-data-provider",
"",
]);
});
});
describe("list", () => {
it("should return without data provider", () => {
expect(queryKeys("post").list()).toEqual(["default", "post", "list", {}]);
});
it("should return with data provider", () => {
expect(queryKeys("post", "custom-data-provider").list()).toEqual([
"custom-data-provider",
"post",
"list",
{},
]);
});
it("should return without resource", () => {
expect(queryKeys(undefined, "custom-data-provider").list()).toEqual([
"custom-data-provider",
"",
"list",
{},
]);
});
it("should return with config", () => {
expect(
queryKeys(undefined, "custom-data-provider").list({
hasPagination: false,
}),
).toEqual([
"custom-data-provider",
"",
"list",
{
hasPagination: false,
},
]);
});
it("should return with config and meta", () => {
expect(
queryKeys(undefined, "custom-data-provider", {
meta: {
foo: "bar",
},
}).list({
hasPagination: false,
}),
).toEqual([
"custom-data-provider",
"",
"list",
{
hasPagination: false,
meta: {
foo: "bar",
},
},
]);
});
it("should return with config and metaData", () => {
expect(
queryKeys(undefined, "custom-data-provider", undefined, {
meta: {
foo: "bar",
},
}).list({
hasPagination: false,
}),
).toEqual([
"custom-data-provider",
"",
"list",
{
hasPagination: false,
meta: {
foo: "bar",
},
},
]);
});
});
describe("many", () => {
it("should return without data provider", () => {
expect(queryKeys("post").many()).toEqual([
"default",
"post",
"getMany",
{},
]);
});
it("should return with data provider", () => {
expect(queryKeys("post", "custom-data-provider").many()).toEqual([
"custom-data-provider",
"post",
"getMany",
{},
]);
});
it("should return without resource", () => {
expect(queryKeys(undefined, "custom-data-provider").many()).toEqual([
"custom-data-provider",
"",
"getMany",
{},
]);
});
it("should return with ids", () => {
expect(
queryKeys(undefined, "custom-data-provider").many([1, 2, 3]),
).toEqual(["custom-data-provider", "", "getMany", ["1", "2", "3"], {}]);
});
it("should return with ids and meta", () => {
expect(
queryKeys(undefined, "custom-data-provider", {
meta: {
foo: "bar",
},
}).many([1, 2, 3]),
).toEqual([
"custom-data-provider",
"",
"getMany",
["1", "2", "3"],
{
meta: {
foo: "bar",
},
},
]);
});
it("should return with ids and metaData", () => {
expect(
queryKeys(undefined, "custom-data-provider", undefined, {
meta: {
foo: "bar",
},
}).many([1, 2, 3]),
).toEqual([
"custom-data-provider",
"",
"getMany",
["1", "2", "3"],
{
meta: {
foo: "bar",
},
},
]);
});
});
describe("detail", () => {
it("should return without data provider", () => {
expect(queryKeys("post").detail(1)).toEqual([
"default",
"post",
"detail",
"1",
{},
]);
});
it("should return with data provider", () => {
expect(queryKeys("post", "custom-data-provider").detail(1)).toEqual([
"custom-data-provider",
"post",
"detail",
"1",
{},
]);
});
it("should return without resource", () => {
expect(queryKeys(undefined, "custom-data-provider").detail(1)).toEqual([
"custom-data-provider",
"",
"detail",
"1",
{},
]);
});
it("should return without id", () => {
expect(queryKeys(undefined, "custom-data-provider").detail()).toEqual([
"custom-data-provider",
"",
"detail",
undefined,
{},
]);
});
it("should return with ids", () => {
expect(queryKeys(undefined, "custom-data-provider").detail(1)).toEqual([
"custom-data-provider",
"",
"detail",
"1",
{},
]);
});
it("should return with ids and meta", () => {
expect(
queryKeys(undefined, "custom-data-provider", {
meta: {
foo: "bar",
},
}).detail(1),
).toEqual([
"custom-data-provider",
"",
"detail",
"1",
{
meta: {
foo: "bar",
},
},
]);
});
it("should return with ids and metaData", () => {
expect(
queryKeys(undefined, "custom-data-provider", undefined, {
meta: {
foo: "bar",
},
}).detail(1),
).toEqual([
"custom-data-provider",
"",
"detail",
"1",
{
meta: {
foo: "bar",
},
},
]);
});
});
describe("logList", () => {
it("should return with resource", () => {
expect(queryKeys("post").logList()).toEqual(["logList", "post"]);
});
it("should return with meta", () => {
expect(
queryKeys("post").logList({
foo: "bar",
}),
).toEqual([
"logList",
"post",
{
foo: "bar",
},
]);
});
it("should return with metaData", () => {
expect(
queryKeys("post", undefined, undefined, {
foo: "bar",
}).logList(),
).toEqual([
"logList",
"post",
{
foo: "bar",
},
]);
});
});
});
describe("queryKeysReplacement should match queryKeys", () => {
describe("all", () => {
it("should return default data-provider", () => {
expect(queryKeysReplacement(true)().all).toEqual(["default"]);
});
it("should return custom data-provider", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider").all,
).toEqual(["custom-data-provider"]);
});
});
describe("resourceAll", () => {
it("should return without data provider", () => {
expect(queryKeysReplacement(true)("post").resourceAll).toEqual([
"default",
"post",
]);
});
it("should return with data provider", () => {
expect(
queryKeysReplacement(true)("post", "custom-data-provider").resourceAll,
).toEqual(["custom-data-provider", "post"]);
});
it("should return without resource", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider")
.resourceAll,
).toEqual(["custom-data-provider", ""]);
});
});
describe("list", () => {
it("should return without data provider", () => {
expect(queryKeysReplacement(true)("post").list()).toEqual([
"default",
"post",
"list",
{},
]);
});
it("should return with data provider", () => {
expect(
queryKeysReplacement(true)("post", "custom-data-provider").list(),
).toEqual(["custom-data-provider", "post", "list", {}]);
});
it("should return without resource", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider").list(),
).toEqual(["custom-data-provider", "", "list", {}]);
});
it("should return with config", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider").list({
hasPagination: false,
}),
).toEqual([
"custom-data-provider",
"",
"list",
{
hasPagination: false,
},
]);
});
it("should return with config and meta", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider", {
meta: {
foo: "bar",
},
}).list({
hasPagination: false,
}),
).toEqual([
"custom-data-provider",
"",
"list",
{
hasPagination: false,
meta: {
foo: "bar",
},
},
]);
});
it("should return with config and metaData", () => {
expect(
queryKeysReplacement(true)(
undefined,
"custom-data-provider",
undefined,
{
meta: {
foo: "bar",
},
},
).list({
hasPagination: false,
}),
).toEqual([
"custom-data-provider",
"",
"list",
{
hasPagination: false,
meta: {
foo: "bar",
},
},
]);
});
});
describe("many", () => {
it("should return without data provider", () => {
expect(queryKeysReplacement(true)("post").many()).toEqual([
"default",
"post",
"getMany",
{},
]);
});
it("should return with data provider", () => {
expect(
queryKeysReplacement(true)("post", "custom-data-provider").many(),
).toEqual(["custom-data-provider", "post", "getMany", {}]);
});
it("should return without resource", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider").many(),
).toEqual(["custom-data-provider", "", "getMany", {}]);
});
it("should return with ids", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider").many([
1, 2, 3,
]),
).toEqual(["custom-data-provider", "", "getMany", ["1", "2", "3"], {}]);
});
it("should return with ids and meta", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider", {
meta: {
foo: "bar",
},
}).many([1, 2, 3]),
).toEqual([
"custom-data-provider",
"",
"getMany",
["1", "2", "3"],
{
meta: {
foo: "bar",
},
},
]);
});
it("should return with ids and metaData", () => {
expect(
queryKeysReplacement(true)(
undefined,
"custom-data-provider",
undefined,
{
meta: {
foo: "bar",
},
},
).many([1, 2, 3]),
).toEqual([
"custom-data-provider",
"",
"getMany",
["1", "2", "3"],
{
meta: {
foo: "bar",
},
},
]);
});
});
describe("detail", () => {
it("should return without data provider", () => {
expect(queryKeysReplacement(true)("post").detail(1)).toEqual([
"default",
"post",
"detail",
"1",
{},
]);
});
it("should return with data provider", () => {
expect(
queryKeysReplacement(true)("post", "custom-data-provider").detail(1),
).toEqual(["custom-data-provider", "post", "detail", "1", {}]);
});
it("should return without resource", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider").detail(1),
).toEqual(["custom-data-provider", "", "detail", "1", {}]);
});
it("should return without id", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider").detail(),
).toEqual(["custom-data-provider", "", "detail", undefined, {}]);
});
it("should return with ids", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider").detail(1),
).toEqual(["custom-data-provider", "", "detail", "1", {}]);
});
it("should return with ids and meta", () => {
expect(
queryKeysReplacement(true)(undefined, "custom-data-provider", {
meta: {
foo: "bar",
},
}).detail(1),
).toEqual([
"custom-data-provider",
"",
"detail",
"1",
{
meta: {
foo: "bar",
},
},
]);
});
it("should return with ids and metaData", () => {
expect(
queryKeysReplacement(true)(
undefined,
"custom-data-provider",
undefined,
{
meta: {
foo: "bar",
},
},
).detail(1),
).toEqual([
"custom-data-provider",
"",
"detail",
"1",
{
meta: {
foo: "bar",
},
},
]);
});
});
describe("logList", () => {
it("should return with resource", () => {
expect(queryKeysReplacement(true)("post").logList()).toEqual([
"logList",
"post",
]);
});
it("should return with meta", () => {
expect(
queryKeysReplacement(true)("post").logList({
foo: "bar",
}),
).toEqual([
"logList",
"post",
{
foo: "bar",
},
]);
});
it("should return with metaData", () => {
expect(
queryKeysReplacement(true)("post", undefined, undefined, {
foo: "bar",
}).logList(),
).toEqual([
"logList",
"post",
{
foo: "bar",
},
]);
});
});
});

View File

@@ -0,0 +1,114 @@
import type { QueryKey } from "@tanstack/react-query";
import type { IQueryKeys, MetaQuery } from "../../../contexts/data/types";
import { keys as newKeys } from "../keys";
import { pickNotDeprecated } from "../pickNotDeprecated";
/**
* @deprecated `queryKeys` is deprecated. Please use `keys` instead.
*/
export const queryKeys = (
resource?: string,
dataProviderName?: string,
meta?: MetaQuery,
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery | undefined,
): IQueryKeys => {
const providerName = dataProviderName || "default";
const keys: IQueryKeys = {
all: [providerName],
resourceAll: [providerName, resource || ""],
list: (config) => [
...keys.resourceAll,
"list",
{
...config,
...(pickNotDeprecated(meta, metaData) || {}),
} as QueryKey,
],
many: (ids) =>
[
...keys.resourceAll,
"getMany",
ids?.map(String) as QueryKey,
{ ...(pickNotDeprecated(meta, metaData) || {}) } as QueryKey,
].filter((item) => item !== undefined),
detail: (id) => [
...keys.resourceAll,
"detail",
id?.toString(),
{ ...(pickNotDeprecated(meta, metaData) || {}) } as QueryKey,
],
logList: (meta) =>
["logList", resource, meta as any, metaData as QueryKey].filter(
(item) => item !== undefined,
),
};
return keys;
};
export const queryKeysReplacement = (preferLegacyKeys?: boolean) => {
return (
resource?: string,
dataProviderName?: string,
meta?: MetaQuery,
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery | undefined,
): IQueryKeys => {
const providerName = dataProviderName || "default";
const keys: IQueryKeys = {
all: newKeys().data(providerName).get(preferLegacyKeys),
resourceAll: newKeys()
.data(dataProviderName)
.resource(resource ?? "")
.get(preferLegacyKeys),
list: (config) =>
newKeys()
.data(dataProviderName)
.resource(resource ?? "")
.action("list")
.params({
...config,
...(pickNotDeprecated(meta, metaData) || {}),
})
.get(preferLegacyKeys),
many: (ids) =>
newKeys()
.data(dataProviderName)
.resource(resource ?? "")
.action("many")
.ids(...(ids ?? []))
.params({
...(pickNotDeprecated(meta, metaData) || {}),
})
.get(preferLegacyKeys),
detail: (id) =>
newKeys()
.data(dataProviderName)
.resource(resource ?? "")
.action("one")
.id(id ?? "")
.params({
...(pickNotDeprecated(meta, metaData) || {}),
})
.get(preferLegacyKeys),
logList: (meta) =>
[
...newKeys()
.audit()
.resource(resource)
.action("list")
.params(meta)
.get(preferLegacyKeys),
metaData as QueryKey,
].filter((item) => item !== undefined),
};
return keys;
};
};

View File

@@ -0,0 +1,55 @@
import { redirectPage } from ".";
import type { IRefineContextOptions } from "../../../contexts/refine/types";
import type { RedirectAction } from "../../../hooks/form/types";
describe("redirectPath", () => {
it.each(["edit", "list", "show", false] as RedirectAction[])(
"should return redirectFromProps if it is provided %s",
(redirectFromProps) => {
const action = "create";
const redirectOptions: IRefineContextOptions["redirect"] = {
afterClone: "edit",
afterCreate: "list",
afterEdit: "show",
};
const result = redirectPage({
redirectFromProps,
action,
redirectOptions,
});
expect(result).toEqual(redirectFromProps);
},
);
it.each(["edit", "create", "clone", "list"] as const)(
"should return redirect option according to action %s",
(action) => {
const redirectOptions: IRefineContextOptions["redirect"] = {
afterClone: "edit",
afterCreate: "list",
afterEdit: "show",
};
const result = redirectPage({
action,
redirectOptions,
});
switch (action) {
case "clone":
expect(result).toEqual(redirectOptions.afterClone);
break;
case "create":
expect(result).toEqual(redirectOptions.afterCreate);
break;
case "edit":
expect(result).toEqual(redirectOptions.afterEdit);
break;
case "list":
expect(result).toBeFalsy();
break;
}
},
);
});

View File

@@ -0,0 +1,30 @@
import type { IRefineContextOptions } from "../../../contexts/refine/types";
import type { Action } from "../../../contexts/router/types";
import type { RedirectAction } from "../../../hooks/form/types";
type RedirectPageProps = {
redirectFromProps?: RedirectAction;
action: Action;
redirectOptions: IRefineContextOptions["redirect"];
};
export const redirectPage = ({
redirectFromProps,
action,
redirectOptions,
}: RedirectPageProps): RedirectAction => {
if (redirectFromProps || redirectFromProps === false) {
return redirectFromProps;
}
switch (action) {
case "clone":
return redirectOptions.afterClone;
case "create":
return redirectOptions.afterCreate;
case "edit":
return redirectOptions.afterEdit;
default:
return false;
}
};

View File

@@ -0,0 +1,70 @@
import { routeGenerator } from ".";
import type { ResourceProps } from "../../../contexts/resource/types";
const mockResources: ResourceProps[] = [
{ name: "posts" },
{
name: "category",
},
{
name: "active",
parentName: "posts",
},
{
name: "first",
parentName: "active",
},
];
const mockItemResourcePropsWithoutParent: ResourceProps = {
name: "category",
};
const mockItemResourcePropsWithParent: ResourceProps = {
name: "active",
parentName: "posts",
};
const mockItemResourcePropsWithTwoParent: ResourceProps = {
name: "first",
parentName: "active",
};
describe("routeGenerator", () => {
it("should return category route", async () => {
const route = routeGenerator(
mockItemResourcePropsWithoutParent,
mockResources,
);
expect(route).toEqual("/category");
});
it("should equal return with parent route", async () => {
const route = routeGenerator(
mockItemResourcePropsWithParent,
mockResources,
);
expect(route).toEqual("/posts/active");
});
it("should return exect route", async () => {
const route = routeGenerator(
mockItemResourcePropsWithTwoParent,
mockResources,
);
expect(route).toEqual("/posts/active/first");
});
it("should return exect route with meta.route", async () => {
const route = routeGenerator(
{
...mockItemResourcePropsWithTwoParent,
meta: {
route: "foo",
},
},
mockResources,
);
expect(route).toEqual("/posts/active/foo");
});
});

View File

@@ -0,0 +1,29 @@
import type { ResourceProps } from "../../../contexts/resource/types";
import { pickNotDeprecated } from "../pickNotDeprecated";
import { getParentPrefixForResource } from "../router";
/**
* generates route for the resource based on parents and custom routes
* @deprecated this is a **legacy** function and works only with the old resource definition
*/
export const routeGenerator = (
item: ResourceProps,
resourcesFromProps: ResourceProps[],
): string | undefined => {
let route;
const parentPrefix = getParentPrefixForResource(
item,
resourcesFromProps,
true,
);
if (parentPrefix) {
const meta = pickNotDeprecated(item.meta, item.options);
route = `${parentPrefix}/${meta?.route ?? item.name}`;
} else {
route = item.options?.route ?? item.name;
}
return `/${route.replace(/^\//, "")}`;
};

View File

@@ -0,0 +1,21 @@
import { checkBySegments } from "../check-by-segments";
describe("checkBySegments", () => {
it("should return true if the route and resourceRoute match by segments", () => {
const result = checkBySegments("/users/edit/123", "/users/edit/:id");
expect(result).toEqual(true);
});
it("should return false if the route and resourceRoute don't match by segments", () => {
const result = checkBySegments("/users/edit/123", "/posts/edit/:id/");
expect(result).toEqual(false);
});
it("should return false if segments are not equal", () => {
const result = checkBySegments("/users/edit/123", "/users/edit/:id/step");
expect(result).toEqual(false);
});
});

View File

@@ -0,0 +1,52 @@
import { composeRoute } from "../compose-route";
describe("composeRoute", () => {
it("should compose single slash", () => {
const result = composeRoute("/");
expect(result).toEqual("/");
});
it("should return the route as same if no params are found", () => {
const result = composeRoute("/users", { id: 1 });
expect(result).toEqual("/users");
});
it("should return the route with params replaced", () => {
const result = composeRoute("/users/:id", { id: 1 });
expect(result).toEqual("/users/1");
});
it("should return the route with multiple params replaced", () => {
const result = composeRoute(
"/users/:id/:name",
{ name: "Jane" },
{ id: 5 },
{
id: 1,
name: "John",
},
);
expect(result).toEqual("/users/1/John");
});
it("should return the route with multiple params by prioritizing meta params", () => {
const result = composeRoute(
"/users/:id/:name",
{},
{ id: 1 },
{ name: "Doe" },
);
expect(result).toEqual("/users/1/Doe");
});
it("should return route when not match object", () => {
const result = composeRoute("/users/:other", {});
expect(result).toEqual("/users/:other");
});
});

View File

@@ -0,0 +1,169 @@
import { getActionRoutesFromResource } from "../get-action-routes-from-resource";
describe("getActionRoutesFromResource", () => {
it("should return empty array if no actions are found", () => {
const result = getActionRoutesFromResource(
{
name: "users",
meta: {},
},
[],
);
expect(result).toEqual([]);
});
it("should return the default routes for a given resource", () => {
const result = getActionRoutesFromResource(
{
name: "users",
meta: {},
list: () => null,
create: () => null,
edit: () => null,
show: () => null,
clone: () => null,
},
[],
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "list",
route: "/users",
}),
expect.objectContaining({
action: "create",
route: "/users/create",
}),
expect.objectContaining({
action: "edit",
route: "/users/edit/:id",
}),
expect.objectContaining({
action: "show",
route: "/users/show/:id",
}),
expect.objectContaining({
action: "clone",
route: "/users/clone/:id",
}),
]),
);
});
it("should return the default routes for a given resource with parent prefix [legacy]", () => {
const result = getActionRoutesFromResource(
{
name: "users",
meta: {
parent: "orgs",
},
edit: () => null,
},
[],
true,
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "edit",
route: "/orgs/users/edit/:id",
}),
]),
);
});
it("should return the default routes for a given resource without parent prefix", () => {
const result = getActionRoutesFromResource(
{
name: "users",
meta: {
parent: "orgs",
},
edit: () => null,
},
[],
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "edit",
route: "/users/edit/:id",
}),
]),
);
});
it("should not include parent prefix if route is explicitly defined", () => {
const result = getActionRoutesFromResource(
{
name: "users",
meta: {
parent: "orgs",
},
edit: {
path: "edit/:id",
component: () => null,
},
},
[],
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "edit",
route: "/edit/:id",
}),
]),
);
});
it("should use deprecated route prop if legacy is set to true", () => {
const result = getActionRoutesFromResource(
{
name: "users",
parentName: "orgs",
options: {
route: "custom-users",
},
list: () => null,
},
[],
true,
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "list",
route: "/orgs/custom-users",
}),
]),
);
});
it("should use the specific route instead of the default one with no component", () => {
const result = getActionRoutesFromResource(
{
name: "users",
meta: {},
list: "/super-cool-nesting/users/list",
},
[],
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "list",
route: "/super-cool-nesting/users/list",
}),
]),
);
});
});

View File

@@ -0,0 +1,45 @@
import { getDefaultActionPath } from "../get-default-action-path";
describe("getDefaultActionPath", () => {
it("should return the default path for a given action and resource", () => {
expect(getDefaultActionPath("users", "list")).toBe("/users");
expect(getDefaultActionPath("users", "create")).toBe("/users/create");
expect(getDefaultActionPath("users", "edit")).toBe("/users/edit/:id");
expect(getDefaultActionPath("users", "show")).toBe("/users/show/:id");
expect(getDefaultActionPath("users", "clone")).toBe("/users/clone/:id");
});
it("should return the default path for a given action and resource with parent prefix", () => {
expect(getDefaultActionPath("users", "list", "/orgs")).toBe("/orgs/users");
expect(getDefaultActionPath("users", "create", "/orgs")).toBe(
"/orgs/users/create",
);
expect(getDefaultActionPath("users", "edit", "/orgs")).toBe(
"/orgs/users/edit/:id",
);
expect(getDefaultActionPath("users", "show", "/orgs")).toBe(
"/orgs/users/show/:id",
);
expect(getDefaultActionPath("users", "clone", "/orgs")).toBe(
"/orgs/users/clone/:id",
);
});
it("should return the default path for a given action and resource with multiple parent prefixes", () => {
expect(getDefaultActionPath("users", "list", "/orgs/posts")).toBe(
"/orgs/posts/users",
);
expect(getDefaultActionPath("users", "create", "/orgs/posts")).toBe(
"/orgs/posts/users/create",
);
expect(getDefaultActionPath("users", "edit", "/orgs/posts")).toBe(
"/orgs/posts/users/edit/:id",
);
expect(getDefaultActionPath("users", "show", "/orgs/posts")).toBe(
"/orgs/posts/users/show/:id",
);
expect(getDefaultActionPath("users", "clone", "/orgs/posts")).toBe(
"/orgs/posts/users/clone/:id",
);
});
});

View File

@@ -0,0 +1,80 @@
import { getParentPrefixForResource } from "../get-parent-prefix-for-resource";
describe("getParentPrefixForResource", () => {
it("should return undefined if no parent is found", () => {
const resources = [
{
name: "users",
},
];
expect(getParentPrefixForResource(resources[0], resources)).toBe(undefined);
});
it("should return the parent prefix", () => {
const resources = [
{
name: "users",
},
{
name: "posts",
meta: {
parent: "users",
},
},
];
expect(getParentPrefixForResource(resources[1], resources)).toBe("/users");
});
it("should return the parent prefix for deeply nested resources", () => {
const resources = [
{
name: "users",
},
{
name: "posts",
meta: {
parent: "users",
},
},
{
name: "comments",
meta: {
parent: "posts",
},
},
];
expect(getParentPrefixForResource(resources[2], resources)).toBe(
"/users/posts",
);
});
it("should return the parent prefix for deeply nested resources with legacy routes", () => {
const resources = [
{
name: "users",
options: {
route: "custom-users",
},
},
{
name: "posts",
meta: {
parent: "users",
},
},
{
name: "comments",
meta: {
parent: "posts",
},
},
];
expect(getParentPrefixForResource(resources[2], resources, true)).toBe(
"/custom-users/posts",
);
});
});

View File

@@ -0,0 +1,51 @@
import { getParentResource } from "../get-parent-resource";
describe("getParentResource", () => {
it("should return undefined if no parent is given", () => {
const result = getParentResource(
{
name: "users",
meta: {},
},
[],
);
expect(result).toEqual(undefined);
});
it("should return the parent resource if parent is given", () => {
const result = getParentResource(
{
name: "users",
meta: {
parent: "orgs",
},
},
[
{
name: "orgs",
},
],
);
expect(result).toEqual({
name: "orgs",
});
});
it("should return the parent resource if parent is given even if the parent is not defined", () => {
const result = getParentResource(
{
name: "users",
options: {
parent: "orgs",
},
},
[],
);
expect(result).toEqual({
name: "orgs",
});
});
});

View File

@@ -0,0 +1,11 @@
import { isParameter } from "../is-parameter";
describe("isParameter", () => {
it("should return true for a parameter", () => {
expect(isParameter(":id")).toBe(true);
});
it("should return false for a non-parameter", () => {
expect(isParameter("id")).toBe(false);
});
});

View File

@@ -0,0 +1,18 @@
import { isSegmentCountsSame } from "../is-segment-counts-same";
describe("isSegmentCountsSame", () => {
it("should return true if the route and resourceRoute have the same number of segments", () => {
const result = isSegmentCountsSame("/users/edit/123", "/users/edit/:id");
expect(result).toEqual(true);
});
it("should return false if the route and resourceRoute don't have the same number of segments", () => {
const result = isSegmentCountsSame(
"/users/edit/123",
"users/posts/edit/:id/",
);
expect(result).toEqual(false);
});
});

View File

@@ -0,0 +1,59 @@
import { matchResourceFromRoute } from "../match-resource-from-route";
describe("matchResourceFromRoute", () => {
it("should return found false if no resource is given", () => {
const result = matchResourceFromRoute("/users", []);
expect(result.found).toEqual(false);
});
it("should return found false if no route is given", () => {
const result = matchResourceFromRoute("", [
{
name: "users",
edit: {
path: "/users/edit/:id",
component: () => null,
},
},
]);
expect(result.found).toEqual(false);
});
it("should return found true if route is found", () => {
const result = matchResourceFromRoute("/users/edit/123", [
{
name: "users",
edit: {
path: "/users/edit/:id",
component: () => null,
},
},
]);
expect(result.found).toEqual(true);
});
it("should return the best one if multiple routes are found", () => {
const result = matchResourceFromRoute("/users/orgs/edit/123", [
{
name: "users",
edit: {
path: "/users/:type/edit/:id",
component: () => null,
},
},
{
name: "org-users",
edit: {
path: "/users/orgs/edit/:id",
component: () => null,
},
},
]);
expect(result.found).toEqual(true);
expect(result.matchedRoute).toEqual("/users/orgs/edit/:id");
});
});

View File

@@ -0,0 +1,89 @@
import { pickMatchedRoute } from "../pick-matched-route";
describe("pickMatchedRoute", () => {
it("should return the route with no params", () => {
const routes = [
{
route: "/users/edit/123",
action: "edit" as const,
resource: { name: "users" },
},
{
route: "users/:action/:id/",
action: "edit" as const,
resource: { name: "users" },
},
{
route: "/users/:action/:id",
action: "edit" as const,
resource: { name: "users" },
},
{
route: "/users/edit/:id",
action: "edit" as const,
resource: { name: "users" },
},
];
const picked = pickMatchedRoute(routes);
expect(picked?.route).toEqual("/users/edit/123");
});
it("should return the route with least params", () => {
const routes = [
{
route: "/users/:action/:id",
action: "edit" as const,
resource: { name: "users" },
},
{
route: "/users/edit/:id",
action: "edit" as const,
resource: { name: "users" },
},
];
const picked = pickMatchedRoute(routes);
expect(picked?.route).toEqual("/users/edit/:id");
});
it("should return the latest parametrized route", () => {
const routes = [
{
route: "/users/page/:action/:id",
action: "edit" as const,
resource: { name: "users" },
},
{
route: "/users/page/list/:id/",
action: "edit" as const,
resource: { name: "users" },
},
];
const picked = pickMatchedRoute(routes);
expect(picked?.route).toEqual("/users/page/list/:id/");
});
it("should return the latest parametrized route with single", () => {
const routes = [
{
route: "/users/:org/list/123",
action: "edit" as const,
resource: { name: "users" },
},
{
route: "/users/refine/list/:id",
action: "edit" as const,
resource: { name: "users" },
},
];
const picked = pickMatchedRoute(routes);
expect(picked?.route).toEqual("/users/refine/list/:id");
});
});

View File

@@ -0,0 +1,18 @@
import { pickRouteParams } from "../pick-route-params";
describe("pickRouteParams", () => {
it("should extract route params from a path", () => {
expect(pickRouteParams("/users/:id/posts/:postId")).toEqual([
"id",
"postId",
]);
});
it("should return an empty array if no route params are given", () => {
expect(pickRouteParams("/users/list")).toEqual([]);
});
it("should extract route params from a path", () => {
expect(pickRouteParams("users/:id")).toEqual(["id"]);
});
});

View File

@@ -0,0 +1,26 @@
import { prepareRouteParams } from "../prepare-route-params";
describe("prepareRouteParams", () => {
it("should return an empty object if no route params are given", () => {
expect(prepareRouteParams([])).toEqual({});
});
it("should return `id` property when params array contains `id`", () => {
expect(prepareRouteParams(["id"], { id: "1" })).toEqual({ id: "1" });
});
it("should prioritize meta over params", () => {
expect(prepareRouteParams(["id"], { id: "2" })).toEqual({
id: "2",
});
});
it("should combine params and meta", () => {
expect(
prepareRouteParams(["id", "action"], {
...{ id: "1" },
...{ action: "2" },
}),
).toEqual({ id: "1", action: "2" });
});
});

View File

@@ -0,0 +1,15 @@
import { removeLeadingTrailingSlashes } from "../remove-leading-trailing-slashes";
describe("removeLeadingTrailingSlashes", () => {
it("should remove leading and trailing slashes", () => {
expect(removeLeadingTrailingSlashes("/a/b/c")).toEqual("a/b/c");
});
it("should remove leading and trailing slashes", () => {
expect(removeLeadingTrailingSlashes("a/b/c")).toEqual("a/b/c");
});
it("should remove leading and trailing slashes", () => {
expect(removeLeadingTrailingSlashes("/a/b/c/")).toEqual("a/b/c");
});
});

View File

@@ -0,0 +1,15 @@
import { splitToSegments } from "../split-to-segments";
describe("splitToSegments", () => {
it("should split a path to segments", () => {
expect(splitToSegments("/a/b/c")).toEqual(["a", "b", "c"]);
});
it("should split a path to segments", () => {
expect(splitToSegments("a/b/c")).toEqual(["a", "b", "c"]);
});
it("should split a path to segments", () => {
expect(splitToSegments("/a/b/c/")).toEqual(["a", "b", "c"]);
});
});

View File

@@ -0,0 +1,28 @@
import { isParameter } from "./is-parameter";
import { isSegmentCountsSame } from "./is-segment-counts-same";
import { removeLeadingTrailingSlashes } from "./remove-leading-trailing-slashes";
import { splitToSegments } from "./split-to-segments";
/**
* This function if the route and resourceRoute match by segments.
* - First, trailing and leading slashes are removed
* - Then, the route and resourceRoute are split to segments and checked if they have the same number of segments
* - Then, each segment is checked if it is a parameter or if it matches the resourceRoute segment
* - If all segments match, the function returns true, otherwise false
*/
export const checkBySegments = (route: string, resourceRoute: string) => {
const stdRoute = removeLeadingTrailingSlashes(route);
const stdResourceRoute = removeLeadingTrailingSlashes(resourceRoute);
// we need to check if the route and resourceRoute have the same number of segments
// if not, we can't match them
if (!isSegmentCountsSame(stdRoute, stdResourceRoute)) {
return false;
}
const routeSegments = splitToSegments(stdRoute);
const resourceRouteSegments = splitToSegments(stdResourceRoute);
return resourceRouteSegments.every((segment, index) => {
return isParameter(segment) || segment === routeSegments[index];
});
};

View File

@@ -0,0 +1,40 @@
import type { MetaQuery } from "../../../contexts/data/types";
import type { ParseResponse } from "../../../contexts/router/types";
import { pickRouteParams } from "./pick-route-params";
import { prepareRouteParams } from "./prepare-route-params";
/**
* This function will compose a route with the given params and meta.
* - A route can have parameters like (eg: /users/:id)
* - First we pick the route params from the route (eg: [id])
* - Then we prepare the route params with the given params and meta (eg: { id: 1 })
* - Then we replace the route params with the prepared route params (eg: /users/1)
*/
export const composeRoute = (
designatedRoute: string,
resourceMeta: MetaQuery = {},
parsed: ParseResponse = {},
meta: Record<string, unknown> = {},
): string => {
// pickRouteParams (from the route)
const routeParams = pickRouteParams(designatedRoute);
// prepareRouteParams (from route params, params and meta)
const preparedRouteParams = prepareRouteParams(routeParams, {
...resourceMeta,
...(typeof parsed?.id !== "undefined" ? { id: parsed.id } : {}),
...(typeof parsed?.action !== "undefined" ? { action: parsed.action } : {}),
...(typeof parsed?.resource !== "undefined"
? { resource: parsed.resource }
: {}),
...parsed?.params,
...meta,
});
// replace route params with prepared route params
return designatedRoute.replace(/:([^\/]+)/g, (match, key) => {
const fromParams = preparedRouteParams[key];
if (typeof fromParams !== "undefined") {
return `${fromParams}`;
}
return match;
});
};

View File

@@ -0,0 +1,66 @@
import type { IResourceItem } from "../../../contexts/resource/types";
import type { Action } from "../../../contexts/router/types";
import { getDefaultActionPath } from "./get-default-action-path";
import { getParentPrefixForResource } from "./get-parent-prefix-for-resource";
export type ResourceActionRoute = {
action: Action;
resource: IResourceItem;
route: string;
};
/**
* This function returns all the routes for available actions for a resource.
* - If the action is a function, it means we're fallbacking to default path for the action
* - If the action is a string, it means we don't have the component, but we have the route
* - If the action is an object, it means we have the component and the route
* - It will return an array of objects with the action, the resource and the route
*/
export const getActionRoutesFromResource = (
resource: IResourceItem,
resources: IResourceItem[],
/**
* Uses legacy route if true (`options.route`)
*/
legacy?: boolean,
) => {
const actions: ResourceActionRoute[] = [];
const actionList: Action[] = ["list", "show", "edit", "create", "clone"];
const parentPrefix = getParentPrefixForResource(resource, resources, legacy);
actionList.forEach((action) => {
const item =
legacy && action === "clone" ? resource.create : resource[action];
let route: string | undefined = undefined;
if (typeof item === "function" || legacy) {
// means we're fallbacking to default path for the action
route = getDefaultActionPath(
legacy
? resource.meta?.route ?? resource.options?.route ?? resource.name
: resource.name,
action,
legacy ? parentPrefix : undefined,
);
} else if (typeof item === "string") {
// means we don't have the component, but we have the route
route = item;
} else if (typeof item === "object") {
// means we have the component and the route
route = item.path;
}
if (route) {
actions.push({
action,
resource,
route: `/${route.replace(/^\//, "")}`,
});
}
});
return actions;
};

View File

@@ -0,0 +1,33 @@
import type { Action } from "../../../contexts/router/types";
import { removeLeadingTrailingSlashes } from "./remove-leading-trailing-slashes";
/**
* This helper function returns the default path for a given action and resource.
* It also applies the parentPrefix if provided.
* This is used by the legacy router and the new router if the resource doesn't provide a custom path.
*/
export const getDefaultActionPath = (
resourceName: string,
action: Action,
parentPrefix?: string,
): string => {
const cleanParentPrefix = removeLeadingTrailingSlashes(parentPrefix || "");
let path = `${cleanParentPrefix}${
cleanParentPrefix ? "/" : ""
}${resourceName}`;
if (action === "list") {
path = `${path}`;
} else if (action === "create") {
path = `${path}/create`;
} else if (action === "edit") {
path = `${path}/edit/:id`;
} else if (action === "show") {
path = `${path}/show/:id`;
} else if (action === "clone") {
path = `${path}/clone/:id`;
}
return `/${path.replace(/^\//, "")}`;
};

View File

@@ -0,0 +1,37 @@
import type { ResourceProps } from "../../../contexts/resource/types";
import { getParentResource } from "./get-parent-resource";
import { removeLeadingTrailingSlashes } from "./remove-leading-trailing-slashes";
/**
* Returns the parent prefix for a resource
* - If `legacy` is provided, the computation is based on the `route` option of the resource
*/
export const getParentPrefixForResource = (
resource: ResourceProps,
resources: ResourceProps[],
/**
* Uses legacy route if true (`options.route`)
*/
legacy?: boolean,
): string | undefined => {
const parents: ResourceProps[] = [];
let parent = getParentResource(resource, resources);
while (parent) {
parents.push(parent);
parent = getParentResource(parent, resources);
}
if (parents.length === 0) {
return undefined;
}
return `/${parents
.reverse()
.map((parent) => {
const v = legacy ? parent.options?.route ?? parent.name : parent.name;
return removeLeadingTrailingSlashes(v);
})
.join("/")}`;
};

View File

@@ -0,0 +1,31 @@
import type { IResourceItem } from "../../../contexts/resource/types";
import { pickNotDeprecated } from "../pickNotDeprecated";
/**
* Returns the parent resource of the given resource.
* Works both with the deprecated `parentName` and the new `parent` property.
*/
export const getParentResource = (
resource: IResourceItem,
resources: IResourceItem[],
): IResourceItem | undefined => {
const parentName = pickNotDeprecated(
resource.meta?.parent,
resource.options?.parent,
resource.parentName,
);
if (!parentName) {
return undefined;
}
const parentResource = resources.find(
(resource) => (resource.identifier ?? resource.name) === parentName,
);
/**
* If the parent resource is not found, we return a resource object with the name of the parent resource.
* Because we still want to have nesting and prefixing for the resource even if the parent is not explicitly defined.
*/
return parentResource ?? { name: parentName };
};

View File

@@ -0,0 +1,10 @@
export { checkBySegments } from "./check-by-segments";
export { getActionRoutesFromResource } from "./get-action-routes-from-resource";
export { getDefaultActionPath } from "./get-default-action-path";
export { getParentPrefixForResource } from "./get-parent-prefix-for-resource";
export { getParentResource } from "./get-parent-resource";
export { isParameter } from "./is-parameter";
export { isSegmentCountsSame } from "./is-segment-counts-same";
export { removeLeadingTrailingSlashes } from "./remove-leading-trailing-slashes";
export { matchResourceFromRoute } from "./match-resource-from-route";

View File

@@ -0,0 +1,6 @@
/**
* Check if a segment is a parameter. (e.g. :id)
*/
export const isParameter = (segment: string) => {
return segment.startsWith(":");
};

View File

@@ -0,0 +1,11 @@
import { splitToSegments } from "./split-to-segments";
/**
* Checks if the both routes have the same number of segments.
*/
export const isSegmentCountsSame = (route: string, resourceRoute: string) => {
const routeSegments = splitToSegments(route);
const resourceRouteSegments = splitToSegments(resourceRoute);
return routeSegments.length === resourceRouteSegments.length;
};

View File

@@ -0,0 +1,38 @@
import type { IResourceItem } from "../../../contexts/resource/types";
import type { Action } from "../../../contexts/router/types";
import { checkBySegments } from "./check-by-segments";
import { getActionRoutesFromResource } from "./get-action-routes-from-resource";
import { pickMatchedRoute } from "./pick-matched-route";
/**
* Match the resource from the route
* - It will calculate all possible routes for resources and their actions
* - It will check if the route matches any of the possible routes
* - It will return the most eligible resource and action
*/
export const matchResourceFromRoute = (
route: string,
resources: IResourceItem[],
): {
found: boolean;
resource?: IResourceItem;
action?: Action;
matchedRoute?: string;
} => {
const allActionRoutes = resources.flatMap((resource) => {
return getActionRoutesFromResource(resource, resources);
});
const allFound = allActionRoutes.filter((actionRoute) => {
return checkBySegments(route, actionRoute.route);
});
const mostEligible = pickMatchedRoute(allFound);
return {
found: !!mostEligible,
resource: mostEligible?.resource,
action: mostEligible?.action,
matchedRoute: mostEligible?.route,
};
};

View File

@@ -0,0 +1,61 @@
import type { ResourceActionRoute } from "./get-action-routes-from-resource";
import { isParameter } from "./is-parameter";
import { removeLeadingTrailingSlashes } from "./remove-leading-trailing-slashes";
import { splitToSegments } from "./split-to-segments";
/**
* Picks the most eligible route from the given matched routes.
* - If there's only one route, it returns it.
* - If there's more than one route, it picks the best non-greedy match.
*/
export const pickMatchedRoute = (
routes: ResourceActionRoute[],
): ResourceActionRoute | undefined => {
// these routes are all matched, we should pick the least parametrized one
// no routes, no match
if (routes.length === 0) {
return undefined;
}
// no need to calculate the route segments if there's only one route
if (routes.length === 1) {
return routes[0];
}
// remove trailing and leading slashes
// split them to segments
const sanitizedRoutes = routes.map((route) => ({
...route,
splitted: splitToSegments(removeLeadingTrailingSlashes(route.route)),
}));
// at this point, before calling this function, we already checked for segment lenghts and expect all of them to be the same
const segmentsCount = sanitizedRoutes[0]?.splitted.length ?? 0;
let eligibleRoutes: Array<(typeof sanitizedRoutes)[number]> = [
...sanitizedRoutes,
];
// loop through the segments
for (let i = 0; i < segmentsCount; i++) {
const nonParametrizedRoutes = eligibleRoutes.filter(
(route) => !isParameter(route.splitted[i]),
);
if (nonParametrizedRoutes.length === 0) {
// keep the eligible routes as they are
continue;
}
if (nonParametrizedRoutes.length === 1) {
// no need to continue, we found the route
eligibleRoutes = nonParametrizedRoutes;
break;
}
// we have more than one non-parametrized route, we need to check the next segment
eligibleRoutes = nonParametrizedRoutes;
}
return eligibleRoutes[0];
};

View File

@@ -0,0 +1,19 @@
import { splitToSegments } from "./split-to-segments";
import { removeLeadingTrailingSlashes } from "./remove-leading-trailing-slashes";
import { isParameter } from "./is-parameter";
/**
* Picks the route parameters from the given route.
* (e.g. /users/:id/posts/:postId => ['id', 'postId'])
*/
export const pickRouteParams = (route: string) => {
const segments = splitToSegments(removeLeadingTrailingSlashes(route));
return segments.flatMap((s) => {
if (isParameter(s)) {
return [s.slice(1)];
}
return [];
});
};

View File

@@ -0,0 +1,23 @@
/**
* Prepares the route params by checking the existing params and meta data.
* Meta data is prioritized over params.
* Params are prioritized over predetermined id, action and resource.
* This means, we can use `meta` for user supplied params (both manually or from the query string)
*/
export const prepareRouteParams = <
TRouteParams extends Record<string, unknown> = Record<string, unknown>,
>(
routeParams: (keyof TRouteParams)[],
meta: Record<string, unknown> = {},
): Partial<TRouteParams> => {
return routeParams.reduce(
(acc, key) => {
const value = meta[key as string];
if (typeof value !== "undefined") {
acc[key] = value as TRouteParams[keyof TRouteParams];
}
return acc;
},
{} as Partial<TRouteParams>,
);
};

View File

@@ -0,0 +1,6 @@
/**
* Remove leading and trailing slashes from a route.
*/
export const removeLeadingTrailingSlashes = (route: string) => {
return route.replace(/^\/|\/$/g, "");
};

View File

@@ -0,0 +1,7 @@
/**
* Split a path to segments.
*/
export const splitToSegments = (path: string) => {
const segments = path.split("/").filter((segment) => segment !== "");
return segments;
};

View File

@@ -0,0 +1,31 @@
import { safeTranslate } from ".";
describe("safeTranslate", () => {
it("should return the translated string", () => {
const translate = jest.fn().mockReturnValue("translated");
const result = safeTranslate(translate, "key");
expect(result).toEqual("translated");
});
it("should return the default message if the key is not translated (undefined)", () => {
const translate = jest.fn().mockReturnValue(undefined);
const result = safeTranslate(translate, "key", "default");
expect(result).toEqual("default");
});
it("should return the default message if the translated string is the same as the key", () => {
const translate = jest.fn().mockReturnValue("key");
const result = safeTranslate(translate, "key", "default");
expect(result).toEqual("default");
});
it("should return the key if the key is not translated and there is no default message", () => {
const translate = jest.fn().mockReturnValue(undefined);
const result = safeTranslate(translate, "key");
expect(result).toEqual("key");
});
});

View File

@@ -0,0 +1,20 @@
import type { useTranslate } from "@hooks/i18n";
export const safeTranslate = (
translate: ReturnType<typeof useTranslate>,
key: string,
defaultMessage?: string,
options?: any,
) => {
const translated = options
? translate(key, options, defaultMessage)
: translate(key, defaultMessage);
const fallback = defaultMessage ?? key;
if (translated === key || typeof translated === "undefined") {
return fallback;
}
return translated;
};

View File

@@ -0,0 +1,49 @@
import { sanitizeResource } from ".";
describe("sanitizeResource", () => {
it("should remove icon property", () => {
expect(
sanitizeResource({
name: "posts",
icon: "icon",
}),
).toEqual({
name: "posts",
});
});
it("should remove meta.icon property", () => {
expect(
sanitizeResource({
name: "posts",
meta: {
icon: "meta-icon",
label: "meta-label",
},
}),
).toEqual({
name: "posts",
meta: {
label: "meta-label",
},
});
});
it("should remove options.icon property", () => {
expect(
sanitizeResource({
name: "posts",
options: {
icon: "options-icon",
label: "options-label",
},
}),
).toEqual({
name: "posts",
options: {
label: "options-label",
},
});
});
it("should return undefined if resource is not passed", () => {
expect(sanitizeResource()).toEqual(undefined);
});
});

View File

@@ -0,0 +1,37 @@
import type { IResourceItem } from "../../../contexts/resource/types";
/**
* Remove all properties that are non-serializable from a resource object.
*/
export const sanitizeResource = (
resource?: Partial<IResourceItem> &
Required<Pick<IResourceItem, "name">> & { children?: unknown },
):
| (Partial<IResourceItem> & Required<Pick<IResourceItem, "name">>)
| undefined => {
if (!resource) {
return undefined;
}
const {
icon,
list,
edit,
create,
show,
clone,
children,
meta,
options,
...restResource
} = resource;
const { icon: _metaIcon, ...restMeta } = meta ?? {};
const { icon: _optionsIcon, ...restOptions } = options ?? {};
return {
...restResource,
...(meta ? { meta: restMeta } : {}),
...(options ? { options: restOptions } : {}),
};
};

View File

@@ -0,0 +1,20 @@
import { sequentialPromises } from ".";
describe("sequentialPromises", () => {
it("should resolve all promises", async () => {
const result = await sequentialPromises(
[
() => Promise.resolve("1"),
() => Promise.resolve("2"),
() => Promise.reject("3"),
],
(response) => {
return response;
},
(error) => {
return error;
},
);
expect(result).toEqual(["1", "2", "3"]);
});
});

View File

@@ -0,0 +1,32 @@
type EachResolve<TResolve, Response> = (
result: TResolve,
index: number,
) => Response;
type EachReject<TReject, Response> = (
error: TReject,
index: number,
) => Response;
export const sequentialPromises = async <
TResolve = unknown,
TReject = unknown,
TResolveResponse = unknown,
TRejectResponse = unknown,
>(
promises: (() => Promise<TResolve>)[],
onEachResolve: EachResolve<TResolve, TResolveResponse>,
onEachReject: EachReject<TReject, TRejectResponse>,
): Promise<(TResolveResponse | TRejectResponse)[]> => {
const results = [];
// @ts-expect-error Remove this when we enable `downLevelIterations`
for (const [index, promise] of promises.entries()) {
try {
const result = await promise();
results.push(onEachResolve(result, index));
} catch (error) {
results.push(onEachReject(error as TReject, index));
}
}
return results;
};

View File

@@ -0,0 +1,77 @@
import { createTreeView } from ".";
import type {
IResourceItem,
ITreeMenu,
} from "../../../../contexts/resource/types";
const mockResources: IResourceItem[] = [
{
name: "cms",
},
{
name: "content",
parentName: "cms",
},
{
name: "posts",
parentName: "content",
},
{
name: "categories-route",
route: "/categories-route",
},
{
name: "categories",
route: "/categories",
},
{
name: "categories",
meta: {
label: "asd",
route: "bitti/son/sonson",
},
parentName: "categories-route",
},
{
name: "users",
route: "/users",
},
];
const expectedMockResources: ITreeMenu[] = [
{
name: "categories-route",
route: "/categories-route",
children: [
{
name: "categories",
meta: {
label: "asd",
route: "bitti/son/sonson",
},
parentName: "categories-route",
children: [],
},
],
},
{
name: "categories",
route: "/categories",
children: [],
},
{
name: "users",
route: "/users",
children: [],
},
];
describe("createTreeView", () => {
const tree: ITreeMenu[] = createTreeView(mockResources);
it("should return an tree which has three member", async () => {
expect(tree.length).toBe(3);
});
it("should be equal expectedMockLocation", async () => {
expect(tree).toEqual(expectedMockResources);
});
});

View File

@@ -0,0 +1,49 @@
import { pickNotDeprecated } from "@definitions/helpers/pickNotDeprecated";
import type {
IMenuItem,
IResourceItem,
ITreeMenu,
} from "../../../../contexts/resource/types";
/**
* @deprecated This helper is deprecated. Please use `createTree` instead.
*/
export const createTreeView = (
resources: IResourceItem[] | IMenuItem[],
): ITreeMenu[] | ITreeMenu[] => {
const tree = [];
const resourcesRouteObject: { [key: string]: any } = {};
const resourcesNameObject: { [key: string]: any } = {};
let parent: IResourceItem | IMenuItem;
let child: ITreeMenu;
for (let i = 0; i < resources.length; i++) {
parent = resources[i];
const route =
parent.route ??
pickNotDeprecated(parent?.meta, parent.options)?.route ??
"";
resourcesRouteObject[route] = parent;
resourcesRouteObject[route]["children"] = [];
resourcesNameObject[parent.name] = parent;
resourcesNameObject[parent.name]["children"] = [];
}
for (const name in resourcesRouteObject) {
if (Object.hasOwn(resourcesRouteObject, name)) {
child = resourcesRouteObject[name];
if (child.parentName && resourcesNameObject[child.parentName]) {
resourcesNameObject[child.parentName]["children"].push(child);
} else {
tree.push(child);
}
}
}
return tree;
};

View File

@@ -0,0 +1 @@
export { createTreeView } from "./createTreeView";

View File

@@ -0,0 +1,67 @@
import { TestWrapper } from "@test/index";
import { renderHook } from "@testing-library/react";
import { useActiveAuthProvider } from ".";
/**
* NOTE: Will be removed in v5
*/
describe("useActiveAuthProvider", () => {
it("returns new authProvider", async () => {
const { result } = renderHook(() => useActiveAuthProvider(), {
wrapper: TestWrapper({
authProvider: {
login: () => Promise.resolve({ success: true }),
check: () => Promise.resolve({ authenticated: true }),
onError: () => Promise.resolve({}),
logout: () => Promise.resolve({ success: true }),
},
}),
});
expect(result.current?.isLegacy).toBeFalsy();
});
it("returns v3LegacyAuthProviderCompatible authProvider", async () => {
const { result } = renderHook(() => useActiveAuthProvider(), {
wrapper: TestWrapper({
legacyAuthProvider: {
login: () => Promise.resolve(),
checkAuth: () => Promise.resolve(),
logout: () => Promise.resolve(),
checkError: () => Promise.resolve(),
},
}),
});
expect(result.current?.isLegacy).toBeTruthy();
});
it("returns new authProvider when both provided", async () => {
const { result } = renderHook(() => useActiveAuthProvider(), {
wrapper: TestWrapper({
legacyAuthProvider: {
login: () => Promise.resolve(),
checkAuth: () => Promise.resolve(),
logout: () => Promise.resolve(),
checkError: () => Promise.resolve(),
},
authProvider: {
login: () => Promise.resolve({ success: true }),
check: () => Promise.resolve({ authenticated: true }),
onError: () => Promise.resolve({}),
logout: () => Promise.resolve({ success: true }),
},
}),
});
expect(result.current?.isLegacy).toBeFalsy();
});
it("returns null", async () => {
const { result } = renderHook(() => useActiveAuthProvider(), {
wrapper: TestWrapper({}),
});
expect(result.current).toBe(null);
});
});

View File

@@ -0,0 +1,30 @@
import { useAuthBindingsContext, useLegacyAuthContext } from "@contexts/auth";
/**
* @returns authProvider or legacyAuthProvider if provided, otherwise null
* @internal
* NOTE: Will be removed in v5
*/
export const useActiveAuthProvider = () => {
const legacyAuthProvider = useLegacyAuthContext();
const authProvider = useAuthBindingsContext();
if (authProvider.isProvided) {
return { isLegacy: false, ...authProvider };
}
if (legacyAuthProvider.isProvided) {
// legacyAuthProvider interface is different from authProvider interface
// we need to convert it to authProvider interface for simple usage
// in the future, we will remove legacyAuthProvider
return {
isLegacy: true,
...legacyAuthProvider,
check: legacyAuthProvider.checkAuth,
onError: legacyAuthProvider.checkError,
getIdentity: legacyAuthProvider.getUserIdentity,
};
}
return null;
};

View File

@@ -0,0 +1,65 @@
import { getNextPageParam, getPreviousPageParam } from "./index";
describe("useInfiniteList pagination helper", () => {
describe("getNextPageParam", () => {
it("default page size", () => {
const hasNextPage = getNextPageParam({
data: [],
total: 10,
});
expect(hasNextPage).toBe(undefined);
});
it("custom pageSize and current page", () => {
const hasNextPage = getNextPageParam({
data: [],
total: 10,
pagination: {
current: 2,
pageSize: 3,
},
});
expect(hasNextPage).toBe(3);
});
it("cursor", () => {
const hasNextPage = getNextPageParam({
data: [],
total: 10,
cursor: {
next: 2,
},
});
expect(hasNextPage).toBe(2);
});
});
describe("getPreviousPageParam", () => {
it("custom pageSize and current page", () => {
const hasPreviousPage = getPreviousPageParam({
data: [],
total: 10,
pagination: {
current: 2,
pageSize: 3,
},
});
expect(hasPreviousPage).toBe(1);
});
it("hasPreviousPage false", () => {
const hasPreviousPage = getPreviousPageParam({
data: [],
total: 10,
});
expect(hasPreviousPage).toBe(undefined);
});
it("cursor", () => {
const hasPreviousPage = getPreviousPageParam({
data: [],
total: 10,
cursor: {
next: 2,
prev: 1,
},
});
expect(hasPreviousPage).toBe(1);
});
});
});

View File

@@ -0,0 +1,30 @@
import type { GetListResponse } from "../../../contexts/data/types";
export const getNextPageParam = (lastPage: GetListResponse) => {
const { pagination, cursor } = lastPage;
// cursor pagination
if (cursor?.next) {
return cursor.next;
}
const current = pagination?.current || 1;
const pageSize = pagination?.pageSize || 10;
const totalPages = Math.ceil((lastPage.total || 0) / pageSize);
return current < totalPages ? Number(current) + 1 : undefined;
};
export const getPreviousPageParam = (lastPage: GetListResponse) => {
const { pagination, cursor } = lastPage;
// cursor pagination
if (cursor?.prev) {
return cursor.prev;
}
const current = pagination?.current || 1;
return current === 1 ? undefined : current - 1;
};

View File

@@ -0,0 +1,29 @@
import { renderHook } from "@testing-library/react";
import { TestWrapper } from "@test/index";
import { useMediaQuery } from ".";
describe("useMediaQuery Helper Hook", () => {
it("should return false if the media query does not match", () => {
const { result } = renderHook(() => useMediaQuery("(min-width: 600px)"), {
wrapper: TestWrapper({}),
});
expect(result.current).toBe(false);
});
it("should return", () => {
window.matchMedia = jest.fn().mockImplementation((query) => ({
matches: query !== "(max-width: 1024px)",
media: "",
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
}));
const { result } = renderHook(() => useMediaQuery("(max-width: 600px)"), {
wrapper: TestWrapper({}),
});
expect(result.current).toBe(true);
});
});

View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from "react";
export const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
}, [matches, query]);
return matches;
};

View File

@@ -0,0 +1,51 @@
import { renderHook } from "@testing-library/react";
import { useUserFriendlyName } from ".";
import { defaultRefineOptions } from "@contexts/refine";
import { TestWrapper } from "@test/index";
describe("useUserFriendlyName helper hook", () => {
describe("with default options", () => {
it("should convert kebab-case to humanizeString with plural", async () => {
const singularKebapCase = "red-tomato";
const { result } = renderHook(() => useUserFriendlyName());
expect(result.current(singularKebapCase, "plural")).toBe("Red tomatoes");
});
it("should convert kebab-case to humanizeString with singular", async () => {
const singularKebapCase = "red-tomato";
const { result } = renderHook(() => useUserFriendlyName());
expect(result.current(singularKebapCase, "singular")).toBe("Red tomato");
});
});
describe("with custom options", () => {
it.each(["singular", "plural"] as const)(
"should not convert any texts",
async (type) => {
const singularKebapCase = "red-tomato";
const { result } = renderHook(() => useUserFriendlyName(), {
wrapper: TestWrapper({
refineProvider: {
options: {
...defaultRefineOptions,
textTransformers: {
humanize: (text: string) => text,
plural: (text: string) => text,
singular: (text: string) => text,
},
},
} as any,
}),
});
expect(result.current(singularKebapCase, type)).toBe("red-tomato");
},
);
});
});

View File

@@ -0,0 +1,21 @@
import { useRefineContext } from "@hooks/refine";
/**
* A method that the internal uses
* @internal
*/
export const useUserFriendlyName = () => {
const {
options: { textTransformers },
} = useRefineContext();
const getFriendlyName = (name = "", type: "singular" | "plural"): string => {
const humanizeName = textTransformers.humanize(name);
if (type === "singular") {
return textTransformers.singular(humanizeName);
}
return textTransformers.plural(humanizeName);
};
return getFriendlyName;
};

View File

@@ -0,0 +1,19 @@
import { userFriendlyResourceName } from "@definitions";
describe("userFriendlyResourceName Helper", () => {
it("should convert kebab-case to humanizeString with plural", async () => {
const singularKebapCase = "red-tomato";
const result = userFriendlyResourceName(singularKebapCase, "plural");
expect(result).toBe("Red tomatoes");
});
it("should convert kebab-case to humanizeString with singular", async () => {
const singularKebapCase = "red-tomato";
const result = userFriendlyResourceName(singularKebapCase, "singular");
expect(result).toBe("Red tomato");
});
});

View File

@@ -0,0 +1,18 @@
import pluralize from "pluralize";
import { humanizeString } from "@definitions";
/**
* A method that the internal uses
* @internal
* @deprecated use `useUserFriendlyName` instead.
*/
export const userFriendlyResourceName = (
resource = "",
type: "singular" | "plural",
): string => {
const humanizeResource = humanizeString(resource);
if (type === "singular") {
return pluralize.singular(humanizeResource);
}
return pluralize.plural(humanizeResource);
};

View File

@@ -0,0 +1,11 @@
import { userFriendlySecond } from "@definitions";
describe("userFriendlySecond Helper", () => {
it("converts milliseconds to seconds correctly", async () => {
const miliseconds = 5000;
const seconds = userFriendlySecond(miliseconds);
expect(seconds).toBe(5);
});
});

View File

@@ -0,0 +1,3 @@
export const userFriendlySecond = (miliseconds: number): number => {
return miliseconds / 1000; //convert to seconds
};

View File

@@ -0,0 +1,3 @@
export * from "./table";
export * from "./helpers";
export * from "./upload";

Some files were not shown because too many files have changed in this diff Show More