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

View File

@@ -0,0 +1,3 @@
export * from "./useSelect";
export * from "./useCheckboxGroup";
export * from "./useRadioGroup";

View 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();
});
});

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

View 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);
});
});

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

View 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);
});
});

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

View 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";

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

View File

@@ -0,0 +1,6 @@
export {
useDrawerForm,
UseDrawerFormProps,
UseDrawerFormConfig,
UseDrawerFormReturnType,
} from "./useDrawerForm";

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

View 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);
});
});

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

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

View File

@@ -0,0 +1,5 @@
export {
useModalForm,
UseModalFormProps,
UseModalFormReturnType,
} from "./useModalForm";

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

View File

@@ -0,0 +1,5 @@
export {
useStepsForm,
UseStepsFormProps,
UseStepsFormReturnType,
} from "./useStepsForm";

View 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();
});
});
});

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

View 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();
});
});
});

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

View 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";

View File

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

View File

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

View 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);
});
});

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

View File

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

View 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);
});
});

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

View File

@@ -0,0 +1,2 @@
export * from "./useTable";
export * from "./useEditableTable";

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { useTable, useTableProps, useTableReturnType } from "./useTable";

View 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>
);
};

View 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);
});
});

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

View 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);
});
});

View 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;
}
});
};

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

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