mirror of
https://github.com/stefanpejcic/openpanel
synced 2025-06-26 18:28:26 +00:00
packages
This commit is contained in:
3
packages/antd/src/hooks/fields/index.ts
Normal file
3
packages/antd/src/hooks/fields/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./useSelect";
|
||||
export * from "./useCheckboxGroup";
|
||||
export * from "./useRadioGroup";
|
||||
134
packages/antd/src/hooks/fields/useCheckboxGroup/index.spec.ts
Normal file
134
packages/antd/src/hooks/fields/useCheckboxGroup/index.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { TestWrapper } from "@test";
|
||||
|
||||
import { useCheckboxGroup } from "./";
|
||||
|
||||
describe("render hook default options", () => {
|
||||
it("should success data with resource", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCheckboxGroup({
|
||||
resource: "posts",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.checkboxGroupProps.options).toHaveLength(2),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.checkboxGroupProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet.",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est.", value: "2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should success data with resource with optionLabel and optionValue", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCheckboxGroup<{ id: string; slug: string }>({
|
||||
resource: "posts",
|
||||
optionLabel: "slug",
|
||||
optionValue: "id",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.checkboxGroupProps.options).toHaveLength(2),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.checkboxGroupProps.options).toEqual([
|
||||
{ label: "ut-ad-et", value: "1" },
|
||||
{ label: "consequatur-molestiae-rerum", value: "2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate options with custom optionLabel and optionValue functions", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCheckboxGroup({
|
||||
resource: "posts",
|
||||
optionLabel: (item) => `${item.title} - ${item.userId}`,
|
||||
optionValue: (item) => `${item.id}`,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
const { checkboxGroupProps } = result.current;
|
||||
|
||||
expect(checkboxGroupProps.options).toHaveLength(2);
|
||||
expect(checkboxGroupProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet. - 5",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est. - 36", value: "2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should invoke queryOptions methods successfully", async () => {
|
||||
const mockFunc = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCheckboxGroup<{ id: string; slug: string }>({
|
||||
resource: "posts",
|
||||
optionLabel: "slug",
|
||||
optionValue: "id",
|
||||
queryOptions: {
|
||||
onSuccess: (data) => {
|
||||
mockFunc();
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.checkboxGroupProps.options).toHaveLength(2),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.checkboxGroupProps.options).toEqual([
|
||||
{ label: "ut-ad-et", value: "1" },
|
||||
{ label: "consequatur-molestiae-rerum", value: "2" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(mockFunc).toBeCalled();
|
||||
});
|
||||
});
|
||||
108
packages/antd/src/hooks/fields/useCheckboxGroup/index.ts
Normal file
108
packages/antd/src/hooks/fields/useCheckboxGroup/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { QueryObserverResult } from "@tanstack/react-query";
|
||||
import type { Checkbox } from "antd";
|
||||
|
||||
import {
|
||||
type BaseRecord,
|
||||
type GetListResponse,
|
||||
type HttpError,
|
||||
type UseSelectProps,
|
||||
useSelect,
|
||||
type BaseKey,
|
||||
pickNotDeprecated,
|
||||
type BaseOption,
|
||||
} from "@refinedev/core";
|
||||
|
||||
export type UseCheckboxGroupReturnType<
|
||||
TData extends BaseRecord = BaseRecord,
|
||||
TOption extends BaseOption = BaseOption,
|
||||
> = {
|
||||
checkboxGroupProps: Omit<
|
||||
React.ComponentProps<typeof Checkbox.Group>,
|
||||
"options"
|
||||
> & {
|
||||
options: TOption[];
|
||||
};
|
||||
query: QueryObserverResult<GetListResponse<TData>>;
|
||||
/**
|
||||
* @deprecated Use `query` instead
|
||||
*/
|
||||
queryResult: QueryObserverResult<GetListResponse<TData>>;
|
||||
};
|
||||
|
||||
type UseCheckboxGroupProps<TQueryFnData, TError, TData> = Omit<
|
||||
UseSelectProps<TQueryFnData, TError, TData>,
|
||||
"defaultValue"
|
||||
> & {
|
||||
/**
|
||||
* Sets the default value
|
||||
*/
|
||||
defaultValue?: BaseKey[];
|
||||
};
|
||||
|
||||
/**
|
||||
* `useCheckboxGroup` hook allows you to manage an Ant Design {@link https://ant.design/components/checkbox/#components-checkbox-demo-group Checkbox.Group} component when records in a resource needs to be used as checkbox options.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/field/useCheckboxGroup/} for more details
|
||||
*
|
||||
* @typeParam TQueryFnData - Result data returned by the query function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#httperror `HttpError`}
|
||||
* @typeParam TData - Result data returned by the `select` function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}. Defaults to `TQueryFnData`
|
||||
*
|
||||
*/
|
||||
|
||||
export const useCheckboxGroup = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TOption extends BaseOption = BaseOption,
|
||||
>({
|
||||
resource,
|
||||
sort,
|
||||
sorters,
|
||||
filters,
|
||||
optionLabel,
|
||||
optionValue,
|
||||
queryOptions,
|
||||
fetchSize,
|
||||
pagination,
|
||||
liveMode,
|
||||
defaultValue,
|
||||
selectedOptionsOrder,
|
||||
onLiveEvent,
|
||||
liveParams,
|
||||
meta,
|
||||
metaData,
|
||||
dataProviderName,
|
||||
}: UseCheckboxGroupProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData
|
||||
>): UseCheckboxGroupReturnType<TData, TOption> => {
|
||||
const { query, options } = useSelect<TQueryFnData, TError, TData, TOption>({
|
||||
resource,
|
||||
sort,
|
||||
sorters,
|
||||
filters,
|
||||
optionLabel,
|
||||
optionValue,
|
||||
queryOptions,
|
||||
fetchSize,
|
||||
pagination,
|
||||
liveMode,
|
||||
defaultValue,
|
||||
selectedOptionsOrder,
|
||||
onLiveEvent,
|
||||
liveParams,
|
||||
meta: pickNotDeprecated(meta, metaData),
|
||||
metaData: pickNotDeprecated(meta, metaData),
|
||||
dataProviderName,
|
||||
});
|
||||
return {
|
||||
checkboxGroupProps: {
|
||||
options,
|
||||
defaultValue,
|
||||
},
|
||||
query,
|
||||
queryResult: query,
|
||||
};
|
||||
};
|
||||
151
packages/antd/src/hooks/fields/useRadioGroup/index.spec.ts
Normal file
151
packages/antd/src/hooks/fields/useRadioGroup/index.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { TestWrapper } from "@test";
|
||||
|
||||
import { useRadioGroup } from "./";
|
||||
|
||||
describe("render hook default options", () => {
|
||||
it("should success data without default values", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useRadioGroup({
|
||||
resource: "posts",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.radioGroupProps.options).toHaveLength(2),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.radioGroupProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet.",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est.", value: "2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should success data with resource with optionLabel and optionValue", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useRadioGroup<{ id: string; slug: string }>({
|
||||
resource: "posts",
|
||||
optionLabel: "slug",
|
||||
optionValue: "id",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.radioGroupProps.options).toHaveLength(2),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.radioGroupProps.options).toEqual([
|
||||
{ label: "ut-ad-et", value: "1" },
|
||||
{ label: "consequatur-molestiae-rerum", value: "2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate options with custom optionLabel and optionValue functions", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useRadioGroup({
|
||||
resource: "posts",
|
||||
optionLabel: (item) => `${item.title} - ${item.userId}`,
|
||||
optionValue: (item) => `${item.id}`,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
const { radioGroupProps } = result.current;
|
||||
|
||||
expect(radioGroupProps.options).toHaveLength(2);
|
||||
expect(radioGroupProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet. - 5",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est. - 36", value: "2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should invoke queryOptions methods successfully", async () => {
|
||||
const mockFunc = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useRadioGroup<{ id: string; slug: string }>({
|
||||
resource: "posts",
|
||||
optionLabel: "slug",
|
||||
optionValue: "id",
|
||||
queryOptions: {
|
||||
onSuccess: (data) => {
|
||||
mockFunc();
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.radioGroupProps.options).toHaveLength(2),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.radioGroupProps.options).toEqual([
|
||||
{ label: "ut-ad-et", value: "1" },
|
||||
{ label: "consequatur-molestiae-rerum", value: "2" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(mockFunc).toBeCalled();
|
||||
});
|
||||
|
||||
it("should work with queryResult and query", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useRadioGroup({
|
||||
resource: "posts",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(result.current.query).toEqual(result.current.queryResult);
|
||||
});
|
||||
});
|
||||
105
packages/antd/src/hooks/fields/useRadioGroup/index.ts
Normal file
105
packages/antd/src/hooks/fields/useRadioGroup/index.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { QueryObserverResult } from "@tanstack/react-query";
|
||||
import type { Radio } from "antd";
|
||||
|
||||
import {
|
||||
type BaseKey,
|
||||
type BaseOption,
|
||||
type BaseRecord,
|
||||
type GetListResponse,
|
||||
type HttpError,
|
||||
pickNotDeprecated,
|
||||
useSelect,
|
||||
type UseSelectProps,
|
||||
} from "@refinedev/core";
|
||||
|
||||
export type UseRadioGroupReturnType<
|
||||
TData extends BaseRecord = BaseRecord,
|
||||
TOption extends BaseOption = BaseOption,
|
||||
> = {
|
||||
radioGroupProps: Omit<React.ComponentProps<typeof Radio.Group>, "options"> & {
|
||||
options: TOption[];
|
||||
};
|
||||
query: QueryObserverResult<GetListResponse<TData>>;
|
||||
/**
|
||||
* @deprecated Use `query` instead
|
||||
*/
|
||||
queryResult: QueryObserverResult<GetListResponse<TData>>;
|
||||
};
|
||||
|
||||
type UseRadioGroupProps<TQueryFnData, TError, TData> = Omit<
|
||||
UseSelectProps<TQueryFnData, TError, TData>,
|
||||
"defaultValue"
|
||||
> & {
|
||||
/**
|
||||
* Sets the default value
|
||||
*/
|
||||
defaultValue?: BaseKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* `useRadioGroup` hook allows you to manage an Ant Design {@link https://ant.design/components/radio/#components-radio-demo-radiogroup-with-name Radio.Group} component when records in a resource needs to be used as radio options.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/field/useRadioGroup/} for more details.
|
||||
*
|
||||
* @typeParam TQueryFnData - Result data returned by the query function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#httperror `HttpError`}
|
||||
* @typeParam TData - Result data returned by the `select` function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}. Defaults to `TQueryFnData`
|
||||
*
|
||||
*/
|
||||
|
||||
export const useRadioGroup = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TOption extends BaseOption = BaseOption,
|
||||
>({
|
||||
resource,
|
||||
sort,
|
||||
sorters,
|
||||
filters,
|
||||
optionLabel,
|
||||
optionValue,
|
||||
queryOptions,
|
||||
fetchSize,
|
||||
pagination,
|
||||
liveMode,
|
||||
defaultValue,
|
||||
selectedOptionsOrder,
|
||||
onLiveEvent,
|
||||
liveParams,
|
||||
meta,
|
||||
metaData,
|
||||
dataProviderName,
|
||||
}: UseRadioGroupProps<TQueryFnData, TError, TData>): UseRadioGroupReturnType<
|
||||
TData,
|
||||
TOption
|
||||
> => {
|
||||
const { query, options } = useSelect<TQueryFnData, TError, TData, TOption>({
|
||||
resource,
|
||||
sort,
|
||||
sorters,
|
||||
filters,
|
||||
optionLabel,
|
||||
optionValue,
|
||||
queryOptions,
|
||||
fetchSize,
|
||||
pagination,
|
||||
liveMode,
|
||||
defaultValue,
|
||||
selectedOptionsOrder,
|
||||
onLiveEvent,
|
||||
liveParams,
|
||||
meta: pickNotDeprecated(meta, metaData),
|
||||
metaData: pickNotDeprecated(meta, metaData),
|
||||
dataProviderName,
|
||||
});
|
||||
|
||||
return {
|
||||
radioGroupProps: {
|
||||
options,
|
||||
defaultValue,
|
||||
},
|
||||
query,
|
||||
queryResult: query,
|
||||
};
|
||||
};
|
||||
372
packages/antd/src/hooks/fields/useSelect/index.spec.ts
Normal file
372
packages/antd/src/hooks/fields/useSelect/index.spec.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { MockJSONServer, TestWrapper } from "@test";
|
||||
|
||||
import { useSelect } from "./";
|
||||
|
||||
describe("useSelect Hook", () => {
|
||||
it("default", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect({
|
||||
resource: "posts",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.queryResult.isFetching).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toHaveLength(2),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet.",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est.", value: "2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaultValue", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect({
|
||||
resource: "posts",
|
||||
defaultValue: ["1", "2", "3", "4"],
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.queryResult.isFetching).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toHaveLength(2),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet.",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est.", value: "2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaultValue is not an array", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect({
|
||||
resource: "posts",
|
||||
defaultValue: "1",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.queryResult.isFetching).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toHaveLength(2),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet.",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est.", value: "2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should success data with resource with optionLabel and optionValue", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect<{ id: string; slug: string }>({
|
||||
resource: "posts",
|
||||
optionLabel: "slug",
|
||||
optionValue: "id",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.queryResult.isFetching).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toHaveLength(2),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toEqual([
|
||||
{ label: "ut-ad-et", value: "1" },
|
||||
{ label: "consequatur-molestiae-rerum", value: "2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate options with custom optionLabel and optionValue functions", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect({
|
||||
resource: "posts",
|
||||
optionLabel: (item) => `${item.title} - ${item.userId}`,
|
||||
optionValue: (item) => `${item.id}`,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
const { selectProps } = result.current;
|
||||
|
||||
expect(selectProps.options).toHaveLength(2);
|
||||
expect(selectProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet. - 5",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est. - 36", value: "2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should success data with resource with filters", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect<{ id: string; slug: string }>({
|
||||
resource: "posts",
|
||||
filters: [
|
||||
{
|
||||
field: "slug",
|
||||
operator: "ncontains",
|
||||
value: "test",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toHaveLength(2),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet.",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est.", value: "2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("onSearch debounce with default value (300ms)", async () => {
|
||||
const getListMock = jest.fn(() => Promise.resolve({ data: [], total: 0 }));
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect({
|
||||
resource: "posts",
|
||||
debounce: 300,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
getList: getListMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getListMock).toBeCalledTimes(1));
|
||||
|
||||
const { selectProps } = result.current;
|
||||
|
||||
selectProps?.onSearch?.("1");
|
||||
selectProps?.onSearch?.("12");
|
||||
selectProps?.onSearch?.("123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.queryResult.isFetching).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getListMock).toBeCalledTimes(2));
|
||||
});
|
||||
|
||||
it("onSearch disabled debounce (0ms)", async () => {
|
||||
const getListMock = jest.fn(() => {
|
||||
return Promise.resolve({ data: [], total: 0 });
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect({
|
||||
resource: "posts",
|
||||
debounce: 0,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
getList: getListMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.queryResult.isFetching).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getListMock).toBeCalledTimes(1));
|
||||
|
||||
const { selectProps } = result.current;
|
||||
|
||||
selectProps?.onSearch?.("1");
|
||||
await waitFor(() => expect(getListMock).toBeCalledTimes(2));
|
||||
|
||||
selectProps?.onSearch?.("2");
|
||||
await waitFor(() => expect(getListMock).toBeCalledTimes(3));
|
||||
|
||||
selectProps?.onSearch?.("3");
|
||||
await waitFor(() => expect(getListMock).toBeCalledTimes(4));
|
||||
});
|
||||
|
||||
it("should invoke queryOptions methods successfully", async () => {
|
||||
const mockFunc = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect({
|
||||
resource: "posts",
|
||||
queryOptions: {
|
||||
onSuccess: (data) => {
|
||||
mockFunc();
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toHaveLength(2),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet.",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est.", value: "2" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(mockFunc).toBeCalled();
|
||||
});
|
||||
|
||||
it("should invoke queryOptions methods for default value successfully", async () => {
|
||||
const mockFunc = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect({
|
||||
resource: "posts",
|
||||
defaultValue: ["1", "2", "3", "4"],
|
||||
defaultValueQueryOptions: {
|
||||
onSuccess: (data) => {
|
||||
mockFunc();
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toHaveLength(2),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.selectProps.options).toEqual([
|
||||
{
|
||||
label:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet.",
|
||||
value: "1",
|
||||
},
|
||||
{ label: "Recusandae consectetur aut atque est.", value: "2" },
|
||||
]),
|
||||
);
|
||||
expect(mockFunc).toBeCalled();
|
||||
});
|
||||
|
||||
it("should work with queryResult and query", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSelect({
|
||||
resource: "posts",
|
||||
defaultValue: ["1", "2", "3", "4"],
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.queryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(result.current.query).toEqual(result.current.queryResult);
|
||||
});
|
||||
});
|
||||
70
packages/antd/src/hooks/fields/useSelect/index.ts
Normal file
70
packages/antd/src/hooks/fields/useSelect/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { SelectProps } from "antd/lib/select";
|
||||
import type { QueryObserverResult } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
useSelect as useSelectCore,
|
||||
type BaseRecord,
|
||||
type GetManyResponse,
|
||||
type GetListResponse,
|
||||
type HttpError,
|
||||
type UseSelectProps,
|
||||
type BaseOption,
|
||||
} from "@refinedev/core";
|
||||
|
||||
export type UseSelectReturnType<
|
||||
TData extends BaseRecord = BaseRecord,
|
||||
TOption extends BaseOption = BaseOption,
|
||||
> = {
|
||||
selectProps: SelectProps<TOption>;
|
||||
query: QueryObserverResult<GetListResponse<TData>>;
|
||||
defaultValueQuery: QueryObserverResult<GetManyResponse<TData>>;
|
||||
/**
|
||||
* @deprecated Use `query` instead
|
||||
*/
|
||||
queryResult: QueryObserverResult<GetListResponse<TData>>;
|
||||
/**
|
||||
* @deprecated Use `defaultValueQuery` instead
|
||||
*/
|
||||
defaultValueQueryResult: QueryObserverResult<GetManyResponse<TData>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* `useSelect` hook allows you to manage an Ant Design {@link https://ant.design/components/select/ Select} component when records in a resource needs to be used as select options.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/field/useSelect/} for more details.
|
||||
*
|
||||
* @typeParam TQueryFnData - Result data returned by the query function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#httperror `HttpError`}
|
||||
* @typeParam TData - Result data returned by the `select` function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}. Defaults to `TQueryFnData`
|
||||
*
|
||||
*/
|
||||
|
||||
export const useSelect = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TOption extends BaseOption = BaseOption,
|
||||
>(
|
||||
props: UseSelectProps<TQueryFnData, TError, TData>,
|
||||
): UseSelectReturnType<TData, TOption> => {
|
||||
const { query, defaultValueQuery, onSearch, options } = useSelectCore<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TOption
|
||||
>(props);
|
||||
|
||||
return {
|
||||
selectProps: {
|
||||
options,
|
||||
onSearch,
|
||||
loading: defaultValueQuery.isFetching,
|
||||
showSearch: true,
|
||||
filterOption: false,
|
||||
},
|
||||
query,
|
||||
defaultValueQuery,
|
||||
queryResult: query,
|
||||
defaultValueQueryResult: defaultValueQuery,
|
||||
};
|
||||
};
|
||||
17
packages/antd/src/hooks/form/index.ts
Normal file
17
packages/antd/src/hooks/form/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { useForm, UseFormProps, UseFormReturnType } from "./useForm";
|
||||
export {
|
||||
useModalForm,
|
||||
UseModalFormProps,
|
||||
UseModalFormReturnType,
|
||||
} from "./useModalForm";
|
||||
export {
|
||||
useDrawerForm,
|
||||
UseDrawerFormProps,
|
||||
UseDrawerFormConfig,
|
||||
UseDrawerFormReturnType,
|
||||
} from "./useDrawerForm";
|
||||
export {
|
||||
useStepsForm,
|
||||
UseStepsFormProps,
|
||||
UseStepsFormReturnType,
|
||||
} from "./useStepsForm";
|
||||
299
packages/antd/src/hooks/form/useDrawerForm/index.spec.tsx
Normal file
299
packages/antd/src/hooks/form/useDrawerForm/index.spec.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { act, MockJSONServer, TestWrapper } from "@test";
|
||||
|
||||
import { useDrawerForm } from "./";
|
||||
|
||||
describe("useDrawerForm Hook", () => {
|
||||
it("should return correct initial value of 'open'", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
action: "create",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.drawerProps.open).toBe(false);
|
||||
});
|
||||
|
||||
it("'open' initial value should be set with 'defaultVisible'", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
action: "create",
|
||||
defaultVisible: true,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.drawerProps.open).toBe(true);
|
||||
});
|
||||
|
||||
it("'open' value should be false when 'onCancel' is called", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
action: "edit",
|
||||
defaultVisible: true,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.drawerProps.onClose?.({} as any);
|
||||
});
|
||||
|
||||
expect(result.current.drawerProps.open).toBe(false);
|
||||
});
|
||||
|
||||
it("'open' value should be true when 'show' is called", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
id: 1,
|
||||
action: "edit",
|
||||
defaultVisible: false,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
});
|
||||
|
||||
await act(async () => expect(result.current.drawerProps.open).toBe(true));
|
||||
});
|
||||
|
||||
it("'open' value should be false when 'show' is called without id", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
action: "edit",
|
||||
defaultVisible: false,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
});
|
||||
|
||||
await act(async () => expect(result.current.drawerProps.open).toBe(false));
|
||||
});
|
||||
|
||||
it("'id' should be updated when 'show' is called with 'id'", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
action: "edit",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
const id = "5";
|
||||
|
||||
await act(async () => {
|
||||
result.current.show(id);
|
||||
});
|
||||
|
||||
expect(result.current.id).toBe(id);
|
||||
});
|
||||
|
||||
it("when 'autoSubmitClose' is true, the modal should be closed when 'submit' is called", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
action: "edit",
|
||||
id: "1",
|
||||
resource: "posts",
|
||||
autoSubmitClose: true,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
result.current.formProps.onFinish?.({});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.drawerProps.open).toBe(false));
|
||||
});
|
||||
|
||||
it("when 'autoSubmitClose' is false, 'close' should not be called when 'submit' is called", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
id: 1,
|
||||
action: "edit",
|
||||
resource: "posts",
|
||||
autoSubmitClose: false,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.saveButtonProps.onClick?.({} as any);
|
||||
});
|
||||
|
||||
await waitFor(() => result.current.mutation.isSuccess);
|
||||
await waitFor(() => expect(result.current.drawerProps.open).toBe(true));
|
||||
});
|
||||
|
||||
it("autoResetForm is true, 'reset' should be called when the form is submitted", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
resource: "posts",
|
||||
action: "edit",
|
||||
autoResetForm: true,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
result.current.formProps.form?.setFieldsValue({
|
||||
test: "test",
|
||||
foo: "bar",
|
||||
});
|
||||
result.current.saveButtonProps.onClick?.({} as any);
|
||||
});
|
||||
|
||||
await waitFor(() => result.current.mutation.isSuccess);
|
||||
|
||||
expect(result.current.formProps.form?.getFieldsValue()).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("when mutationMode is 'pessimistic', the form should be closed when the mutation is successful", async () => {
|
||||
const updateMock = jest.fn(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
action: "edit",
|
||||
resource: "posts",
|
||||
id: 1,
|
||||
mutationMode: "pessimistic",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
update: updateMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
result.current.formProps.onFinish?.({});
|
||||
});
|
||||
|
||||
expect(result.current.drawerProps.open).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.drawerProps.open).toBe(false));
|
||||
|
||||
expect(updateMock).toBeCalledTimes(1);
|
||||
expect(result.current.drawerProps.open).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["optimistic", "undoable"] as const)(
|
||||
"when mutationMode is '%s', the form should be closed when the mutation is successful",
|
||||
async (mutationMode) => {
|
||||
const updateMock = jest.fn(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
action: "edit",
|
||||
resource: "posts",
|
||||
id: 1,
|
||||
mutationMode,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
update: updateMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
result.current.formProps.onFinish?.({});
|
||||
});
|
||||
|
||||
expect(result.current.drawerProps.open).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("should `meta[syncWithLocationKey]` overrided by default", async () => {
|
||||
const mockGetOne = jest.fn();
|
||||
const mockUpdate = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDrawerForm({
|
||||
syncWithLocation: true,
|
||||
id: 5,
|
||||
action: "edit",
|
||||
resource: "posts",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
getOne: mockGetOne,
|
||||
update: mockUpdate,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.drawerProps.open).toBe(true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetOne).toBeCalledTimes(1);
|
||||
expect(mockGetOne).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({
|
||||
"drawer-posts-edit": undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
6
packages/antd/src/hooks/form/useDrawerForm/index.ts
Normal file
6
packages/antd/src/hooks/form/useDrawerForm/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
useDrawerForm,
|
||||
UseDrawerFormProps,
|
||||
UseDrawerFormConfig,
|
||||
UseDrawerFormReturnType,
|
||||
} from "./useDrawerForm";
|
||||
324
packages/antd/src/hooks/form/useDrawerForm/useDrawerForm.ts
Normal file
324
packages/antd/src/hooks/form/useDrawerForm/useDrawerForm.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { useCallback } from "react";
|
||||
import type { FormInstance, FormProps, DrawerProps, ButtonProps } from "antd";
|
||||
import {
|
||||
useTranslate,
|
||||
useWarnAboutChange,
|
||||
type UseFormProps as UseFormPropsCore,
|
||||
type HttpError,
|
||||
type LiveModeProps,
|
||||
type BaseRecord,
|
||||
type FormWithSyncWithLocationParams,
|
||||
type BaseKey,
|
||||
useResource,
|
||||
useParsed,
|
||||
useGo,
|
||||
useModal,
|
||||
useInvalidate,
|
||||
} from "@refinedev/core";
|
||||
|
||||
import { useForm, type UseFormProps, type UseFormReturnType } from "../useForm";
|
||||
import type { DeleteButtonProps } from "../../../components";
|
||||
|
||||
export interface UseDrawerFormConfig {
|
||||
action: "show" | "edit" | "create" | "clone";
|
||||
}
|
||||
|
||||
export type UseDrawerFormProps<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
> = UseFormPropsCore<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> &
|
||||
UseFormProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> &
|
||||
UseDrawerFormConfig &
|
||||
LiveModeProps &
|
||||
FormWithSyncWithLocationParams & {
|
||||
defaultVisible?: boolean;
|
||||
autoSubmitClose?: boolean;
|
||||
autoResetForm?: boolean;
|
||||
};
|
||||
|
||||
export type UseDrawerFormReturnType<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
> = UseFormReturnType<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> & {
|
||||
formProps: FormProps<TVariables> & {
|
||||
form: FormInstance<TVariables>;
|
||||
};
|
||||
show: (id?: BaseKey) => void;
|
||||
close: () => void;
|
||||
drawerProps: DrawerProps;
|
||||
saveButtonProps: ButtonProps;
|
||||
deleteButtonProps: DeleteButtonProps;
|
||||
formLoading: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* `useDrawerForm` hook allows you to manage a form within a drawer. It returns Ant Design {@link https://ant.design/components/form/ Form} and {@link https://ant.design/components/drawer/ Drawer} components props.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/form/useDrawerForm} for more details.
|
||||
*
|
||||
* @typeParam TData - Result data of the query extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences/#httperror `HttpError`}
|
||||
* @typeParam TVariables - Values for params. default `{}`
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
export const useDrawerForm = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
>({
|
||||
syncWithLocation,
|
||||
defaultVisible = false,
|
||||
autoSubmitClose = true,
|
||||
autoResetForm = true,
|
||||
autoSave,
|
||||
invalidates,
|
||||
...rest
|
||||
}: UseDrawerFormProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
>): UseDrawerFormReturnType<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> => {
|
||||
const invalidate = useInvalidate();
|
||||
const [initiallySynced, setInitiallySynced] = React.useState(false);
|
||||
|
||||
const { visible, show, close } = useModal({
|
||||
defaultVisible,
|
||||
});
|
||||
|
||||
const {
|
||||
resource,
|
||||
action: actionFromParams,
|
||||
identifier,
|
||||
} = useResource(rest.resource);
|
||||
|
||||
const parsed = useParsed();
|
||||
const go = useGo();
|
||||
|
||||
const action = rest.action ?? actionFromParams ?? "";
|
||||
|
||||
const syncingId = !(
|
||||
typeof syncWithLocation === "object" && syncWithLocation?.syncId === false
|
||||
);
|
||||
|
||||
const syncWithLocationKey =
|
||||
typeof syncWithLocation === "object" && "key" in syncWithLocation
|
||||
? syncWithLocation.key
|
||||
: resource && action && syncWithLocation
|
||||
? `drawer-${resource?.identifier ?? resource?.name}-${action}`
|
||||
: undefined;
|
||||
|
||||
const useFormProps = useForm<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
>({
|
||||
meta: {
|
||||
...(syncWithLocationKey ? { [syncWithLocationKey]: undefined } : {}),
|
||||
...rest.meta,
|
||||
},
|
||||
autoSave,
|
||||
invalidates,
|
||||
...rest,
|
||||
});
|
||||
|
||||
const { form, formProps, formLoading, id, setId, onFinish, autoSaveProps } =
|
||||
useFormProps;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initiallySynced === false && syncWithLocationKey) {
|
||||
const openStatus = parsed?.params?.[syncWithLocationKey]?.open;
|
||||
if (typeof openStatus === "boolean") {
|
||||
openStatus ? show() : close();
|
||||
} else if (typeof openStatus === "string") {
|
||||
if (openStatus === "true") {
|
||||
show();
|
||||
}
|
||||
}
|
||||
|
||||
if (syncingId) {
|
||||
const idFromParams = parsed?.params?.[syncWithLocationKey]?.id;
|
||||
if (idFromParams) {
|
||||
setId?.(idFromParams);
|
||||
}
|
||||
}
|
||||
|
||||
setInitiallySynced(true);
|
||||
}
|
||||
}, [syncWithLocationKey, parsed, syncingId, setId, initiallySynced]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initiallySynced === true) {
|
||||
if (visible && syncWithLocationKey) {
|
||||
go({
|
||||
query: {
|
||||
[syncWithLocationKey]: {
|
||||
...parsed?.params?.[syncWithLocationKey],
|
||||
open: true,
|
||||
...(syncingId && id && { id }),
|
||||
},
|
||||
},
|
||||
options: { keepQuery: true },
|
||||
type: "replace",
|
||||
});
|
||||
} else if (syncWithLocationKey && !visible) {
|
||||
go({
|
||||
query: {
|
||||
[syncWithLocationKey]: undefined,
|
||||
},
|
||||
options: { keepQuery: true },
|
||||
type: "replace",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
id,
|
||||
visible,
|
||||
show,
|
||||
close,
|
||||
syncWithLocationKey,
|
||||
syncingId,
|
||||
initiallySynced,
|
||||
]);
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
const { warnWhen, setWarnWhen } = useWarnAboutChange();
|
||||
|
||||
const saveButtonProps = {
|
||||
disabled: formLoading,
|
||||
onClick: () => {
|
||||
form.submit();
|
||||
},
|
||||
loading: formLoading,
|
||||
};
|
||||
|
||||
const deleteButtonProps = {
|
||||
recordItemId: id,
|
||||
onSuccess: () => {
|
||||
setId?.(undefined);
|
||||
close();
|
||||
},
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (autoSaveProps.status === "success" && autoSave?.invalidateOnClose) {
|
||||
invalidate({
|
||||
id,
|
||||
invalidates: invalidates || ["list", "many", "detail"],
|
||||
dataProviderName: rest.dataProviderName,
|
||||
resource: identifier,
|
||||
});
|
||||
}
|
||||
|
||||
if (warnWhen) {
|
||||
const warnWhenConfirm = window.confirm(
|
||||
translate(
|
||||
"warnWhenUnsavedChanges",
|
||||
"Are you sure you want to leave? You have unsaved changes.",
|
||||
),
|
||||
);
|
||||
|
||||
if (warnWhenConfirm) {
|
||||
setWarnWhen(false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
close();
|
||||
setId?.(undefined);
|
||||
}, [warnWhen]);
|
||||
|
||||
const handleShow = useCallback(
|
||||
(showId?: BaseKey) => {
|
||||
if (typeof showId !== "undefined") {
|
||||
setId?.(showId);
|
||||
}
|
||||
const needsIdToOpen = action === "edit" || action === "clone";
|
||||
const hasId = typeof showId !== "undefined" || typeof id !== "undefined";
|
||||
if (needsIdToOpen ? hasId : true) {
|
||||
show();
|
||||
}
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
return {
|
||||
...useFormProps,
|
||||
show: handleShow,
|
||||
close: handleClose,
|
||||
formProps: {
|
||||
form,
|
||||
...useFormProps.formProps,
|
||||
onValuesChange: formProps?.onValuesChange,
|
||||
onKeyUp: formProps?.onKeyUp,
|
||||
onFinish: async (values) => {
|
||||
await onFinish(values);
|
||||
|
||||
if (autoSubmitClose) {
|
||||
close();
|
||||
}
|
||||
|
||||
if (autoResetForm) {
|
||||
form.resetFields();
|
||||
}
|
||||
},
|
||||
},
|
||||
drawerProps: {
|
||||
width: "500px",
|
||||
onClose: handleClose,
|
||||
open: visible,
|
||||
forceRender: true,
|
||||
},
|
||||
saveButtonProps,
|
||||
deleteButtonProps,
|
||||
formLoading,
|
||||
};
|
||||
};
|
||||
360
packages/antd/src/hooks/form/useForm.spec.tsx
Normal file
360
packages/antd/src/hooks/form/useForm.spec.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import React from "react";
|
||||
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import type { IRefineOptions, HttpError } from "@refinedev/core";
|
||||
|
||||
import { Form, Input, Select } from "antd";
|
||||
import { useForm, useSelect } from "..";
|
||||
import {
|
||||
MockJSONServer,
|
||||
TestWrapper,
|
||||
render,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
renderHook,
|
||||
act,
|
||||
} from "@test";
|
||||
import { mockRouterBindings } from "@test/dataMocks";
|
||||
import { SaveButton } from "@components/buttons";
|
||||
|
||||
interface IPost {
|
||||
title: string;
|
||||
content: string;
|
||||
slug: string;
|
||||
category: { id: number };
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const renderForm = ({
|
||||
formParams,
|
||||
refineOptions,
|
||||
}: {
|
||||
formParams: any;
|
||||
refineOptions?: IRefineOptions;
|
||||
}) => {
|
||||
const Page = () => {
|
||||
const {
|
||||
formProps,
|
||||
saveButtonProps,
|
||||
query,
|
||||
formLoading,
|
||||
defaultFormValuesLoading,
|
||||
} = useForm<IPost, HttpError, IPost>(formParams);
|
||||
|
||||
const postData = query?.data?.data;
|
||||
const { selectProps: categorySelectProps } = useSelect({
|
||||
resource: "categories",
|
||||
defaultValue: postData?.category?.id,
|
||||
queryOptions: {
|
||||
enabled: !!postData?.category?.id,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{formLoading && <div>formLoading</div>}
|
||||
{defaultFormValuesLoading && <div>defaultFormValuesLoading</div>}
|
||||
<SaveButton {...saveButtonProps} />
|
||||
<Form {...formProps} layout="vertical">
|
||||
<Form.Item label="Title" name="title">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Category" name={["category", "id"]}>
|
||||
<Select {...categorySelectProps} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Status" name="status">
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
label: "Published",
|
||||
value: "published",
|
||||
},
|
||||
{
|
||||
label: "Draft",
|
||||
value: "draft",
|
||||
},
|
||||
{
|
||||
label: "Rejected",
|
||||
value: "rejected",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Content" name="content">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/" element={<Page />} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
options: refineOptions,
|
||||
routerProvider: mockRouterBindings(),
|
||||
i18nProvider: {
|
||||
changeLocale: () => Promise.resolve(),
|
||||
getLocale: () => "en",
|
||||
translate: (key: string) => {
|
||||
if (key === "form.error.content") {
|
||||
return "Translated content error";
|
||||
}
|
||||
|
||||
return key;
|
||||
},
|
||||
},
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
update: async () => {
|
||||
const error: HttpError = {
|
||||
message: "An error occurred while updating the record.",
|
||||
statusCode: 400,
|
||||
errors: {
|
||||
title: ["Title is required"],
|
||||
"category.id": ["Category is required"],
|
||||
status: true,
|
||||
content: {
|
||||
key: "form.error.content",
|
||||
message: "Content is required",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
create: async () => {
|
||||
const error: HttpError = {
|
||||
message: "Create is not supported in this example.",
|
||||
statusCode: 400,
|
||||
slug: true,
|
||||
errors: {
|
||||
title: ["Title is required"],
|
||||
"category.id": ["Category is required"],
|
||||
status: true,
|
||||
content: {
|
||||
key: "form.error.content",
|
||||
message: "Content is required",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
getMany: async () => {
|
||||
return Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: "lorem ipsum dolor",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe("useForm hook", () => {
|
||||
it.each(["edit", "create"] as const)(
|
||||
"should set %s-form errors from data provider",
|
||||
async (action) => {
|
||||
const onMutationErrorMock = jest.fn();
|
||||
|
||||
const { getByText, getByTestId } = renderForm({
|
||||
formParams: {
|
||||
onMutationError: onMutationErrorMock,
|
||||
resource: "posts",
|
||||
action: action,
|
||||
id: action === "edit" ? "1" : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const saveButton = getByTestId("refine-save-button");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body).not.toHaveTextContent("loading");
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body).not.toHaveTextContent("loading");
|
||||
expect(onMutationErrorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Title is required")).toBeInTheDocument();
|
||||
expect(getByText("Category is required")).toBeInTheDocument();
|
||||
expect(getByText("Translated content error")).toBeInTheDocument();
|
||||
expect(getByText("Field is not valid.")).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
action: "edit",
|
||||
disableFromRefineOption: false,
|
||||
disableFromHook: true,
|
||||
},
|
||||
{
|
||||
action: "edit",
|
||||
disableFromRefineOption: true,
|
||||
disableFromHook: false,
|
||||
},
|
||||
{
|
||||
action: "create",
|
||||
disableFromRefineOption: false,
|
||||
disableFromHook: true,
|
||||
},
|
||||
{
|
||||
action: "create",
|
||||
disableFromRefineOption: true,
|
||||
disableFromHook: false,
|
||||
},
|
||||
] as const)("should disable server-side validation", async (testCase) => {
|
||||
const onMutationErrorMock = jest.fn();
|
||||
|
||||
const { queryByText, getByTestId } = renderForm({
|
||||
refineOptions: {
|
||||
disableServerSideValidation: testCase.disableFromRefineOption,
|
||||
},
|
||||
formParams: {
|
||||
onMutationError: onMutationErrorMock,
|
||||
resource: "posts",
|
||||
action: testCase.action,
|
||||
id: testCase.action === "edit" ? "1" : undefined,
|
||||
disableServerSideValidation: testCase.disableFromHook,
|
||||
},
|
||||
});
|
||||
|
||||
const saveButton = getByTestId("refine-save-button");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body).not.toHaveTextContent("loading");
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body).not.toHaveTextContent("loading");
|
||||
expect(onMutationErrorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText("Title is required")).not.toBeInTheDocument();
|
||||
expect(queryByText("Category is required")).not.toBeInTheDocument();
|
||||
expect(queryByText("Translated content error")).not.toBeInTheDocument();
|
||||
expect(queryByText("Field is not valid.")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept defaultFormValues", async () => {
|
||||
const { getByLabelText } = renderForm({
|
||||
formParams: {
|
||||
resource: "posts",
|
||||
action: "create",
|
||||
defaultFormValues: {
|
||||
title: "Default Title",
|
||||
content: "Default Content",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText("Title")).toHaveValue("Default Title");
|
||||
expect(getByLabelText("Content")).toHaveValue("Default Content");
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept defaultFormValues as promise", async () => {
|
||||
const { getByLabelText } = renderForm({
|
||||
formParams: {
|
||||
resource: "posts",
|
||||
action: "create",
|
||||
defaultFormValues: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return {
|
||||
title: "Default Title",
|
||||
content: "Default Content",
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText("Title")).toHaveValue("");
|
||||
expect(getByLabelText("Content")).toHaveValue("");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText("Title")).toHaveValue("Default Title");
|
||||
expect(getByLabelText("Content")).toHaveValue("Default Content");
|
||||
});
|
||||
});
|
||||
|
||||
it("formLoading and defaultFormValuesLoading should work", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
return useForm<IPost, HttpError, IPost>({
|
||||
resource: "posts",
|
||||
action: "edit",
|
||||
id: "1",
|
||||
defaultFormValues: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return {
|
||||
title: "Default Title",
|
||||
content: "Default Content",
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
getOne: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
return {
|
||||
data: {
|
||||
id: 1,
|
||||
title:
|
||||
"Necessitatibus necessitatibus id et cupiditate provident est qui amet.",
|
||||
content: "Content",
|
||||
category: {
|
||||
id: 1,
|
||||
},
|
||||
tags: ["tag1", "tag2"],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.formLoading).toBe(true);
|
||||
expect(result.current.defaultFormValuesLoading).toBe(true);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
expect(result.current.formLoading).toBe(true);
|
||||
expect(result.current.defaultFormValuesLoading).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(result.current.formLoading).toBe(false);
|
||||
expect(result.current.defaultFormValuesLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
326
packages/antd/src/hooks/form/useForm.ts
Normal file
326
packages/antd/src/hooks/form/useForm.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import React from "react";
|
||||
import {
|
||||
type FormInstance,
|
||||
type FormProps,
|
||||
Form,
|
||||
type ButtonProps,
|
||||
} from "antd";
|
||||
import { useForm as useFormSF, type UseFormConfig } from "sunflower-antd";
|
||||
import {
|
||||
type AutoSaveProps,
|
||||
flattenObjectKeys,
|
||||
propertyPathToArray,
|
||||
} from "@refinedev/core";
|
||||
|
||||
import {
|
||||
type HttpError,
|
||||
type BaseRecord,
|
||||
useForm as useFormCore,
|
||||
type UseFormReturnType as UseFormReturnTypeCore,
|
||||
useWarnAboutChange,
|
||||
type UseFormProps as UseFormPropsCore,
|
||||
type CreateResponse,
|
||||
type UpdateResponse,
|
||||
pickNotDeprecated,
|
||||
useTranslate,
|
||||
useRefineContext,
|
||||
} from "@refinedev/core";
|
||||
|
||||
export type UseFormProps<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
> = UseFormPropsCore<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> & {
|
||||
submitOnEnter?: boolean;
|
||||
/**
|
||||
* Shows notification when unsaved changes exist
|
||||
*/
|
||||
warnWhenUnsavedChanges?: boolean;
|
||||
/**
|
||||
* Disables server-side validation
|
||||
* @default false
|
||||
* @see {@link https://refine.dev/docs/advanced-tutorials/forms/server-side-form-validation/}
|
||||
*/
|
||||
disableServerSideValidation?: boolean;
|
||||
} & AutoSaveProps<TVariables> &
|
||||
Pick<UseFormConfig, "defaultFormValues">;
|
||||
|
||||
export type UseFormReturnType<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
> = UseFormReturnTypeCore<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> & {
|
||||
form: FormInstance<TVariables>;
|
||||
formProps: FormProps<TVariables>;
|
||||
saveButtonProps: ButtonProps & {
|
||||
onClick: () => void;
|
||||
};
|
||||
onFinish: (
|
||||
values?: TVariables,
|
||||
) => Promise<CreateResponse<TResponse> | UpdateResponse<TResponse> | void>;
|
||||
} & Pick<
|
||||
ReturnType<typeof useFormSF<TResponse, TVariables>>,
|
||||
"defaultFormValuesLoading"
|
||||
>;
|
||||
|
||||
/**
|
||||
* `useForm` is used to manage forms. It uses Ant Design {@link https://ant.design/components/form/ Form} data scope management under the hood and returns the required props for managing the form actions.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/core/hooks/useForm} for more details.
|
||||
*
|
||||
* @typeParam TData - Result data of the query extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences/#httperror `HttpError`}
|
||||
* @typeParam TVariables - Values for params. default `{}`
|
||||
* @typeParam TData - Result data returned by the `select` function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}. Defaults to `TQueryFnData`
|
||||
* @typeParam TResponse - Result data returned by the mutation function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}. Defaults to `TData`
|
||||
* @typeParam TResponseError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#httperror `HttpError`}. Defaults to `TError`
|
||||
*
|
||||
*
|
||||
*/
|
||||
export const useForm = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
>({
|
||||
action,
|
||||
resource,
|
||||
onMutationSuccess: onMutationSuccessProp,
|
||||
onMutationError: onMutationErrorProp,
|
||||
autoSave,
|
||||
submitOnEnter = false,
|
||||
warnWhenUnsavedChanges: warnWhenUnsavedChangesProp,
|
||||
redirect,
|
||||
successNotification,
|
||||
errorNotification,
|
||||
meta,
|
||||
metaData,
|
||||
queryMeta,
|
||||
mutationMeta,
|
||||
liveMode,
|
||||
liveParams,
|
||||
mutationMode,
|
||||
dataProviderName,
|
||||
onLiveEvent,
|
||||
invalidates,
|
||||
undoableTimeout,
|
||||
queryOptions,
|
||||
createMutationOptions,
|
||||
updateMutationOptions,
|
||||
id: idFromProps,
|
||||
overtimeOptions,
|
||||
optimisticUpdateMap,
|
||||
defaultFormValues,
|
||||
disableServerSideValidation: disableServerSideValidationProp = false,
|
||||
}: UseFormProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> = {}): UseFormReturnType<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> => {
|
||||
const { options } = useRefineContext();
|
||||
const disableServerSideValidation =
|
||||
options?.disableServerSideValidation || disableServerSideValidationProp;
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
const [formAnt] = Form.useForm();
|
||||
const formSF = useFormSF<TResponse, TVariables>({
|
||||
form: formAnt,
|
||||
defaultFormValues,
|
||||
});
|
||||
const { form } = formSF;
|
||||
|
||||
const useFormCoreResult = useFormCore<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
>({
|
||||
onMutationSuccess: onMutationSuccessProp
|
||||
? onMutationSuccessProp
|
||||
: undefined,
|
||||
onMutationError: async (error, _variables, _context) => {
|
||||
if (disableServerSideValidation) {
|
||||
onMutationErrorProp?.(error, _variables, _context);
|
||||
return;
|
||||
}
|
||||
|
||||
type FieldData = Parameters<typeof form.setFields>[0];
|
||||
type NamePath = FieldData[number]["name"];
|
||||
// antd form expects error object to be in a specific format.
|
||||
let parsedErrors: FieldData = [];
|
||||
|
||||
// reset antd errors before setting new errors
|
||||
const fieldsValue = form.getFieldsValue() as unknown as object;
|
||||
|
||||
const fields = Object.keys(flattenObjectKeys(fieldsValue));
|
||||
|
||||
parsedErrors = fields.map((field) => {
|
||||
return {
|
||||
name: propertyPathToArray(field) as NamePath,
|
||||
errors: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
form.setFields(parsedErrors);
|
||||
|
||||
const errors = error?.errors;
|
||||
// parse errors to antd form errors
|
||||
for (const key in errors) {
|
||||
const fieldError = errors[key];
|
||||
|
||||
let newError: string[] = [];
|
||||
|
||||
if (Array.isArray(fieldError)) {
|
||||
newError = fieldError;
|
||||
}
|
||||
|
||||
if (typeof fieldError === "string") {
|
||||
newError = [fieldError];
|
||||
}
|
||||
|
||||
if (typeof fieldError === "boolean" && fieldError) {
|
||||
newError = ["Field is not valid."];
|
||||
}
|
||||
|
||||
if (typeof fieldError === "object" && "key" in fieldError) {
|
||||
const translatedMessage = translate(
|
||||
fieldError.key,
|
||||
fieldError.message,
|
||||
);
|
||||
|
||||
newError = [translatedMessage];
|
||||
}
|
||||
|
||||
parsedErrors.push({
|
||||
name: propertyPathToArray(key) as NamePath,
|
||||
errors: newError,
|
||||
});
|
||||
}
|
||||
|
||||
form.setFields([...parsedErrors]);
|
||||
|
||||
onMutationErrorProp?.(error, _variables, _context);
|
||||
},
|
||||
redirect,
|
||||
action,
|
||||
resource,
|
||||
successNotification,
|
||||
errorNotification,
|
||||
meta: pickNotDeprecated(meta, metaData),
|
||||
metaData: pickNotDeprecated(meta, metaData),
|
||||
queryMeta,
|
||||
mutationMeta,
|
||||
liveMode,
|
||||
liveParams,
|
||||
mutationMode,
|
||||
dataProviderName,
|
||||
onLiveEvent,
|
||||
invalidates,
|
||||
undoableTimeout,
|
||||
queryOptions,
|
||||
createMutationOptions,
|
||||
updateMutationOptions,
|
||||
id: idFromProps,
|
||||
overtimeOptions,
|
||||
optimisticUpdateMap,
|
||||
autoSave,
|
||||
});
|
||||
|
||||
const { formLoading, onFinish, query, id, onFinishAutoSave } =
|
||||
useFormCoreResult;
|
||||
|
||||
const { warnWhenUnsavedChanges: warnWhenUnsavedChangesRefine, setWarnWhen } =
|
||||
useWarnAboutChange();
|
||||
const warnWhenUnsavedChanges =
|
||||
warnWhenUnsavedChangesProp ?? warnWhenUnsavedChangesRefine;
|
||||
|
||||
// populate form with data when query is ready or id changes
|
||||
// form populated via initialValues prop
|
||||
React.useEffect(() => {
|
||||
form.resetFields();
|
||||
}, [query?.data?.data, id]);
|
||||
|
||||
const onKeyUp = (event: React.KeyboardEvent<HTMLFormElement>) => {
|
||||
if (submitOnEnter && event.key === "Enter") {
|
||||
form.submit();
|
||||
}
|
||||
};
|
||||
|
||||
const onValuesChange = (changeValues: object, allValues: any) => {
|
||||
if (changeValues && warnWhenUnsavedChanges) {
|
||||
setWarnWhen(true);
|
||||
}
|
||||
|
||||
if (autoSave?.enabled) {
|
||||
setWarnWhen(false);
|
||||
|
||||
const onFinishFromProps = autoSave?.onFinish ?? ((values) => values);
|
||||
|
||||
return onFinishAutoSave(onFinishFromProps(allValues)).catch(
|
||||
(error) => error,
|
||||
);
|
||||
}
|
||||
|
||||
return changeValues;
|
||||
};
|
||||
|
||||
const saveButtonProps = {
|
||||
disabled: formLoading,
|
||||
onClick: () => {
|
||||
form.submit();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
form: formSF.form,
|
||||
formProps: {
|
||||
...formSF.formProps,
|
||||
onFinish: (values: TVariables) =>
|
||||
onFinish(values).catch((error) => error),
|
||||
onKeyUp,
|
||||
onValuesChange,
|
||||
initialValues: query?.data?.data,
|
||||
},
|
||||
saveButtonProps,
|
||||
defaultFormValuesLoading: formSF.defaultFormValuesLoading,
|
||||
...useFormCoreResult,
|
||||
onFinish: async (values?: TVariables) => {
|
||||
return await onFinish(values ?? formSF.form.getFieldsValue(true));
|
||||
},
|
||||
};
|
||||
};
|
||||
301
packages/antd/src/hooks/form/useModalForm/index.spec.tsx
Normal file
301
packages/antd/src/hooks/form/useModalForm/index.spec.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { act, MockJSONServer, TestWrapper } from "@test";
|
||||
|
||||
import { useModalForm } from "./";
|
||||
|
||||
describe("useModalForm Hook", () => {
|
||||
it("should return correct initial value of 'open'", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
action: "create",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.modalProps.open).toBe(false);
|
||||
});
|
||||
|
||||
it("'open' initial value should be set with 'defaultVisible'", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
action: "create",
|
||||
defaultVisible: true,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.modalProps.open).toBe(true);
|
||||
});
|
||||
|
||||
it("'open' value should be false when 'onCancel' is called", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
action: "edit",
|
||||
defaultVisible: true,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.modalProps.open).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
result.current.modalProps.onCancel?.({} as any);
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.open).toBe(false);
|
||||
});
|
||||
|
||||
it("'open' value should be true when 'show' is called", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
id: 1,
|
||||
action: "edit",
|
||||
defaultVisible: false,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.modalProps.open).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.modalProps.open).toBe(true));
|
||||
});
|
||||
|
||||
it("'id' should be updated when 'show' is called with 'id'", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
action: "edit",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
const id = "5";
|
||||
|
||||
await act(async () => {
|
||||
result.current.show(id);
|
||||
});
|
||||
|
||||
expect(result.current.id).toBe(id);
|
||||
});
|
||||
|
||||
it("'title' should be set with 'action' and 'resource'", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
resource: "test",
|
||||
action: "edit",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.modalProps.title).toBe("Edit test");
|
||||
});
|
||||
|
||||
it("when 'autoSubmitClose' is true, the modal should be closed when 'submit' is called", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
action: "edit",
|
||||
id: "1",
|
||||
resource: "posts",
|
||||
autoSubmitClose: true,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.formProps.onFinish?.({});
|
||||
});
|
||||
|
||||
await waitFor(() => result.current.mutation.isSuccess);
|
||||
|
||||
await waitFor(() => expect(result.current.modalProps.open).toBe(false));
|
||||
|
||||
expect(result.current.modalProps.open).toBe(false);
|
||||
});
|
||||
|
||||
it("when 'autoSubmitClose' is false, 'close' should not be called when 'submit' is called", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
id: 1,
|
||||
action: "edit",
|
||||
resource: "posts",
|
||||
autoSubmitClose: false,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
result.current.formProps.onFinish?.({});
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.open).toBe(true);
|
||||
});
|
||||
|
||||
it("autoResetForm is true, 'reset' should be called when the form is submitted", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
resource: "posts",
|
||||
action: "edit",
|
||||
autoResetForm: true,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
result.current.formProps.form?.setFieldsValue({
|
||||
test: "test",
|
||||
foo: "bar",
|
||||
});
|
||||
result.current.modalProps.onOk?.({} as any);
|
||||
});
|
||||
|
||||
await waitFor(() => result.current.mutation.isSuccess);
|
||||
|
||||
expect(result.current.formProps.form?.getFieldsValue()).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("when mutationMode is 'pessimistic', the form should be closed when the mutation is successful", async () => {
|
||||
const updateMock = jest.fn(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
action: "edit",
|
||||
resource: "posts",
|
||||
id: 1,
|
||||
mutationMode: "pessimistic",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
update: updateMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
result.current.formProps.onFinish?.({});
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.open).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.modalProps.open).toBe(false));
|
||||
|
||||
expect(updateMock).toBeCalledTimes(1);
|
||||
expect(result.current.modalProps.open).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["optimistic", "undoable"] as const)(
|
||||
"when mutationMode is '%s', the form should be closed when the mutation is successful",
|
||||
async (mutationMode) => {
|
||||
const updateMock = jest.fn(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
action: "edit",
|
||||
resource: "posts",
|
||||
id: 1,
|
||||
mutationMode,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
update: updateMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
result.current.formProps.onFinish?.({});
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.open).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("should `meta[syncWithLocationKey]` overrided by default", async () => {
|
||||
const mockGetOne = jest.fn();
|
||||
const mockUpdate = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModalForm({
|
||||
syncWithLocation: true,
|
||||
id: 5,
|
||||
action: "edit",
|
||||
resource: "posts",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
getOne: mockGetOne,
|
||||
update: mockUpdate,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.show();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetOne).toBeCalledTimes(1);
|
||||
expect(mockGetOne).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({
|
||||
"modal-posts-edit": undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
5
packages/antd/src/hooks/form/useModalForm/index.ts
Normal file
5
packages/antd/src/hooks/form/useModalForm/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
useModalForm,
|
||||
UseModalFormProps,
|
||||
UseModalFormReturnType,
|
||||
} from "./useModalForm";
|
||||
359
packages/antd/src/hooks/form/useModalForm/useModalForm.ts
Normal file
359
packages/antd/src/hooks/form/useModalForm/useModalForm.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { useCallback } from "react";
|
||||
import type { FormInstance, FormProps, ModalProps } from "antd";
|
||||
|
||||
import {
|
||||
useTranslate,
|
||||
useWarnAboutChange,
|
||||
type HttpError,
|
||||
type UseFormProps as UseFormPropsCore,
|
||||
type BaseRecord,
|
||||
type LiveModeProps,
|
||||
type BaseKey,
|
||||
useUserFriendlyName,
|
||||
useResource,
|
||||
type FormWithSyncWithLocationParams,
|
||||
useParsed,
|
||||
useGo,
|
||||
useInvalidate,
|
||||
} from "@refinedev/core";
|
||||
import { useForm, type UseFormProps, type UseFormReturnType } from "../useForm";
|
||||
import { useModal } from "@hooks/modal";
|
||||
|
||||
export type useModalFormFromSFReturnType<TResponse, TVariables> = {
|
||||
open: boolean;
|
||||
form: FormInstance<TVariables>;
|
||||
show: (id?: BaseKey) => void;
|
||||
close: () => void;
|
||||
modalProps: ModalProps;
|
||||
formProps: FormProps<TVariables>;
|
||||
formLoading: boolean;
|
||||
defaultFormValuesLoading: boolean;
|
||||
formValues: {};
|
||||
initialValues: {};
|
||||
formResult: undefined;
|
||||
submit: (values?: TVariables) => Promise<TResponse>;
|
||||
/** @deprecated Please use `open` instead. */
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
type useModalFormConfig = {
|
||||
action: "show" | "edit" | "create" | "clone";
|
||||
};
|
||||
|
||||
export type UseModalFormReturnType<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
> = Omit<
|
||||
UseFormReturnType<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
>,
|
||||
"saveButtonProps" | "deleteButtonProps"
|
||||
> &
|
||||
useModalFormFromSFReturnType<TResponse, TVariables>;
|
||||
|
||||
export type UseModalFormProps<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
> = UseFormPropsCore<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> &
|
||||
UseFormProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> &
|
||||
useModalFormConfig &
|
||||
LiveModeProps &
|
||||
FormWithSyncWithLocationParams & {
|
||||
defaultVisible?: boolean;
|
||||
autoSubmitClose?: boolean;
|
||||
autoResetForm?: boolean;
|
||||
};
|
||||
/**
|
||||
* `useModalForm` hook allows you to manage a form within a modal. It returns Ant Design {@link https://ant.design/components/form/ Form} and {@link https://ant.design/components/modal/ Modal} components props.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/form/useModalForm} for more details.
|
||||
*
|
||||
* @typeParam TData - Result data of the query extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences/#httperror `HttpError`}
|
||||
* @typeParam TVariables - Values for params. default `{}`
|
||||
*
|
||||
*
|
||||
*/
|
||||
export const useModalForm = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
>({
|
||||
syncWithLocation,
|
||||
defaultVisible = false,
|
||||
autoSubmitClose = true,
|
||||
autoResetForm = true,
|
||||
autoSave,
|
||||
invalidates,
|
||||
...rest
|
||||
}: UseModalFormProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
>): UseModalFormReturnType<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> => {
|
||||
const [initiallySynced, setInitiallySynced] = React.useState(false);
|
||||
const invalidate = useInvalidate();
|
||||
|
||||
const {
|
||||
resource,
|
||||
action: actionFromParams,
|
||||
identifier,
|
||||
} = useResource(rest.resource);
|
||||
|
||||
const parsed = useParsed();
|
||||
const go = useGo();
|
||||
const getUserFriendlyName = useUserFriendlyName();
|
||||
|
||||
const action = rest.action ?? actionFromParams ?? "";
|
||||
|
||||
const syncingId = !(
|
||||
typeof syncWithLocation === "object" && syncWithLocation?.syncId === false
|
||||
);
|
||||
|
||||
const syncWithLocationKey =
|
||||
typeof syncWithLocation === "object" && "key" in syncWithLocation
|
||||
? syncWithLocation.key
|
||||
: resource && action && syncWithLocation
|
||||
? `modal-${identifier}-${action}`
|
||||
: undefined;
|
||||
|
||||
const useFormProps = useForm<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
>({
|
||||
meta: {
|
||||
...(syncWithLocationKey ? { [syncWithLocationKey]: undefined } : {}),
|
||||
...rest.meta,
|
||||
},
|
||||
autoSave,
|
||||
invalidates,
|
||||
...rest,
|
||||
});
|
||||
|
||||
const { form, formProps, id, setId, formLoading, onFinish, autoSaveProps } =
|
||||
useFormProps;
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
const { warnWhen, setWarnWhen } = useWarnAboutChange();
|
||||
|
||||
const { show, close, modalProps } = useModal({
|
||||
modalProps: {
|
||||
open: defaultVisible,
|
||||
},
|
||||
});
|
||||
|
||||
const visible = modalProps.open || false;
|
||||
const sunflowerUseModal: useModalFormFromSFReturnType<TResponse, TVariables> =
|
||||
{
|
||||
modalProps,
|
||||
form,
|
||||
formLoading,
|
||||
formProps,
|
||||
formResult: undefined,
|
||||
formValues: form.getFieldsValue,
|
||||
defaultFormValuesLoading: false,
|
||||
initialValues: {},
|
||||
submit: onFinish as any,
|
||||
close,
|
||||
open: modalProps.open || false,
|
||||
show,
|
||||
visible,
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initiallySynced === false && syncWithLocationKey) {
|
||||
const openStatus = parsed?.params?.[syncWithLocationKey]?.open;
|
||||
|
||||
if (typeof openStatus === "boolean") {
|
||||
if (openStatus) {
|
||||
show();
|
||||
}
|
||||
} else if (typeof openStatus === "string") {
|
||||
if (openStatus === "true") {
|
||||
show();
|
||||
}
|
||||
}
|
||||
|
||||
if (syncingId) {
|
||||
const idFromParams = parsed?.params?.[syncWithLocationKey]?.id;
|
||||
if (idFromParams) {
|
||||
setId?.(idFromParams);
|
||||
}
|
||||
}
|
||||
|
||||
setInitiallySynced(true);
|
||||
}
|
||||
}, [syncWithLocationKey, parsed, syncingId, setId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initiallySynced === true) {
|
||||
if (visible && syncWithLocationKey) {
|
||||
go({
|
||||
query: {
|
||||
[syncWithLocationKey]: {
|
||||
...parsed?.params?.[syncWithLocationKey],
|
||||
open: true,
|
||||
...(syncingId && id && { id }),
|
||||
},
|
||||
},
|
||||
options: { keepQuery: true },
|
||||
type: "replace",
|
||||
});
|
||||
} else if (syncWithLocationKey && !visible) {
|
||||
go({
|
||||
query: {
|
||||
[syncWithLocationKey]: undefined,
|
||||
},
|
||||
options: { keepQuery: true },
|
||||
type: "replace",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [id, visible, show, syncWithLocationKey, syncingId]);
|
||||
|
||||
const saveButtonPropsSF = {
|
||||
disabled: formLoading,
|
||||
loading: formLoading,
|
||||
onClick: () => {
|
||||
form.submit();
|
||||
},
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (autoSaveProps.status === "success" && autoSave?.invalidateOnClose) {
|
||||
invalidate({
|
||||
id,
|
||||
invalidates: invalidates || ["list", "many", "detail"],
|
||||
dataProviderName: rest.dataProviderName,
|
||||
resource: identifier,
|
||||
});
|
||||
}
|
||||
|
||||
if (warnWhen) {
|
||||
const warnWhenConfirm = window.confirm(
|
||||
translate(
|
||||
"warnWhenUnsavedChanges",
|
||||
"Are you sure you want to leave? You have unsaved changes.",
|
||||
),
|
||||
);
|
||||
|
||||
if (warnWhenConfirm) {
|
||||
setWarnWhen(false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setId?.(undefined);
|
||||
sunflowerUseModal.close();
|
||||
}, [warnWhen, autoSaveProps.status]);
|
||||
|
||||
const handleShow = useCallback(
|
||||
(showId?: BaseKey) => {
|
||||
if (typeof showId !== "undefined") {
|
||||
setId?.(showId);
|
||||
}
|
||||
const needsIdToOpen = action === "edit" || action === "clone";
|
||||
const hasId = typeof showId !== "undefined" || typeof id !== "undefined";
|
||||
if (needsIdToOpen ? hasId : true) {
|
||||
sunflowerUseModal.show();
|
||||
}
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
const { visible: _visible, ...otherModalProps } = modalProps;
|
||||
const newModalProps = { open: _visible, ...otherModalProps };
|
||||
|
||||
return {
|
||||
...useFormProps,
|
||||
...sunflowerUseModal,
|
||||
show: handleShow,
|
||||
close: handleClose,
|
||||
open: visible,
|
||||
formProps: {
|
||||
...formProps,
|
||||
...useFormProps.formProps,
|
||||
onValuesChange: formProps?.onValuesChange,
|
||||
onKeyUp: formProps?.onKeyUp,
|
||||
onFinish: async (values) => {
|
||||
await onFinish(values);
|
||||
|
||||
if (autoSubmitClose) {
|
||||
close();
|
||||
}
|
||||
|
||||
if (autoResetForm) {
|
||||
form.resetFields();
|
||||
}
|
||||
},
|
||||
},
|
||||
modalProps: {
|
||||
...newModalProps,
|
||||
width: "1000px",
|
||||
okButtonProps: saveButtonPropsSF,
|
||||
title: translate(
|
||||
`${identifier}.titles.${rest.action}`,
|
||||
`${getUserFriendlyName(
|
||||
`${rest.action} ${
|
||||
resource?.meta?.label ??
|
||||
resource?.options?.label ??
|
||||
resource?.label ??
|
||||
identifier
|
||||
}`,
|
||||
"singular",
|
||||
)}`,
|
||||
),
|
||||
okText: translate("buttons.save", "Save"),
|
||||
cancelText: translate("buttons.cancel", "Cancel"),
|
||||
onCancel: handleClose,
|
||||
forceRender: true,
|
||||
},
|
||||
formLoading,
|
||||
};
|
||||
};
|
||||
5
packages/antd/src/hooks/form/useStepsForm/index.ts
Normal file
5
packages/antd/src/hooks/form/useStepsForm/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
useStepsForm,
|
||||
UseStepsFormProps,
|
||||
UseStepsFormReturnType,
|
||||
} from "./useStepsForm";
|
||||
271
packages/antd/src/hooks/form/useStepsForm/useStepsForm.spec.tsx
Normal file
271
packages/antd/src/hooks/form/useStepsForm/useStepsForm.spec.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import React from "react";
|
||||
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import type { IRefineOptions, HttpError } from "@refinedev/core";
|
||||
|
||||
import { Button, Form, Input, Select, Steps } from "antd";
|
||||
import { useStepsForm } from "..";
|
||||
import { useSelect } from "../..";
|
||||
import { MockJSONServer, TestWrapper, fireEvent, render, waitFor } from "@test";
|
||||
import { mockRouterBindings } from "@test/dataMocks";
|
||||
import { SaveButton } from "@components/buttons";
|
||||
import { act } from "react-dom/test-utils";
|
||||
|
||||
interface IPost {
|
||||
title: string;
|
||||
content: string;
|
||||
slug: string;
|
||||
category: { id: number };
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const renderForm = ({
|
||||
formParams,
|
||||
refineOptions,
|
||||
}: {
|
||||
formParams: any;
|
||||
refineOptions?: IRefineOptions;
|
||||
}) => {
|
||||
const Page = () => {
|
||||
const {
|
||||
current,
|
||||
gotoStep,
|
||||
stepsProps,
|
||||
formProps,
|
||||
saveButtonProps,
|
||||
formLoading,
|
||||
} = useStepsForm<IPost, HttpError, IPost>(formParams);
|
||||
|
||||
const { selectProps: categorySelectProps } = useSelect({
|
||||
resource: "categories",
|
||||
});
|
||||
|
||||
const formList = [
|
||||
<>
|
||||
<Form.Item
|
||||
label="Title"
|
||||
name="title"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Title is required",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Category"
|
||||
name={["category", "id"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Category is required",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select {...categorySelectProps} />
|
||||
</Form.Item>
|
||||
</>,
|
||||
<>
|
||||
<Form.Item
|
||||
label="Status"
|
||||
name="status"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Status is required",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
label: "Published",
|
||||
value: "published",
|
||||
},
|
||||
{
|
||||
label: "Draft",
|
||||
value: "draft",
|
||||
},
|
||||
{
|
||||
label: "Rejected",
|
||||
value: "rejected",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>,
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{formLoading && <div>loading...</div>}
|
||||
<Steps
|
||||
{...stepsProps}
|
||||
items={[{ title: "About Post" }, { title: "Content" }]}
|
||||
/>
|
||||
|
||||
<Form {...formProps} layout="vertical" style={{ marginTop: 30 }}>
|
||||
{formList[current]}
|
||||
</Form>
|
||||
{current > 0 && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await gotoStep(current - 1);
|
||||
} catch (e) {}
|
||||
}}
|
||||
data-testid="previous-button"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{current < formList.length - 1 && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await gotoStep(current + 1);
|
||||
} catch (e) {}
|
||||
}}
|
||||
data-testid="next-button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{current === formList.length - 1 && <SaveButton {...saveButtonProps} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/" element={<Page />} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
options: refineOptions,
|
||||
routerProvider: mockRouterBindings(),
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
getList: async () => {
|
||||
return Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
title: "lorem ipsum dolor",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe("useStepsForm hook", () => {
|
||||
it("should run validation when going to next step", async () => {
|
||||
const { getByText, getByTestId } = renderForm({
|
||||
formParams: {
|
||||
resource: "posts",
|
||||
action: "create",
|
||||
},
|
||||
});
|
||||
|
||||
const nextButton = getByTestId("next-button");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(nextButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Title is required")).toBeInTheDocument();
|
||||
expect(getByText("Category is required")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("By default, should not run validation when going to previous step", async () => {
|
||||
const { getByText, getByTestId, getByLabelText } = renderForm({
|
||||
formParams: {
|
||||
resource: "posts",
|
||||
action: "create",
|
||||
},
|
||||
});
|
||||
|
||||
const titleInput = getByLabelText("Title");
|
||||
const categorySelect = getByLabelText("Category");
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(titleInput, { target: { value: "foo" } });
|
||||
fireEvent.mouseDown(categorySelect);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(getByText("lorem ipsum dolor"));
|
||||
});
|
||||
|
||||
const nextButton = getByTestId("next-button");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(nextButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText("Status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const previousButton = getByTestId("previous-button");
|
||||
|
||||
fireEvent.click(previousButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText("Title")).toBeInTheDocument();
|
||||
expect(getByLabelText("Category")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should run validation when going to previous step", async () => {
|
||||
const { getByText, getByTestId, getByLabelText } = renderForm({
|
||||
formParams: {
|
||||
resource: "posts",
|
||||
action: "create",
|
||||
isBackValidate: true,
|
||||
},
|
||||
});
|
||||
|
||||
const titleInput = getByLabelText("Title");
|
||||
const categorySelect = getByLabelText("Category");
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(titleInput, { target: { value: "foo" } });
|
||||
fireEvent.mouseDown(categorySelect);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(getByText("lorem ipsum dolor"));
|
||||
});
|
||||
|
||||
const nextButton = getByTestId("next-button");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(nextButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText("Status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const previousButton = getByTestId("previous-button");
|
||||
|
||||
fireEvent.click(previousButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Status is required")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
145
packages/antd/src/hooks/form/useStepsForm/useStepsForm.ts
Normal file
145
packages/antd/src/hooks/form/useStepsForm/useStepsForm.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
useStepsForm as useStepsFormSF,
|
||||
type UseStepsFormConfig,
|
||||
} from "sunflower-antd";
|
||||
import type { FormInstance, FormProps } from "antd";
|
||||
|
||||
import type {
|
||||
HttpError,
|
||||
UseFormProps as UseFormPropsCore,
|
||||
BaseRecord,
|
||||
} from "@refinedev/core";
|
||||
|
||||
import { useForm, type UseFormProps, type UseFormReturnType } from "../useForm";
|
||||
|
||||
export type UseStepsFormFromSFReturnType<TResponse, TVariables> = {
|
||||
current: number;
|
||||
gotoStep: (step: number) => Promise<TVariables> | true;
|
||||
stepsProps: {
|
||||
current: number;
|
||||
onChange: (currentStep: number) => void;
|
||||
};
|
||||
formProps: FormProps<TVariables>;
|
||||
formLoading: boolean;
|
||||
defaultFormValuesLoading: boolean;
|
||||
formValues: {};
|
||||
initialValues: {};
|
||||
formResult: undefined;
|
||||
form: FormInstance<TVariables>;
|
||||
submit: (values?: TVariables) => Promise<TResponse>;
|
||||
};
|
||||
|
||||
export type UseStepsFormReturnType<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
> = UseFormReturnType<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> &
|
||||
UseStepsFormFromSFReturnType<TResponse, TVariables>;
|
||||
|
||||
export type UseStepsFormProps<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
> = UseFormPropsCore<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> &
|
||||
UseFormProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> &
|
||||
UseStepsFormConfig;
|
||||
|
||||
/**
|
||||
* `useStepsForm` hook allows you to split your form under an Ant Design based {@link https://ant.design/components/steps/ Steps} component and provides you with a few useful functionalities that will help you manage your form.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/form/useStepsForm} for more details.
|
||||
*
|
||||
* @typeParam TData - Result data of the query extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences/#httperror `HttpError`}
|
||||
* @typeParam TVariables - Values for params. default `{}`
|
||||
*
|
||||
*
|
||||
*/
|
||||
export const useStepsForm = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
TResponse extends BaseRecord = TData,
|
||||
TResponseError extends HttpError = TError,
|
||||
>(
|
||||
props: UseStepsFormProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> = {},
|
||||
): UseStepsFormReturnType<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
> => {
|
||||
const useFormProps = useForm<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TData,
|
||||
TResponse,
|
||||
TResponseError
|
||||
>({
|
||||
...props,
|
||||
});
|
||||
const { form, formProps } = useFormProps;
|
||||
|
||||
const stepsPropsSunflower = useStepsFormSF<TResponse, TVariables>({
|
||||
isBackValidate: false,
|
||||
form: form,
|
||||
submit: (values: any) => {
|
||||
formProps?.onFinish?.(values);
|
||||
},
|
||||
...props,
|
||||
});
|
||||
|
||||
return {
|
||||
...useFormProps,
|
||||
...stepsPropsSunflower,
|
||||
formLoading: useFormProps.formLoading,
|
||||
formProps: {
|
||||
...stepsPropsSunflower.formProps,
|
||||
...useFormProps.formProps,
|
||||
onValuesChange: formProps?.onValuesChange,
|
||||
onKeyUp: formProps?.onKeyUp,
|
||||
},
|
||||
saveButtonProps: {
|
||||
...useFormProps.saveButtonProps,
|
||||
onClick: () => stepsPropsSunflower.submit(),
|
||||
},
|
||||
};
|
||||
};
|
||||
77
packages/antd/src/hooks/import/index.spec.ts
Normal file
77
packages/antd/src/hooks/import/index.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { RcFile, UploadFile } from "antd/lib/upload/interface";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { notification } from "antd";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { TestWrapper, MockJSONServer, waitFor } from "@test";
|
||||
|
||||
import { useImport } from ".";
|
||||
|
||||
const file = new File(
|
||||
[
|
||||
`"id","title","createdAt","updatedAt"
|
||||
"35ad97dd-9379-480a-b6ac-6fc9c13e9224","Viral Strategist Local","2021-04-09T12:03:23.933Z","2021-04-09T12:03:23.933Z"
|
||||
"9a428977-1b03-4c3e-8cdd-1e4e2813528a","Concrete Soap Neural","2021-04-09T12:03:23.835Z","2021-04-09T12:03:23.835Z"
|
||||
"1a428977-1b03-4c3e-8cdd-1e4e281e9224","Strategist Soap Viral","2021-03-09T12:12:23.933Z","2021-03-09T12:12:23.933Z"`,
|
||||
],
|
||||
"data.csv",
|
||||
{ type: "text/csv" },
|
||||
);
|
||||
|
||||
describe("useImport hook", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const notificationOpenSpy = jest.spyOn(notification, "open");
|
||||
const notificationCloseSpy = jest.spyOn(notification, "destroy");
|
||||
|
||||
it("should return false from uploadProps.beforeUpload callback", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useImport({
|
||||
resourceName: "tests",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const beforeUploadResult = result.current.uploadProps.beforeUpload?.(
|
||||
file as unknown as RcFile,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(beforeUploadResult).toBe(false);
|
||||
});
|
||||
|
||||
it("should open notification", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useImport({
|
||||
batchSize: 1,
|
||||
resourceName: "posts",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadProps.onChange?.({
|
||||
fileList: [],
|
||||
file: file as unknown as UploadFile,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationOpenSpy).toBeCalled();
|
||||
expect(notificationCloseSpy).toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
137
packages/antd/src/hooks/import/index.tsx
Normal file
137
packages/antd/src/hooks/import/index.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import {
|
||||
type ButtonProps,
|
||||
notification,
|
||||
type UploadProps,
|
||||
Progress,
|
||||
} from "antd";
|
||||
import {
|
||||
useTranslate,
|
||||
useResource,
|
||||
type BaseRecord,
|
||||
type HttpError,
|
||||
useImport as useImportCore,
|
||||
type UseImportReturnType,
|
||||
type ImportOptions,
|
||||
pickNotDeprecated,
|
||||
} from "@refinedev/core";
|
||||
|
||||
/**
|
||||
* `useImport` hook allows you to handle your csv import logic easily.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/import/useImport} for more details.
|
||||
*
|
||||
* @typeParam TItem - Interface of parsed csv data
|
||||
* @typeParam TData - Result data of the query extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences/#httperror `HttpError`}
|
||||
* @typeParam TVariables - Values for mutation function
|
||||
*
|
||||
*/
|
||||
export const useImport = <
|
||||
TItem = any,
|
||||
TData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = any,
|
||||
>({
|
||||
resource: resourceFromProp,
|
||||
resourceName,
|
||||
mapData = (item) => item as unknown as TVariables,
|
||||
paparseOptions,
|
||||
batchSize = Number.MAX_SAFE_INTEGER,
|
||||
onFinish,
|
||||
meta,
|
||||
metaData,
|
||||
dataProviderName,
|
||||
onProgress: onProgressFromProp,
|
||||
}: ImportOptions<TItem, TVariables, TData> = {}): Omit<
|
||||
UseImportReturnType<TData, TVariables, TError>,
|
||||
"handleChange" | "inputProps"
|
||||
> & {
|
||||
uploadProps: UploadProps;
|
||||
buttonProps: ButtonProps;
|
||||
} => {
|
||||
const t = useTranslate();
|
||||
|
||||
const { resource } = useResource(resourceFromProp ?? resourceName);
|
||||
|
||||
const { mutationResult, isLoading, handleChange } = useImportCore<
|
||||
TItem,
|
||||
TData,
|
||||
TError,
|
||||
TVariables
|
||||
>({
|
||||
resource: resource?.identifier ?? resource?.name,
|
||||
mapData,
|
||||
paparseOptions,
|
||||
batchSize,
|
||||
meta: pickNotDeprecated(meta, metaData),
|
||||
metaData: pickNotDeprecated(meta, metaData),
|
||||
dataProviderName,
|
||||
onFinish,
|
||||
onProgress:
|
||||
onProgressFromProp ??
|
||||
(({ totalAmount, processedAmount }) => {
|
||||
if (totalAmount > 0 && processedAmount > 0) {
|
||||
const description = (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "-7px",
|
||||
}}
|
||||
>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={Math.floor((processedAmount / totalAmount) * 100)}
|
||||
size={50}
|
||||
strokeColor="#1890ff"
|
||||
status="normal"
|
||||
/>
|
||||
<span style={{ marginLeft: 8, width: "100%" }}>
|
||||
{t(
|
||||
"notifications.importProgress",
|
||||
{
|
||||
processed: processedAmount,
|
||||
total: totalAmount,
|
||||
},
|
||||
`Importing: ${processedAmount}/${totalAmount}`,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
notification.open({
|
||||
description,
|
||||
message: null,
|
||||
key: `${resource}-import`,
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
if (processedAmount >= totalAmount) {
|
||||
}
|
||||
|
||||
if (processedAmount === totalAmount) {
|
||||
setTimeout(() => {
|
||||
notification.destroy(`${resource}-import`);
|
||||
}, 4500);
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
uploadProps: {
|
||||
onChange: handleChange,
|
||||
beforeUpload: () => false,
|
||||
showUploadList: false,
|
||||
accept: ".csv",
|
||||
},
|
||||
buttonProps: {
|
||||
type: "default",
|
||||
loading: isLoading,
|
||||
},
|
||||
mutationResult,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
9
packages/antd/src/hooks/index.ts
Normal file
9
packages/antd/src/hooks/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./form";
|
||||
export * from "./table";
|
||||
export * from "./fields";
|
||||
export * from "./import";
|
||||
export * from "./list";
|
||||
export * from "./useFileUploadState";
|
||||
export * from "./modal";
|
||||
export * from "./useSiderVisible";
|
||||
export * from "./useThemedLayoutContext";
|
||||
1
packages/antd/src/hooks/list/index.ts
Normal file
1
packages/antd/src/hooks/list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./useSimpleList";
|
||||
1
packages/antd/src/hooks/list/useSimpleList/index.ts
Normal file
1
packages/antd/src/hooks/list/useSimpleList/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useSimpleList } from "./useSimpleList";
|
||||
191
packages/antd/src/hooks/list/useSimpleList/useSimpleList.spec.ts
Normal file
191
packages/antd/src/hooks/list/useSimpleList/useSimpleList.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { MockJSONServer, TestWrapper } from "@test";
|
||||
|
||||
import { useSimpleList } from "./useSimpleList";
|
||||
|
||||
const defaultPagination = {
|
||||
pageSize: 10,
|
||||
current: 1,
|
||||
total: 2,
|
||||
};
|
||||
|
||||
const routerProvider = {
|
||||
parse: () => {
|
||||
return () => ({
|
||||
resource: {
|
||||
name: "posts",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
describe("useSimpleList Hook", () => {
|
||||
it("default", async () => {
|
||||
const { result } = renderHook(() => useSimpleList(), {
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
routerProvider,
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.listProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
const {
|
||||
listProps: { pagination, dataSource },
|
||||
} = result.current;
|
||||
|
||||
expect(dataSource).toHaveLength(2);
|
||||
expect(pagination).toEqual({
|
||||
...defaultPagination,
|
||||
onChange: (pagination as any).onChange,
|
||||
itemRender: (pagination as any).itemRender,
|
||||
simple: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("with initial pagination parameters", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSimpleList({
|
||||
pagination: {
|
||||
current: 2,
|
||||
pageSize: 1,
|
||||
},
|
||||
initialCurrent: 10,
|
||||
initialPageSize: 20,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.listProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(result.current.listProps.pagination).toEqual(
|
||||
expect.objectContaining({
|
||||
pageSize: 1,
|
||||
current: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("with disabled pagination", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSimpleList({
|
||||
hasPagination: false,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.listProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
const {
|
||||
listProps: { pagination },
|
||||
} = result.current;
|
||||
|
||||
expect(pagination).toBe(false);
|
||||
});
|
||||
|
||||
it("with custom resource", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSimpleList({
|
||||
resource: "categories",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }, { name: "categories" }],
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.listProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
const {
|
||||
listProps: { dataSource },
|
||||
} = result.current;
|
||||
|
||||
expect(dataSource).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["client", "server"] as const)(
|
||||
"when pagination mode is %s, should set pagination props",
|
||||
async (mode) => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSimpleList({
|
||||
pagination: {
|
||||
mode,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.listProps.pagination).toEqual(
|
||||
expect.objectContaining({
|
||||
pageSize: 10,
|
||||
current: 1,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("when pagination mode is off, pagination should be false", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSimpleList({
|
||||
pagination: {
|
||||
mode: "off",
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.listProps.pagination).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should work with query and queryResult", async () => {
|
||||
const { result } = renderHook(() => useSimpleList(), {
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
routerProvider,
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.query.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(result.current.query).toEqual(result.current.queryResult);
|
||||
});
|
||||
});
|
||||
232
packages/antd/src/hooks/list/useSimpleList/useSimpleList.ts
Normal file
232
packages/antd/src/hooks/list/useSimpleList/useSimpleList.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Children, createElement, Fragment } from "react";
|
||||
import { type ListProps, type FormProps, Form, Grid } from "antd";
|
||||
|
||||
import {
|
||||
type BaseRecord,
|
||||
type CrudFilters,
|
||||
type HttpError,
|
||||
useTable as useTableCore,
|
||||
type useTableProps as useTablePropsCore,
|
||||
type useTableReturnType,
|
||||
pickNotDeprecated,
|
||||
} from "@refinedev/core";
|
||||
import { useLiveMode } from "@refinedev/core";
|
||||
import { PaginationLink } from "@hooks/table/useTable/paginationLink";
|
||||
import type { PaginationConfig } from "antd/lib/pagination";
|
||||
|
||||
export type useSimpleListProps<TQueryFnData, TError, TSearchVariables, TData> =
|
||||
useTablePropsCore<TQueryFnData, TError, TData> & {
|
||||
onSearch?: (data: TSearchVariables) => CrudFilters | Promise<CrudFilters>;
|
||||
};
|
||||
|
||||
export type useSimpleListReturnType<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TSearchVariables = unknown,
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
> = Omit<useTableReturnType<TData>, "tableQueryResult" | "tableQuery"> & {
|
||||
listProps: ListProps<TData>;
|
||||
/**
|
||||
* @deprecated Use `query` instead
|
||||
*/
|
||||
queryResult: useTableReturnType["tableQueryResult"];
|
||||
query: useTableReturnType["tableQuery"];
|
||||
searchFormProps: FormProps<TSearchVariables>;
|
||||
};
|
||||
|
||||
/**
|
||||
* By using `useSimpleList` you get props for your records from API in accordance with Ant Design {@link https://ant.design/components/list/ `<List>`} component.
|
||||
* All features such as pagination, sorting come out of the box.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/list/useSimpleList} for more details.
|
||||
*
|
||||
* @typeParam TQueryFnData - Result data returned by the query function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#httperror `HttpError`}
|
||||
* @typeParam TSearchVariables - Antd form values
|
||||
* @typeParam TData - Result data returned by the `select` function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}. Defaults to `TQueryFnData`
|
||||
*
|
||||
*/
|
||||
|
||||
export const useSimpleList = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TSearchVariables = unknown,
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
>({
|
||||
resource,
|
||||
initialCurrent,
|
||||
initialPageSize,
|
||||
pagination,
|
||||
hasPagination = true,
|
||||
initialSorter,
|
||||
permanentSorter,
|
||||
initialFilter,
|
||||
permanentFilter,
|
||||
defaultSetFilterBehavior,
|
||||
filters: filtersFromProp,
|
||||
sorters: sortersFromProp,
|
||||
onSearch,
|
||||
queryOptions,
|
||||
syncWithLocation,
|
||||
successNotification,
|
||||
errorNotification,
|
||||
liveMode: liveModeFromProp,
|
||||
onLiveEvent,
|
||||
liveParams,
|
||||
meta,
|
||||
metaData,
|
||||
dataProviderName,
|
||||
}: useSimpleListProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TSearchVariables,
|
||||
TData
|
||||
> = {}): useSimpleListReturnType<TData, TSearchVariables> => {
|
||||
const {
|
||||
sorters,
|
||||
sorter,
|
||||
filters,
|
||||
current,
|
||||
pageSize,
|
||||
pageCount,
|
||||
setFilters,
|
||||
setCurrent,
|
||||
setPageSize,
|
||||
setSorter,
|
||||
setSorters,
|
||||
createLinkForSyncWithLocation,
|
||||
tableQueryResult: queryResult,
|
||||
tableQuery: query,
|
||||
overtime,
|
||||
} = useTableCore({
|
||||
resource,
|
||||
initialSorter,
|
||||
permanentSorter,
|
||||
initialFilter,
|
||||
permanentFilter,
|
||||
filters: filtersFromProp,
|
||||
sorters: sortersFromProp,
|
||||
defaultSetFilterBehavior,
|
||||
initialCurrent,
|
||||
initialPageSize,
|
||||
queryOptions,
|
||||
successNotification,
|
||||
errorNotification,
|
||||
liveMode: liveModeFromProp,
|
||||
onLiveEvent,
|
||||
liveParams,
|
||||
meta: pickNotDeprecated(meta, metaData),
|
||||
metaData: pickNotDeprecated(meta, metaData),
|
||||
syncWithLocation,
|
||||
dataProviderName,
|
||||
pagination,
|
||||
hasPagination,
|
||||
});
|
||||
|
||||
const hasPaginationString = hasPagination === false ? "off" : "server";
|
||||
const isPaginationEnabled =
|
||||
(pagination?.mode ?? hasPaginationString) !== "off";
|
||||
|
||||
const breakpoint = Grid.useBreakpoint();
|
||||
|
||||
const liveMode = useLiveMode(liveModeFromProp);
|
||||
|
||||
const [form] = Form.useForm<TSearchVariables>();
|
||||
|
||||
const { data, isFetched, isLoading } = queryResult;
|
||||
|
||||
const onChange = (page: number, pageSize?: number): void => {
|
||||
if (isPaginationEnabled) {
|
||||
setCurrent(page);
|
||||
setPageSize(pageSize || 10);
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values: TSearchVariables) => {
|
||||
if (onSearch) {
|
||||
const searchFilters = await onSearch(values);
|
||||
if (isPaginationEnabled) {
|
||||
setCurrent?.(1);
|
||||
}
|
||||
return setFilters(searchFilters);
|
||||
}
|
||||
};
|
||||
|
||||
const antdPagination = (): false | PaginationConfig => {
|
||||
if (isPaginationEnabled) {
|
||||
return {
|
||||
itemRender: (page, type, element) => {
|
||||
const link = createLinkForSyncWithLocation({
|
||||
pagination: {
|
||||
pageSize,
|
||||
current: page,
|
||||
},
|
||||
sorters,
|
||||
filters,
|
||||
});
|
||||
|
||||
if (type === "page") {
|
||||
return createElement(PaginationLink, {
|
||||
to: link,
|
||||
element: `${page}`,
|
||||
});
|
||||
}
|
||||
if (type === "next" || type === "prev") {
|
||||
return createElement(PaginationLink, {
|
||||
to: link,
|
||||
element: element,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "jump-next" || type === "jump-prev") {
|
||||
const elementChildren = (element as React.ReactElement)?.props
|
||||
?.children;
|
||||
|
||||
return createElement(PaginationLink, {
|
||||
to: link,
|
||||
element:
|
||||
Children.count(elementChildren) > 1
|
||||
? createElement(Fragment, {}, elementChildren)
|
||||
: elementChildren,
|
||||
});
|
||||
}
|
||||
|
||||
return element;
|
||||
},
|
||||
pageSize,
|
||||
current,
|
||||
simple: !breakpoint.sm,
|
||||
total: data?.total,
|
||||
onChange,
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
searchFormProps: {
|
||||
form,
|
||||
onFinish,
|
||||
},
|
||||
listProps: {
|
||||
dataSource: data?.data,
|
||||
loading: liveMode === "auto" ? isLoading : !isFetched,
|
||||
pagination: antdPagination(),
|
||||
},
|
||||
query,
|
||||
queryResult,
|
||||
filters,
|
||||
setFilters,
|
||||
sorter,
|
||||
setSorter,
|
||||
sorters,
|
||||
setSorters,
|
||||
current,
|
||||
setCurrent,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
pageCount,
|
||||
createLinkForSyncWithLocation,
|
||||
overtime,
|
||||
};
|
||||
};
|
||||
1
packages/antd/src/hooks/modal/index.ts
Normal file
1
packages/antd/src/hooks/modal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./useModal";
|
||||
132
packages/antd/src/hooks/modal/useModal/index.spec.ts
Normal file
132
packages/antd/src/hooks/modal/useModal/index.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
|
||||
import { TestWrapper } from "@test";
|
||||
|
||||
import { useModal } from ".";
|
||||
|
||||
const Wrapper = TestWrapper({});
|
||||
|
||||
describe("useModal Hook", () => {
|
||||
it("should visible false on init", async () => {
|
||||
const { result } = renderHook(() => useModal(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const { modalProps } = result.current;
|
||||
|
||||
expect(modalProps.open).toEqual(false);
|
||||
});
|
||||
|
||||
it("should visible true on pass visible true with prop", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModal({
|
||||
modalProps: {
|
||||
open: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
const { modalProps } = result.current;
|
||||
|
||||
expect(modalProps.visible).toEqual(true);
|
||||
});
|
||||
|
||||
it("should visible true on called show", async () => {
|
||||
const { result } = renderHook(() => useModal(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const { show } = result.current;
|
||||
|
||||
act(() => {
|
||||
show();
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.visible).toEqual(true);
|
||||
});
|
||||
|
||||
it("should visible false on called show after close", async () => {
|
||||
const { result } = renderHook(() => useModal(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const { show, close } = result.current;
|
||||
|
||||
act(() => {
|
||||
show();
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.visible).toEqual(true);
|
||||
|
||||
act(() => {
|
||||
close();
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.visible).toEqual(false);
|
||||
});
|
||||
|
||||
it("should call close on modal onCancel", async () => {
|
||||
const mockedOnClose = jest.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useModal({
|
||||
modalProps: {
|
||||
onCancel: mockedOnClose,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
const { show, modalProps } = result.current;
|
||||
|
||||
act(() => {
|
||||
show();
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.visible).toEqual(true);
|
||||
|
||||
act(() => {
|
||||
modalProps.onCancel?.(
|
||||
new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}) as any,
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.open).toEqual(false);
|
||||
expect(mockedOnClose).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call close if modalProps onCancel is undefined", async () => {
|
||||
const { result } = renderHook(() => useModal(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const { show, modalProps } = result.current;
|
||||
|
||||
act(() => {
|
||||
show();
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.visible).toEqual(true);
|
||||
|
||||
act(() => {
|
||||
modalProps.onCancel?.(
|
||||
new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}) as any,
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.modalProps.open).toEqual(false);
|
||||
});
|
||||
});
|
||||
43
packages/antd/src/hooks/modal/useModal/index.tsx
Normal file
43
packages/antd/src/hooks/modal/useModal/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ModalProps } from "antd";
|
||||
import {
|
||||
useModal as useCoreModal,
|
||||
type useModalReturnType as useCoreModelReturnType,
|
||||
} from "@refinedev/core";
|
||||
|
||||
export type useModalReturnType = {
|
||||
modalProps: ModalProps;
|
||||
} & Omit<useCoreModelReturnType, "visible">;
|
||||
|
||||
export type useModalProps = {
|
||||
/**
|
||||
* Default props for Ant Design {@link https://ant.design/components/modal/ `<Modal>`} component.
|
||||
*/
|
||||
modalProps?: ModalProps;
|
||||
};
|
||||
|
||||
/**
|
||||
* By using `useModal` you get props for your records from API in accordance with Ant Design {@link https://ant.design/components/modal/ `<Modal>`} component.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/ui/useModal} for more details.
|
||||
*/
|
||||
export const useModal = ({
|
||||
modalProps = {},
|
||||
}: useModalProps = {}): useModalReturnType => {
|
||||
const { show, close, visible } = useCoreModal({
|
||||
defaultVisible: modalProps.open,
|
||||
});
|
||||
|
||||
return {
|
||||
modalProps: {
|
||||
...modalProps,
|
||||
onCancel: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
modalProps.onCancel?.(e);
|
||||
close();
|
||||
},
|
||||
open: visible,
|
||||
visible,
|
||||
},
|
||||
show,
|
||||
close,
|
||||
};
|
||||
};
|
||||
2
packages/antd/src/hooks/table/index.ts
Normal file
2
packages/antd/src/hooks/table/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./useTable";
|
||||
export * from "./useEditableTable";
|
||||
1
packages/antd/src/hooks/table/useEditableTable/index.ts
Normal file
1
packages/antd/src/hooks/table/useEditableTable/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useEditableTable } from "./useEditableTable";
|
||||
@@ -0,0 +1,146 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { TestWrapper } from "@test";
|
||||
import { posts } from "@test/dataMocks";
|
||||
|
||||
import { useEditableTable } from "./useEditableTable";
|
||||
import { act } from "react-dom/test-utils";
|
||||
|
||||
const routerProvider = {
|
||||
parse: () => {
|
||||
return () => ({
|
||||
resource: {
|
||||
name: "posts",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
describe("useEditableTable Hook", () => {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, "error").mockImplementation(jest.fn());
|
||||
});
|
||||
it("fetches table and form data", async () => {
|
||||
const { result } = renderHook(() => useEditableTable(), {
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.tableProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
const {
|
||||
tableProps: { dataSource },
|
||||
} = result.current;
|
||||
|
||||
expect(dataSource).toHaveLength(2);
|
||||
|
||||
const examplePost = posts[0];
|
||||
|
||||
act(() => {
|
||||
result.current.editButtonProps(examplePost.id).onClick();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.formLoading).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(result.current.formProps.initialValues?.title).toEqual(
|
||||
examplePost.title,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["success", "fail"] as const)(
|
||||
"should set ot not set ID to undefined after submit is %p",
|
||||
async (scenario) => {
|
||||
const { result } = renderHook(() => useEditableTable(), {
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
});
|
||||
if (scenario === "fail") {
|
||||
jest
|
||||
.spyOn(result.current.formProps, "onFinish")
|
||||
.mockImplementation(() => new Error("Error"));
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.tableProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
const examplePost = posts[0];
|
||||
|
||||
act(() => {
|
||||
result.current.editButtonProps(examplePost.id).onClick();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.formLoading).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.id).toBe(examplePost.id);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.formProps?.onFinish?.(examplePost);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.formLoading).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.id).toBe(
|
||||
scenario === "success" ? undefined : examplePost.id,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("should not set id to `undefined` when `autoSubmitClose` is false", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useEditableTable({
|
||||
autoSubmitClose: false,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.tableProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
const examplePost = posts[0];
|
||||
|
||||
act(() => {
|
||||
result.current.editButtonProps(examplePost.id).onClick();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.formLoading).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.id).toBe(examplePost.id);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.formProps?.onFinish?.(examplePost);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.formLoading).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.id).toBe(examplePost.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useTable } from "@hooks";
|
||||
import type {
|
||||
BaseKey,
|
||||
BaseRecord,
|
||||
HttpError,
|
||||
UseFormProps,
|
||||
} from "@refinedev/core";
|
||||
import type { ButtonProps } from "antd";
|
||||
|
||||
import type { useTableProps, useTableReturnType } from "../useTable";
|
||||
import { type UseFormReturnType, useForm } from "../../form/useForm";
|
||||
|
||||
export type useEditableTableReturnType<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TSearchVariables = unknown,
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
> = useTableReturnType<TData, TError, TSearchVariables> &
|
||||
UseFormReturnType<TQueryFnData, TError, TVariables> & {
|
||||
saveButtonProps: ButtonProps & {
|
||||
onClick: () => void;
|
||||
};
|
||||
cancelButtonProps: ButtonProps & {
|
||||
onClick: () => void;
|
||||
};
|
||||
editButtonProps: (id: BaseKey) => ButtonProps & {
|
||||
onClick: () => void;
|
||||
};
|
||||
isEditing: (id: BaseKey) => boolean;
|
||||
};
|
||||
|
||||
type useEditableTableProps<
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TSearchVariables = unknown,
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
> = Omit<
|
||||
useTableProps<TQueryFnData, TError, TSearchVariables, TData>,
|
||||
"successNotification" | "errorNotification"
|
||||
> &
|
||||
UseFormProps<TQueryFnData, TError, TVariables> & {
|
||||
/**
|
||||
* When true, row will be closed after successful submit.
|
||||
*/
|
||||
autoSubmitClose?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* `useEditeableTable` allows you to implement edit feature on the table with ease,
|
||||
* on top of all the features that {@link https://refine.dev/docs/api-reference/core/hooks/useTable/ `useTable`} provides.
|
||||
* `useEditableTable` return properties that can be used on Ant Design's {@link https://ant.design/components/table/ `<Table>`}
|
||||
* and {@link https://ant.design/components/form/ `<Form>`} components.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/table/useTable/} for more details.
|
||||
*
|
||||
* @typeParam TQueryFnData - Result data returned by the query function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#httperror `HttpError`}
|
||||
* @typeParam TVariables - Values for params
|
||||
* @typeParam TSearchVariables - Values for search params
|
||||
* @typeParam TData - Result data returned by the `select` function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}. Defaults to `TQueryFnData`
|
||||
*
|
||||
*/
|
||||
export const useEditableTable = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
TSearchVariables = unknown,
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
>({
|
||||
autoSubmitClose = true,
|
||||
...props
|
||||
}: useEditableTableProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TSearchVariables,
|
||||
TData
|
||||
> = {}): useEditableTableReturnType<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TVariables,
|
||||
TSearchVariables,
|
||||
TData
|
||||
> => {
|
||||
const table = useTable<TQueryFnData, TError, TSearchVariables, TData>({
|
||||
...props,
|
||||
successNotification: undefined,
|
||||
errorNotification: undefined,
|
||||
});
|
||||
const edit = useForm<TQueryFnData, TError, TVariables>({
|
||||
...props,
|
||||
action: "edit",
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
const { id: editId, setId, saveButtonProps } = edit;
|
||||
|
||||
const cancelButtonProps = {
|
||||
onClick: () => {
|
||||
setId(undefined);
|
||||
},
|
||||
};
|
||||
|
||||
const editButtonProps = (id: BaseKey) => {
|
||||
return {
|
||||
onClick: () => setId(id),
|
||||
};
|
||||
};
|
||||
|
||||
const isEditing = (id: BaseKey) => id === editId;
|
||||
|
||||
return {
|
||||
...table,
|
||||
...edit,
|
||||
formProps: {
|
||||
...edit.formProps,
|
||||
onFinish: async (values) => {
|
||||
const result = await edit.onFinish(values);
|
||||
if (autoSubmitClose) {
|
||||
setId(undefined);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
saveButtonProps,
|
||||
cancelButtonProps,
|
||||
editButtonProps,
|
||||
isEditing,
|
||||
};
|
||||
};
|
||||
1
packages/antd/src/hooks/table/useTable/index.ts
Normal file
1
packages/antd/src/hooks/table/useTable/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useTable, useTableProps, useTableReturnType } from "./useTable";
|
||||
27
packages/antd/src/hooks/table/useTable/paginationLink.tsx
Normal file
27
packages/antd/src/hooks/table/useTable/paginationLink.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useLink, useRouterContext, useRouterType } from "@refinedev/core";
|
||||
import React, { type ReactNode } from "react";
|
||||
|
||||
interface PaginationLinkProps {
|
||||
to: string;
|
||||
element: ReactNode;
|
||||
}
|
||||
|
||||
export const PaginationLink = ({ to, element }: PaginationLinkProps) => {
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
const routerType = useRouterType();
|
||||
const Link = useLink();
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
return (
|
||||
<ActiveLink
|
||||
to={to}
|
||||
replace={false}
|
||||
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
</ActiveLink>
|
||||
);
|
||||
};
|
||||
358
packages/antd/src/hooks/table/useTable/useTable.spec.tsx
Normal file
358
packages/antd/src/hooks/table/useTable/useTable.spec.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import React from "react";
|
||||
import type { CrudFilters } from "@refinedev/core";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { Form, Input } from "antd";
|
||||
import { act, TestWrapper, render, MockJSONServer } from "@test";
|
||||
|
||||
import { useTable } from "./useTable";
|
||||
|
||||
const defaultPagination = {
|
||||
pageSize: 10,
|
||||
current: 1,
|
||||
total: 2,
|
||||
simple: true,
|
||||
position: ["bottomCenter"],
|
||||
};
|
||||
|
||||
const customPagination = {
|
||||
current: 2,
|
||||
pageSize: 1,
|
||||
total: 2,
|
||||
simple: true,
|
||||
position: ["bottomCenter"],
|
||||
};
|
||||
|
||||
const routerProvider = {
|
||||
parse: () => {
|
||||
return () => ({
|
||||
resource: {
|
||||
name: "posts",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
describe("useTable Hook", () => {
|
||||
it("default", async () => {
|
||||
const { result } = renderHook(() => useTable(), {
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.tableProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
const {
|
||||
tableProps: { pagination, dataSource },
|
||||
} = result.current;
|
||||
|
||||
expect(dataSource).toHaveLength(2);
|
||||
expect(pagination).toEqual(expect.objectContaining(defaultPagination));
|
||||
});
|
||||
|
||||
it("with initial pagination parameters", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTable({
|
||||
pagination: {
|
||||
current: customPagination.current,
|
||||
pageSize: customPagination.pageSize,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.tableProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
const {
|
||||
tableProps: { pagination },
|
||||
} = result.current;
|
||||
|
||||
expect(pagination).toEqual(expect.objectContaining(customPagination));
|
||||
});
|
||||
|
||||
it("with custom resource", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTable({
|
||||
resource: "categories",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.tableProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
const {
|
||||
tableProps: { dataSource },
|
||||
} = result.current;
|
||||
|
||||
expect(dataSource).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("with syncWithLocation", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTable({
|
||||
resource: "categories",
|
||||
syncWithLocation: true,
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(!result.current.tableProps.loading).toBeTruthy();
|
||||
});
|
||||
|
||||
const {
|
||||
tableProps: { dataSource },
|
||||
} = result.current;
|
||||
|
||||
expect(dataSource).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should success data with resource", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTable({
|
||||
resource: "categories",
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tableQueryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should set filters manually with `setFilters`", async () => {
|
||||
const initialFilter: CrudFilters = [
|
||||
{
|
||||
field: "name",
|
||||
operator: "contains",
|
||||
value: "test",
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTable({
|
||||
resource: "categories",
|
||||
filters: {
|
||||
initial: initialFilter,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const nextFilters: CrudFilters = [
|
||||
{
|
||||
field: "name",
|
||||
operator: "contains",
|
||||
value: "x",
|
||||
},
|
||||
{
|
||||
field: "id",
|
||||
operator: "gte",
|
||||
value: 1,
|
||||
},
|
||||
];
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tableQueryResult.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.setFilters(nextFilters);
|
||||
});
|
||||
|
||||
// TODO: update tests
|
||||
await waitFor(() => {
|
||||
return isEqual(result.current.filters, [...nextFilters]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should change behavior to `replace` when `defaultSetFilterBehavior="replace"`', async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTable({
|
||||
resource: "categories",
|
||||
filters: {
|
||||
initial: [
|
||||
{
|
||||
field: "name",
|
||||
operator: "eq",
|
||||
value: "test",
|
||||
},
|
||||
{
|
||||
field: "id",
|
||||
operator: "gte",
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
defaultBehavior: "replace",
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const newFilters: CrudFilters = [
|
||||
{
|
||||
field: "name",
|
||||
operator: "eq",
|
||||
value: "next-test",
|
||||
},
|
||||
{
|
||||
field: "other-field",
|
||||
operator: "contains",
|
||||
value: "other",
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
result.current.setFilters(newFilters);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
return isEqual(result.current.filters, newFilters);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["client", "server"] as const)(
|
||||
"when pagination mode is %s, should set pagination props",
|
||||
async (mode) => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTable({
|
||||
pagination: {
|
||||
mode,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.tableProps.pagination).toEqual(
|
||||
expect.objectContaining({
|
||||
pageSize: 10,
|
||||
current: 1,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("when pagination mode is off, pagination should be false", async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTable({
|
||||
pagination: {
|
||||
mode: "off",
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.tableProps.pagination).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should pass form values to search form from params (syncWithLocation)", async () => {
|
||||
const Component = () => {
|
||||
const { searchFormProps } = useTable({
|
||||
resource: "categories",
|
||||
syncWithLocation: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...searchFormProps}>
|
||||
<Form.Item name="name" noStyle>
|
||||
<Input
|
||||
data-test-id="search-name"
|
||||
size="large"
|
||||
placeholder="Search by name"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByDisplayValue } = render(<Component />, {
|
||||
wrapper: TestWrapper({
|
||||
routerProvider: {
|
||||
parse: () => {
|
||||
return () => ({
|
||||
resource: {
|
||||
name: "posts",
|
||||
},
|
||||
params: {
|
||||
filters: [
|
||||
{
|
||||
field: "name",
|
||||
operator: "contains",
|
||||
value: "Some Name To Look For",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByDisplayValue("Some Name To Look For")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with query and queryResult", async () => {
|
||||
const { result } = renderHook(() => useTable(), {
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
routerProvider,
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tableQuery.isSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(result.current.tableQuery).toEqual(result.current.tableQueryResult);
|
||||
});
|
||||
});
|
||||
298
packages/antd/src/hooks/table/useTable/useTable.ts
Normal file
298
packages/antd/src/hooks/table/useTable/useTable.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import React, { Children, createElement, Fragment } from "react";
|
||||
import {
|
||||
Grid,
|
||||
type FormProps,
|
||||
Form,
|
||||
type TablePaginationConfig,
|
||||
type TableProps,
|
||||
} from "antd";
|
||||
import { useForm as useFormSF } from "sunflower-antd";
|
||||
|
||||
import {
|
||||
useLiveMode,
|
||||
type BaseRecord,
|
||||
type CrudFilters,
|
||||
type HttpError,
|
||||
useTable as useTableCore,
|
||||
type useTableProps as useTablePropsCore,
|
||||
type useTableReturnType as useTableCoreReturnType,
|
||||
pickNotDeprecated,
|
||||
useSyncWithLocation,
|
||||
} from "@refinedev/core";
|
||||
|
||||
import {
|
||||
mapAntdSorterToCrudSorting,
|
||||
mapAntdFilterToCrudFilter,
|
||||
} from "@definitions/table";
|
||||
import { PaginationLink } from "./paginationLink";
|
||||
import type { FilterValue, SorterResult } from "../../../definitions/table";
|
||||
|
||||
export type useTableProps<TQueryFnData, TError, TSearchVariables, TData> =
|
||||
useTablePropsCore<TQueryFnData, TError, TData> & {
|
||||
onSearch?: (data: TSearchVariables) => CrudFilters | Promise<CrudFilters>;
|
||||
};
|
||||
|
||||
export type useTableReturnType<
|
||||
TData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TSearchVariables = unknown,
|
||||
> = useTableCoreReturnType<TData, TError> & {
|
||||
searchFormProps: FormProps<TSearchVariables>;
|
||||
tableProps: TableProps<TData>;
|
||||
};
|
||||
|
||||
/**
|
||||
* By using useTable, you are able to get properties that are compatible with
|
||||
* Ant Design {@link https://ant.design/components/table/ `<Table>`} component.
|
||||
* All features such as sorting, filtering and pagination comes as out of box.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/antd/hooks/table/useTable/} for more details.
|
||||
*
|
||||
* @typeParam TQueryFnData - Result data returned by the query function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
|
||||
* @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#httperror `HttpError`}
|
||||
* @typeParam TSearchVariables - Values for search params
|
||||
* @typeParam TData - Result data returned by the `select` function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}. Defaults to `TQueryFnData`
|
||||
*
|
||||
*/
|
||||
|
||||
export const useTable = <
|
||||
TQueryFnData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TSearchVariables = unknown,
|
||||
TData extends BaseRecord = TQueryFnData,
|
||||
>({
|
||||
onSearch,
|
||||
initialCurrent,
|
||||
initialPageSize,
|
||||
hasPagination = true,
|
||||
pagination,
|
||||
initialSorter,
|
||||
permanentSorter,
|
||||
initialFilter,
|
||||
permanentFilter,
|
||||
defaultSetFilterBehavior,
|
||||
filters: filtersFromProp,
|
||||
sorters: sortersFromProp,
|
||||
syncWithLocation,
|
||||
resource,
|
||||
successNotification,
|
||||
errorNotification,
|
||||
queryOptions,
|
||||
liveMode: liveModeFromProp,
|
||||
onLiveEvent,
|
||||
liveParams,
|
||||
meta,
|
||||
metaData,
|
||||
dataProviderName,
|
||||
}: useTableProps<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TSearchVariables,
|
||||
TData
|
||||
> = {}): useTableReturnType<TData, TError, TSearchVariables> => {
|
||||
const {
|
||||
tableQueryResult,
|
||||
tableQuery,
|
||||
current,
|
||||
setCurrent,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilters,
|
||||
sorters,
|
||||
setSorters,
|
||||
sorter,
|
||||
setSorter,
|
||||
createLinkForSyncWithLocation,
|
||||
pageCount,
|
||||
overtime,
|
||||
} = useTableCore<TQueryFnData, TError, TData>({
|
||||
permanentSorter,
|
||||
permanentFilter,
|
||||
initialCurrent,
|
||||
initialPageSize,
|
||||
pagination,
|
||||
hasPagination,
|
||||
filters: filtersFromProp,
|
||||
sorters: sortersFromProp,
|
||||
initialSorter,
|
||||
initialFilter,
|
||||
syncWithLocation,
|
||||
resource,
|
||||
defaultSetFilterBehavior,
|
||||
successNotification,
|
||||
errorNotification,
|
||||
queryOptions,
|
||||
liveMode: liveModeFromProp,
|
||||
onLiveEvent,
|
||||
liveParams,
|
||||
meta: pickNotDeprecated(meta, metaData),
|
||||
metaData: pickNotDeprecated(meta, metaData),
|
||||
dataProviderName,
|
||||
});
|
||||
const { syncWithLocation: defaultSyncWithLocation } = useSyncWithLocation();
|
||||
const shouldSyncWithLocation = syncWithLocation ?? defaultSyncWithLocation;
|
||||
const breakpoint = Grid.useBreakpoint();
|
||||
const [form] = Form.useForm<TSearchVariables>();
|
||||
const formSF = useFormSF<any, TSearchVariables>({
|
||||
form: form,
|
||||
});
|
||||
const liveMode = useLiveMode(liveModeFromProp);
|
||||
|
||||
const hasPaginationString = hasPagination === false ? "off" : "server";
|
||||
const isPaginationEnabled =
|
||||
(pagination?.mode ?? hasPaginationString) !== "off";
|
||||
|
||||
const preferredInitialFilters = pickNotDeprecated(
|
||||
filtersFromProp?.initial,
|
||||
initialFilter,
|
||||
);
|
||||
|
||||
const { data, isFetched, isLoading } = tableQueryResult;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldSyncWithLocation) {
|
||||
// get registered fields of form
|
||||
const registeredFields = formSF.form.getFieldsValue() as Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
// map `filters` for registered fields
|
||||
const filterFilterMap = Object.keys(registeredFields).reduce(
|
||||
(acc, curr) => {
|
||||
// find filter for current field
|
||||
const filter = filters.find(
|
||||
(filter) => "field" in filter && filter.field === curr,
|
||||
);
|
||||
// if filter exists, set value to filter value
|
||||
if (filter) {
|
||||
acc[curr] = filter?.value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
// set values to form
|
||||
formSF.form.setFieldsValue(filterFilterMap as any);
|
||||
}
|
||||
}, [shouldSyncWithLocation]);
|
||||
|
||||
const onChange = (
|
||||
paginationState: TablePaginationConfig,
|
||||
tableFilters: Record<string, FilterValue | null>,
|
||||
sorter: SorterResult | SorterResult[],
|
||||
) => {
|
||||
if (tableFilters && Object.keys(tableFilters).length > 0) {
|
||||
// Map Antd:Filter -> refine:CrudFilter
|
||||
const crudFilters = mapAntdFilterToCrudFilter(
|
||||
tableFilters,
|
||||
filters,
|
||||
preferredInitialFilters,
|
||||
);
|
||||
setFilters(crudFilters);
|
||||
}
|
||||
|
||||
if (sorter && Object.keys(sorter).length > 0) {
|
||||
// Map Antd:Sorter -> refine:CrudSorting
|
||||
const crudSorting = mapAntdSorterToCrudSorting(sorter);
|
||||
setSorters(crudSorting);
|
||||
}
|
||||
|
||||
if (isPaginationEnabled) {
|
||||
setCurrent?.(paginationState.current || 1);
|
||||
setPageSize?.(paginationState.pageSize || 10);
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (value: TSearchVariables) => {
|
||||
if (onSearch) {
|
||||
const searchFilters = await onSearch(value);
|
||||
setFilters(searchFilters);
|
||||
|
||||
if (isPaginationEnabled) {
|
||||
setCurrent?.(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const antdPagination = (): false | TablePaginationConfig => {
|
||||
if (isPaginationEnabled) {
|
||||
return {
|
||||
itemRender: (page, type, element) => {
|
||||
const link = createLinkForSyncWithLocation({
|
||||
pagination: {
|
||||
pageSize,
|
||||
current: page,
|
||||
},
|
||||
sorters,
|
||||
filters,
|
||||
});
|
||||
|
||||
if (type === "page") {
|
||||
return createElement(PaginationLink, {
|
||||
to: link,
|
||||
element: `${page}`,
|
||||
});
|
||||
}
|
||||
if (type === "next" || type === "prev") {
|
||||
return createElement(PaginationLink, {
|
||||
to: link,
|
||||
element: element,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "jump-next" || type === "jump-prev") {
|
||||
const elementChildren = (element as React.ReactElement)?.props
|
||||
?.children;
|
||||
|
||||
return createElement(PaginationLink, {
|
||||
to: link,
|
||||
element:
|
||||
Children.count(elementChildren) > 1
|
||||
? createElement(Fragment, {}, elementChildren)
|
||||
: elementChildren,
|
||||
});
|
||||
}
|
||||
|
||||
return element;
|
||||
},
|
||||
pageSize,
|
||||
current,
|
||||
simple: !breakpoint.sm,
|
||||
position: !breakpoint.sm ? ["bottomCenter"] : ["bottomRight"],
|
||||
total: data?.total,
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
searchFormProps: {
|
||||
...formSF.formProps,
|
||||
onFinish,
|
||||
},
|
||||
tableProps: {
|
||||
dataSource: data?.data,
|
||||
loading: liveMode === "auto" ? isLoading : !isFetched,
|
||||
onChange,
|
||||
pagination: antdPagination(),
|
||||
scroll: { x: true },
|
||||
},
|
||||
tableQueryResult,
|
||||
tableQuery,
|
||||
sorters,
|
||||
sorter,
|
||||
filters,
|
||||
setSorters,
|
||||
setSorter,
|
||||
setFilters,
|
||||
current,
|
||||
setCurrent,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
pageCount,
|
||||
createLinkForSyncWithLocation,
|
||||
overtime,
|
||||
};
|
||||
};
|
||||
47
packages/antd/src/hooks/useFileUploadState/index.spec.ts
Normal file
47
packages/antd/src/hooks/useFileUploadState/index.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
import { MockJSONServer, TestWrapper, act } from "@test";
|
||||
|
||||
import { useFileUploadState } from "./index";
|
||||
|
||||
describe("useFileUploadState Hook", () => {
|
||||
it("isLoading false", async () => {
|
||||
const { result } = renderHook(() => useFileUploadState(), {
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
}),
|
||||
});
|
||||
|
||||
const { isLoading } = result.current;
|
||||
|
||||
expect(isLoading).toEqual(false);
|
||||
});
|
||||
|
||||
it("onChange and isLoading true", async () => {
|
||||
const { result } = renderHook(() => useFileUploadState(), {
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onChange({
|
||||
file: {
|
||||
uid: "1",
|
||||
name: "aa",
|
||||
},
|
||||
fileList: [
|
||||
{
|
||||
uid: "1",
|
||||
name: "test",
|
||||
status: "uploading",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toEqual(true);
|
||||
});
|
||||
});
|
||||
34
packages/antd/src/hooks/useFileUploadState/index.ts
Normal file
34
packages/antd/src/hooks/useFileUploadState/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { UploadChangeParam } from "antd/lib/upload";
|
||||
|
||||
export type UseFileUploadStateType = () => {
|
||||
isLoading: boolean;
|
||||
onChange: (info: UploadChangeParam) => void;
|
||||
};
|
||||
|
||||
export const useFileUploadState: UseFileUploadStateType = () => {
|
||||
const [isLoading, setIsloading] = useState(false);
|
||||
|
||||
const onChange = useCallback((info: UploadChangeParam) => {
|
||||
const fileListLoadings = mapStatusToLoading(info.fileList);
|
||||
|
||||
if (fileListLoadings.includes(true)) {
|
||||
setIsloading(true);
|
||||
} else {
|
||||
setIsloading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({ isLoading, onChange }), [isLoading]);
|
||||
};
|
||||
|
||||
const mapStatusToLoading = (files: UploadChangeParam["fileList"]) => {
|
||||
return files.map((file) => {
|
||||
switch (file.status) {
|
||||
case "uploading":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
29
packages/antd/src/hooks/useSiderVisible/index.ts
Normal file
29
packages/antd/src/hooks/useSiderVisible/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { ThemedLayoutContext } from "@contexts";
|
||||
|
||||
export type UseSiderVisibleType = {
|
||||
siderVisible: boolean;
|
||||
drawerSiderVisible: boolean;
|
||||
setSiderVisible: (visible: boolean) => void;
|
||||
setDrawerSiderVisible: (visible: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Please use `useThemedLayoutContext` instead.
|
||||
*/
|
||||
export const useSiderVisible = (): UseSiderVisibleType => {
|
||||
const {
|
||||
mobileSiderOpen,
|
||||
siderCollapsed,
|
||||
setMobileSiderOpen,
|
||||
setSiderCollapsed,
|
||||
} = useContext(ThemedLayoutContext);
|
||||
|
||||
return {
|
||||
siderVisible: mobileSiderOpen,
|
||||
setSiderVisible: setMobileSiderOpen,
|
||||
drawerSiderVisible: siderCollapsed,
|
||||
setDrawerSiderVisible: setSiderCollapsed,
|
||||
};
|
||||
};
|
||||
22
packages/antd/src/hooks/useThemedLayoutContext/index.ts
Normal file
22
packages/antd/src/hooks/useThemedLayoutContext/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { ThemedLayoutContext } from "@contexts";
|
||||
import type { IThemedLayoutContext } from "@contexts/themedLayoutContext/IThemedLayoutContext";
|
||||
|
||||
export type UseThemedLayoutContextType = IThemedLayoutContext;
|
||||
|
||||
export const useThemedLayoutContext = (): UseThemedLayoutContextType => {
|
||||
const {
|
||||
mobileSiderOpen,
|
||||
siderCollapsed,
|
||||
setMobileSiderOpen,
|
||||
setSiderCollapsed,
|
||||
} = useContext(ThemedLayoutContext);
|
||||
|
||||
return {
|
||||
mobileSiderOpen,
|
||||
siderCollapsed,
|
||||
setMobileSiderOpen,
|
||||
setSiderCollapsed,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user