mirror of
https://github.com/stefanpejcic/openpanel
synced 2025-06-26 18:28:26 +00:00
packages
This commit is contained in:
609
packages/core/src/components/authenticated/index.spec.tsx
Normal file
609
packages/core/src/components/authenticated/index.spec.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import React from "react";
|
||||
|
||||
import { act, waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockJSONServer,
|
||||
TestWrapper,
|
||||
mockLegacyRouterProvider,
|
||||
render,
|
||||
} from "@test";
|
||||
|
||||
import type { AuthProvider } from "../../contexts/auth/types";
|
||||
import type { LegacyRouterProvider } from "../../contexts/router/legacy/types";
|
||||
import { Authenticated } from "./";
|
||||
|
||||
const legacyMockAuthProvider = {
|
||||
login: () => Promise.resolve(),
|
||||
logout: () => Promise.resolve(),
|
||||
checkError: () => Promise.resolve(),
|
||||
checkAuth: () => Promise.resolve(),
|
||||
getPermissions: () => Promise.resolve(["admin"]),
|
||||
getUserIdentity: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
const mockReplace = jest.fn();
|
||||
|
||||
const mockLegacyRouter: LegacyRouterProvider = {
|
||||
...mockLegacyRouterProvider(),
|
||||
useHistory: () => ({
|
||||
goBack: jest.fn(),
|
||||
push: jest.fn(),
|
||||
replace: mockReplace,
|
||||
}),
|
||||
useLocation: () => ({
|
||||
pathname: "/posts",
|
||||
search: "",
|
||||
}),
|
||||
};
|
||||
|
||||
const mockAuthProvider: AuthProvider = {
|
||||
login: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
}),
|
||||
logout: () => Promise.resolve({ success: true }),
|
||||
onError: () => Promise.resolve({}),
|
||||
check: () => Promise.resolve({ authenticated: true }),
|
||||
getPermissions: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
describe("v3LegacyAuthProviderCompatible Authenticated", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "error").mockImplementation((message) => {
|
||||
if (typeof message !== "undefined") console.warn(message);
|
||||
});
|
||||
});
|
||||
|
||||
it("should render children successfully", async () => {
|
||||
const { getByText } = render(
|
||||
<Authenticated
|
||||
key="should-render-children-legacy"
|
||||
v3LegacyAuthProviderCompatible={true}
|
||||
>
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
legacyAuthProvider: legacyMockAuthProvider,
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => getByText("Custom Authenticated"));
|
||||
});
|
||||
|
||||
it("not authenticated test", async () => {
|
||||
const { queryByText } = render(
|
||||
<Authenticated
|
||||
key="not-authenticated-legacy"
|
||||
v3LegacyAuthProviderCompatible={true}
|
||||
>
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
legacyAuthProvider: {
|
||||
...legacyMockAuthProvider,
|
||||
checkAuth: () => Promise.reject(),
|
||||
},
|
||||
legacyRouterProvider: mockLegacyRouter,
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText("Custom Authenticated")).toBeNull();
|
||||
expect(mockReplace).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("not authenticated fallback component test", async () => {
|
||||
legacyMockAuthProvider.checkAuth = jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.reject());
|
||||
|
||||
const { queryByText } = render(
|
||||
<Authenticated
|
||||
key="fallback-component-test"
|
||||
fallback={<div>Error fallback</div>}
|
||||
v3LegacyAuthProviderCompatible={true}
|
||||
>
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
legacyAuthProvider: legacyMockAuthProvider,
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("Error fallback"));
|
||||
});
|
||||
});
|
||||
|
||||
it("loading test", async () => {
|
||||
legacyMockAuthProvider.checkAuth = jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.reject());
|
||||
|
||||
const { queryByText } = render(
|
||||
<Authenticated
|
||||
key="loading-test"
|
||||
loading={<div>loading</div>}
|
||||
v3LegacyAuthProviderCompatible={true}
|
||||
>
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
legacyAuthProvider: legacyMockAuthProvider,
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("loading"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authenticated", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "error").mockImplementation((message) => {
|
||||
if (typeof message !== "undefined") console.warn(message);
|
||||
});
|
||||
});
|
||||
|
||||
it("should render children successfully", async () => {
|
||||
const { getByText } = render(
|
||||
<Authenticated key="render-children-successfully">
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: mockAuthProvider,
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => getByText("Custom Authenticated"));
|
||||
});
|
||||
|
||||
it("not authenticated test", async () => {
|
||||
const { queryByText } = render(
|
||||
<Authenticated key="not-authenticated-test">
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
check: () => Promise.resolve({ authenticated: false }),
|
||||
},
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
legacyRouterProvider: mockLegacyRouter,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText("Custom Authenticated")).toBeNull();
|
||||
expect(mockReplace).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("not authenticated fallback component test", async () => {
|
||||
mockAuthProvider.check = jest.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
authenticated: false,
|
||||
error: new Error("Not authenticated"),
|
||||
}),
|
||||
);
|
||||
|
||||
const { queryByText } = render(
|
||||
<Authenticated
|
||||
key="not-authenticated-fallback-component-test"
|
||||
fallback={<div>Error fallback</div>}
|
||||
>
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: mockAuthProvider,
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("Error fallback"));
|
||||
});
|
||||
});
|
||||
|
||||
it("loading test", async () => {
|
||||
const { queryByText } = render(
|
||||
<Authenticated key="loading-test" loading={<div>loading</div>}>
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: mockAuthProvider,
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("loading"));
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to `/my-path` if not authenticated (authProvider's check)", async () => {
|
||||
const mockGo = jest.fn();
|
||||
|
||||
const { queryByText } = render(
|
||||
<Authenticated key="should-redirect-custom-provider-check">
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
check: async () => {
|
||||
return {
|
||||
authenticated: false,
|
||||
redirectTo: "/my-path",
|
||||
};
|
||||
},
|
||||
},
|
||||
routerProvider: {
|
||||
go: () => mockGo,
|
||||
},
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("Custom Authenticated")).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockGo).toBeCalledWith(
|
||||
expect.objectContaining({ to: "/my-path", type: "replace" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should redirect to `/my-path` if not authenticated (`redirectOnFail` prop)", async () => {
|
||||
const mockGo = jest.fn();
|
||||
|
||||
const { queryByText } = render(
|
||||
<Authenticated
|
||||
key="should-redirect-custom-on-fail"
|
||||
redirectOnFail="/my-path"
|
||||
>
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
check: async () => {
|
||||
return {
|
||||
authenticated: false,
|
||||
redirectTo: "/other-path",
|
||||
};
|
||||
},
|
||||
},
|
||||
routerProvider: {
|
||||
go: () => mockGo,
|
||||
},
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("Custom Authenticated")).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockGo).toBeCalledWith(
|
||||
expect.objectContaining({ to: "/my-path", type: "replace" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should redirect to `/my-path` if not authenticated (navigate in fallback)", async () => {
|
||||
const mockGo = jest.fn();
|
||||
|
||||
const NavigateComp = ({ to }: { to: string }) => {
|
||||
React.useEffect(() => {
|
||||
mockGo({ to, type: "replace" });
|
||||
}, [to]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const { queryByText } = render(
|
||||
<Authenticated
|
||||
key="should-redirect-fallback"
|
||||
fallback={<NavigateComp to="/my-path" />}
|
||||
>
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
check: async () => {
|
||||
return {
|
||||
authenticated: false,
|
||||
redirectTo: "/other-path",
|
||||
};
|
||||
},
|
||||
},
|
||||
routerProvider: {
|
||||
go: () => mockGo,
|
||||
},
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("Custom Authenticated")).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockGo).toBeCalledWith(
|
||||
expect.objectContaining({ to: "/my-path", type: "replace" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should redirect to `/my-path?to=/dashboard?current=1&pageSize=2` if not authenticated (`redirectOnFail` with append query)", async () => {
|
||||
const mockGo = jest.fn();
|
||||
|
||||
const currentQuery = {
|
||||
current: 1,
|
||||
pageSize: 2,
|
||||
};
|
||||
|
||||
const currentPathname = "dashboard";
|
||||
|
||||
const { queryByText } = render(
|
||||
<Authenticated
|
||||
key="should-redirect-with-to"
|
||||
redirectOnFail="/my-path"
|
||||
appendCurrentPathToQuery
|
||||
>
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
check: async () => {
|
||||
return {
|
||||
authenticated: false,
|
||||
redirectTo: "/other-path",
|
||||
};
|
||||
},
|
||||
},
|
||||
routerProvider: {
|
||||
go: () => {
|
||||
return (config) => {
|
||||
if (config.type === "path") {
|
||||
const params = new URLSearchParams(currentQuery as any);
|
||||
return `/${config.to}?${params.toString()}`;
|
||||
}
|
||||
return mockGo(config);
|
||||
};
|
||||
},
|
||||
parse: () => {
|
||||
return () => ({
|
||||
pathname: currentPathname,
|
||||
params: currentQuery,
|
||||
});
|
||||
},
|
||||
},
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("Custom Authenticated")).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockGo).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/my-path",
|
||||
query: {
|
||||
to: "/dashboard?current=1&pageSize=2",
|
||||
},
|
||||
type: "replace",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should redirect to `/my-path?to=/dashboard?current=1&pageSize=2` if not authenticated (authProvider's check with append query)", async () => {
|
||||
const mockGo = jest.fn();
|
||||
|
||||
const currentQuery = {
|
||||
current: 1,
|
||||
pageSize: 2,
|
||||
};
|
||||
|
||||
const currentPathname = "dashboard";
|
||||
|
||||
const { queryByText } = render(
|
||||
<Authenticated
|
||||
key="should-redirect-path-from-provider"
|
||||
appendCurrentPathToQuery
|
||||
>
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
check: async () => {
|
||||
return {
|
||||
authenticated: false,
|
||||
redirectTo: "/my-path",
|
||||
};
|
||||
},
|
||||
},
|
||||
routerProvider: {
|
||||
go: () => {
|
||||
return (config) => {
|
||||
if (config.type === "path") {
|
||||
const params = new URLSearchParams(currentQuery as any);
|
||||
return `/${config.to}?${params.toString()}`;
|
||||
}
|
||||
return mockGo(config);
|
||||
};
|
||||
},
|
||||
parse: () => {
|
||||
return () => ({
|
||||
pathname: currentPathname,
|
||||
params: currentQuery,
|
||||
});
|
||||
},
|
||||
},
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("Custom Authenticated")).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockGo).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/my-path",
|
||||
query: {
|
||||
to: "/dashboard?current=1&pageSize=2",
|
||||
},
|
||||
type: "replace",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should redirect to `/login` without `to` query if at root", async () => {
|
||||
const mockGo = jest.fn();
|
||||
|
||||
const { queryByText } = render(
|
||||
<Authenticated key="should-redirect-custom-provider-check">
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
check: async () => {
|
||||
return {
|
||||
authenticated: false,
|
||||
redirectTo: "/login",
|
||||
};
|
||||
},
|
||||
},
|
||||
routerProvider: {
|
||||
go: () => mockGo,
|
||||
},
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("Custom Authenticated")).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockGo).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/login",
|
||||
type: "replace",
|
||||
query: undefined,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should redirect to `/login?to=/dashboard` if at /dashboard route", async () => {
|
||||
const mockGo = jest.fn();
|
||||
|
||||
// Mocking first return value to simulate that user's location is at /dashboard
|
||||
mockGo.mockReturnValueOnce("/dashboard");
|
||||
|
||||
const { queryByText } = render(
|
||||
<Authenticated key="should-redirect-custom-provider-check">
|
||||
Custom Authenticated
|
||||
</Authenticated>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
check: async () => {
|
||||
return {
|
||||
authenticated: false,
|
||||
redirectTo: "/login",
|
||||
};
|
||||
},
|
||||
},
|
||||
routerProvider: {
|
||||
go: () => mockGo,
|
||||
},
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(queryByText("Custom Authenticated")).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockGo).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/login",
|
||||
type: "replace",
|
||||
query: expect.objectContaining({
|
||||
to: "/dashboard",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
224
packages/core/src/components/authenticated/index.tsx
Normal file
224
packages/core/src/components/authenticated/index.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React from "react";
|
||||
|
||||
import { useActiveAuthProvider } from "@definitions/index";
|
||||
import {
|
||||
useGo,
|
||||
useIsAuthenticated,
|
||||
useNavigation,
|
||||
useParsed,
|
||||
useRouterContext,
|
||||
useRouterType,
|
||||
} from "@hooks";
|
||||
import type { GoConfig } from "../../contexts/router/types";
|
||||
|
||||
export type AuthenticatedCommonProps = {
|
||||
/**
|
||||
* Unique key to identify the component.
|
||||
* This is required if you have multiple `Authenticated` components at the same level.
|
||||
* @required
|
||||
*/
|
||||
key: React.Key;
|
||||
/**
|
||||
* Whether to redirect user if not logged in or not.
|
||||
* If not set, user will be redirected to `redirectTo` property of the `check` function's response.
|
||||
* This behavior is only available for new auth providers.
|
||||
* Legacy auth providers will redirect to `/login` by default if this property is not set.
|
||||
* If set to a string, user will be redirected to that string.
|
||||
*
|
||||
* This property only works if `fallback` is **not set**.
|
||||
*/
|
||||
redirectOnFail?: string | true;
|
||||
/**
|
||||
* Whether to append current path to search params of the redirect url at `to` property.
|
||||
*
|
||||
* By default, `to` parameter is used by successful invocations of the `useLogin` hook.
|
||||
* If `to` present, it will be used as the redirect url after successful login.
|
||||
*/
|
||||
appendCurrentPathToQuery?: boolean;
|
||||
/**
|
||||
* Content to show if user is not logged in.
|
||||
*/
|
||||
fallback?: React.ReactNode;
|
||||
/**
|
||||
* Content to show while checking whether user is logged in or not.
|
||||
*/
|
||||
loading?: React.ReactNode;
|
||||
/**
|
||||
* Content to show if user is logged in
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export type LegacyAuthenticatedProps = {
|
||||
v3LegacyAuthProviderCompatible: true;
|
||||
} & AuthenticatedCommonProps;
|
||||
|
||||
export type AuthenticatedProps = {
|
||||
v3LegacyAuthProviderCompatible?: false;
|
||||
} & AuthenticatedCommonProps;
|
||||
|
||||
/**
|
||||
* `<Authenticated>` is the component form of {@link https://refine.dev/docs/api-reference/core/hooks/auth/useAuthenticated `useAuthenticated`}. It internally uses `useAuthenticated` to provide it's functionality.
|
||||
*
|
||||
* @requires {@link https://react.dev/learn/rendering-lists#why-does-react-need-keys `key`} prop if you have multiple components at the same level.
|
||||
* In React, components don't automatically unmount and remount with prop changes, which is generally good for performance. However, for specific cases this can cause issues like unwanted content rendering (`fallback` or `children`). To solve this, assigning unique `key` values to each instance of component is necessary, forcing React to unmount and remount the component, rather than just updating its props.
|
||||
* @example
|
||||
*```tsx
|
||||
* <Authenticated key="dashboard">
|
||||
* <h1>Dashboard Page</h1>
|
||||
* </Authenticated>
|
||||
*```
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/core/components/auth/authenticated `<Authenticated>`} component for more details.
|
||||
*/
|
||||
export function Authenticated(
|
||||
props: LegacyAuthenticatedProps,
|
||||
): JSX.Element | null;
|
||||
|
||||
/**
|
||||
* `<Authenticated>` is the component form of {@link https://refine.dev/docs/api-reference/core/hooks/auth/useAuthenticated `useAuthenticated`}. It internally uses `useAuthenticated` to provide it's functionality.
|
||||
*
|
||||
* @requires {@link https://react.dev/learn/rendering-lists#why-does-react-need-keys `key`} prop if you have multiple components at the same level.
|
||||
* In React, components don't automatically unmount and remount with prop changes, which is generally good for performance. However, for specific cases this can cause issues like unwanted content rendering (`fallback` or `children`). To solve this, assigning unique `key` values to each instance of component is necessary, forcing React to unmount and remount the component, rather than just updating its props.
|
||||
* @example
|
||||
*```tsx
|
||||
* <Authenticated key="dashboard">
|
||||
* <h1>Dashboard Page</h1>
|
||||
* </Authenticated>
|
||||
*```
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/core/components/auth/authenticated `<Authenticated>`} component for more details.
|
||||
*/
|
||||
export function Authenticated(props: AuthenticatedProps): JSX.Element | null;
|
||||
|
||||
export function Authenticated({
|
||||
redirectOnFail = true,
|
||||
appendCurrentPathToQuery = true,
|
||||
children,
|
||||
fallback: fallbackContent,
|
||||
loading: loadingContent,
|
||||
}: AuthenticatedProps | LegacyAuthenticatedProps): JSX.Element | null {
|
||||
const activeAuthProvider = useActiveAuthProvider();
|
||||
const routerType = useRouterType();
|
||||
|
||||
const hasAuthProvider = Boolean(activeAuthProvider?.isProvided);
|
||||
const isLegacyAuth = Boolean(activeAuthProvider?.isLegacy);
|
||||
const isLegacyRouter = routerType === "legacy";
|
||||
|
||||
const parsed = useParsed();
|
||||
const go = useGo();
|
||||
const { useLocation } = useRouterContext();
|
||||
const legacyLocation = useLocation();
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isSuccess,
|
||||
data: {
|
||||
authenticated: isAuthenticatedStatus,
|
||||
redirectTo: authenticatedRedirect,
|
||||
} = {},
|
||||
} = useIsAuthenticated({
|
||||
v3LegacyAuthProviderCompatible: isLegacyAuth,
|
||||
});
|
||||
|
||||
// Authentication status
|
||||
const isAuthenticated = hasAuthProvider
|
||||
? isLegacyAuth
|
||||
? isSuccess
|
||||
: isAuthenticatedStatus
|
||||
: true;
|
||||
|
||||
// when there is no auth provider
|
||||
if (!hasAuthProvider) {
|
||||
return <>{children ?? null}</>;
|
||||
}
|
||||
|
||||
// when checking authentication status
|
||||
if (isFetching) {
|
||||
return <>{loadingContent ?? null}</>;
|
||||
}
|
||||
|
||||
// when user is authenticated return children
|
||||
if (isAuthenticated) {
|
||||
return <>{children ?? null}</>;
|
||||
}
|
||||
// when user is not authenticated redirect or render fallbackContent
|
||||
|
||||
// render fallbackContent if it is exist
|
||||
if (typeof fallbackContent !== "undefined") {
|
||||
return <>{fallbackContent ?? null}</>;
|
||||
}
|
||||
// if there is no fallbackContent, redirect page
|
||||
|
||||
// Redirect url to use. use redirectOnFail if it is set.
|
||||
// Otherwise use redirectTo property of the check function's response.
|
||||
// If both are not set, use `/login` as the default redirect url. (only for legacy auth providers)
|
||||
const appliedRedirect = isLegacyAuth
|
||||
? typeof redirectOnFail === "string"
|
||||
? redirectOnFail
|
||||
: "/login"
|
||||
: typeof redirectOnFail === "string"
|
||||
? redirectOnFail
|
||||
: (authenticatedRedirect as string | undefined);
|
||||
|
||||
// Current pathname to append to the redirect url.
|
||||
// User will be redirected to this url after successful mutation. (like login)
|
||||
const pathname = `${
|
||||
isLegacyRouter ? legacyLocation?.pathname : parsed.pathname
|
||||
}`.replace(/(\?.*|#.*)$/, "");
|
||||
// Redirect if appliedRedirect is set, otherwise return null.
|
||||
// Uses `replace` for legacy router and `go` for new router.
|
||||
if (appliedRedirect) {
|
||||
if (isLegacyRouter) {
|
||||
const toQuery = appendCurrentPathToQuery
|
||||
? `?to=${encodeURIComponent(pathname)}`
|
||||
: "";
|
||||
return <RedirectLegacy to={`${appliedRedirect}${toQuery}`} />;
|
||||
}
|
||||
|
||||
const queryToValue: string | undefined = parsed.params?.to
|
||||
? parsed.params.to
|
||||
: go({
|
||||
to: pathname,
|
||||
options: { keepQuery: true },
|
||||
type: "path",
|
||||
});
|
||||
|
||||
return (
|
||||
<Redirect
|
||||
config={{
|
||||
to: appliedRedirect,
|
||||
query:
|
||||
appendCurrentPathToQuery && (queryToValue ?? "").length > 1
|
||||
? {
|
||||
to: queryToValue,
|
||||
}
|
||||
: undefined,
|
||||
type: "replace",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const Redirect = ({ config }: { config: GoConfig }) => {
|
||||
const go = useGo();
|
||||
|
||||
React.useEffect(() => {
|
||||
go(config);
|
||||
}, [go, config]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const RedirectLegacy = ({ to }: { to: string }) => {
|
||||
const { replace } = useNavigation();
|
||||
|
||||
React.useEffect(() => {
|
||||
replace(to);
|
||||
}, [replace, to]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { render } from "@test";
|
||||
|
||||
import { AutoSaveIndicator } from "./";
|
||||
|
||||
describe("AutoSaveIndicator", () => {
|
||||
it("should render success", async () => {
|
||||
const { findByText, getByText } = render(
|
||||
<AutoSaveIndicator status="success" />,
|
||||
);
|
||||
await findByText("saved");
|
||||
getByText("saved");
|
||||
});
|
||||
|
||||
it("should render error", async () => {
|
||||
const { findByText, getByText } = render(
|
||||
<AutoSaveIndicator status="error" />,
|
||||
);
|
||||
|
||||
await findByText("auto save failure");
|
||||
getByText("auto save failure");
|
||||
});
|
||||
|
||||
it("should render idle", async () => {
|
||||
const { findByText, getByText } = render(
|
||||
<AutoSaveIndicator status="idle" />,
|
||||
);
|
||||
|
||||
await findByText("waiting for changes");
|
||||
getByText("waiting for changes");
|
||||
});
|
||||
|
||||
it("should render loading", async () => {
|
||||
const { findByText, getByText } = render(
|
||||
<AutoSaveIndicator status="loading" />,
|
||||
);
|
||||
|
||||
await findByText("saving...");
|
||||
getByText("saving...");
|
||||
});
|
||||
});
|
||||
77
packages/core/src/components/autoSaveIndicator/index.tsx
Normal file
77
packages/core/src/components/autoSaveIndicator/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
|
||||
import { useTranslate } from "@hooks/i18n";
|
||||
|
||||
import type { BaseRecord, HttpError } from "../../contexts/data/types";
|
||||
import type { AutoSaveIndicatorElements } from "../../hooks/form/types";
|
||||
import type { UseUpdateReturnType } from "../../hooks/data/useUpdate";
|
||||
|
||||
export type AutoSaveIndicatorProps<
|
||||
TData extends BaseRecord = BaseRecord,
|
||||
TError extends HttpError = HttpError,
|
||||
TVariables = {},
|
||||
> = {
|
||||
/**
|
||||
* The data returned by the update request.
|
||||
*/
|
||||
data?: UseUpdateReturnType<TData, TError, TVariables>["data"];
|
||||
/**
|
||||
* The error returned by the update request.
|
||||
*/
|
||||
error?: UseUpdateReturnType<TData, TError, TVariables>["error"];
|
||||
/**
|
||||
* The status of the update request.
|
||||
*/
|
||||
status: UseUpdateReturnType<TData, TError, TVariables>["status"];
|
||||
/**
|
||||
* The elements to display for each status.
|
||||
*/
|
||||
elements?: AutoSaveIndicatorElements;
|
||||
};
|
||||
|
||||
export const AutoSaveIndicator: React.FC<AutoSaveIndicatorProps> = ({
|
||||
status,
|
||||
elements: {
|
||||
success = (
|
||||
<Message translationKey="autoSave.success" defaultMessage="saved" />
|
||||
),
|
||||
error = (
|
||||
<Message
|
||||
translationKey="autoSave.error"
|
||||
defaultMessage="auto save failure"
|
||||
/>
|
||||
),
|
||||
loading = (
|
||||
<Message translationKey="autoSave.loading" defaultMessage="saving..." />
|
||||
),
|
||||
idle = (
|
||||
<Message
|
||||
translationKey="autoSave.idle"
|
||||
defaultMessage="waiting for changes"
|
||||
/>
|
||||
),
|
||||
} = {},
|
||||
}) => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return <>{success}</>;
|
||||
case "error":
|
||||
return <>{error}</>;
|
||||
case "loading":
|
||||
return <>{loading}</>;
|
||||
default:
|
||||
return <>{idle}</>;
|
||||
}
|
||||
};
|
||||
|
||||
const Message = ({
|
||||
translationKey,
|
||||
defaultMessage,
|
||||
}: {
|
||||
translationKey: string;
|
||||
defaultMessage: string;
|
||||
}) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return <span>{translate(translationKey, defaultMessage)}</span>;
|
||||
};
|
||||
445
packages/core/src/components/canAccess/index.spec.tsx
Normal file
445
packages/core/src/components/canAccess/index.spec.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import React from "react";
|
||||
|
||||
import { act } from "react-dom/test-utils";
|
||||
|
||||
import {
|
||||
mockLegacyRouterProvider,
|
||||
mockRouterProvider,
|
||||
render,
|
||||
TestWrapper,
|
||||
waitFor,
|
||||
} from "@test";
|
||||
|
||||
import * as UseCanHook from "../../hooks/accessControl/useCan";
|
||||
import { CanAccess } from ".";
|
||||
|
||||
describe("CanAccess Component", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render children", async () => {
|
||||
const onUnauthorized = jest.fn();
|
||||
|
||||
const { container, findByText } = render(
|
||||
<CanAccess
|
||||
action="list"
|
||||
resource="posts"
|
||||
onUnauthorized={(args) => onUnauthorized(args)}
|
||||
>
|
||||
Accessible
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: async ({ resource, action }) => {
|
||||
if (action === "list" && resource === "posts") {
|
||||
return {
|
||||
can: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { can: false };
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
await findByText("Accessible");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUnauthorized).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render children and call onUnauthorized", async () => {
|
||||
const onUnauthorized = jest.fn();
|
||||
|
||||
const { container, queryByText } = render(
|
||||
<CanAccess
|
||||
action="list"
|
||||
resource="posts"
|
||||
onUnauthorized={(args) => onUnauthorized(args)}
|
||||
>
|
||||
Accessible
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: async () => ({
|
||||
can: false,
|
||||
reason: "test",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(container).toBeTruthy();
|
||||
expect(queryByText("Accessible")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
||||
expect(onUnauthorized).toHaveBeenCalledWith({
|
||||
resource: "posts",
|
||||
action: "list",
|
||||
reason: "test",
|
||||
params: {
|
||||
id: undefined,
|
||||
resource: expect.objectContaining({
|
||||
name: "posts",
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully pass the own attirbute to its children", async () => {
|
||||
const { container, findByText } = render(
|
||||
<CanAccess action="list" resource="posts" data-id="refine">
|
||||
<p>Accessible</p>
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: async () => ({
|
||||
can: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
const el = await findByText("Accessible");
|
||||
|
||||
expect(el.closest("p")?.getAttribute("data-id"));
|
||||
});
|
||||
|
||||
it("should fallback successfully render when not accessible", async () => {
|
||||
const { container, queryByText, findByText } = render(
|
||||
<CanAccess action="list" resource="posts" fallback={<p>Access Denied</p>}>
|
||||
<p>Accessible</p>
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: async () => ({ can: false }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
expect(queryByText("Accessible")).not.toBeInTheDocument();
|
||||
await findByText("Access Denied");
|
||||
});
|
||||
|
||||
describe("when no prop is passed", () => {
|
||||
it("should work", async () => {
|
||||
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
|
||||
|
||||
const { container, queryByText, findByText } = render(
|
||||
<CanAccess fallback={<p>Access Denied</p>}>
|
||||
<p>Accessible</p>
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", list: "/posts" }],
|
||||
routerProvider: mockRouterProvider({
|
||||
resource: { name: "posts", list: "/posts" },
|
||||
action: "list",
|
||||
id: undefined,
|
||||
}),
|
||||
accessControlProvider: {
|
||||
can: async () => {
|
||||
return { can: false };
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
expect(useCanSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: "posts",
|
||||
action: "list",
|
||||
params: {
|
||||
id: undefined,
|
||||
resource: expect.objectContaining({
|
||||
list: "/posts",
|
||||
name: "posts",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(queryByText("Accessible")).not.toBeInTheDocument();
|
||||
|
||||
await findByText("Access Denied");
|
||||
});
|
||||
|
||||
test("when fallback is empty", async () => {
|
||||
const { container } = render(
|
||||
<CanAccess action="list" resource="posts">
|
||||
Accessible
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: async () => {
|
||||
return { can: false };
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(container.nodeValue).toStrictEqual(null);
|
||||
});
|
||||
|
||||
describe("When props not passed", () => {
|
||||
describe("When new router", () => {
|
||||
describe("when resource is an object", () => {
|
||||
it("should deny access", async () => {
|
||||
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
|
||||
|
||||
const { container, queryByText, findByText } = render(
|
||||
<CanAccess fallback={<p>Access Denied</p>}>
|
||||
<p>Accessible</p>
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", list: "/posts" }],
|
||||
routerProvider: mockRouterProvider({
|
||||
resource: { name: "posts", list: "/posts" },
|
||||
action: "list",
|
||||
id: undefined,
|
||||
}),
|
||||
accessControlProvider: {
|
||||
can: async () => {
|
||||
return { can: false };
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
expect(useCanSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: "posts",
|
||||
action: "list",
|
||||
params: expect.objectContaining({
|
||||
id: undefined,
|
||||
resource: expect.objectContaining({
|
||||
name: "posts",
|
||||
list: "/posts",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(queryByText("Accessible")).not.toBeInTheDocument();
|
||||
|
||||
await findByText("Access Denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when resource is a string", () => {
|
||||
describe("when pick resource is object", () => {
|
||||
it("should deny access", async () => {
|
||||
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
|
||||
|
||||
const { container, queryByText, findByText } = render(
|
||||
<CanAccess fallback={<p>Access Denied</p>}>
|
||||
<p>Accessible</p>
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [
|
||||
{ name: "posts", list: "/posts", identifier: "posts" },
|
||||
],
|
||||
routerProvider: mockRouterProvider({
|
||||
action: "list",
|
||||
id: undefined,
|
||||
resource: {
|
||||
name: "posts",
|
||||
list: "/posts",
|
||||
identifier: "posts",
|
||||
},
|
||||
}),
|
||||
accessControlProvider: {
|
||||
can: async () => {
|
||||
return { can: false };
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
expect(useCanSpy).toHaveBeenCalledWith({
|
||||
resource: "posts",
|
||||
action: "list",
|
||||
params: expect.objectContaining({
|
||||
id: undefined,
|
||||
resource: expect.objectContaining({
|
||||
name: "posts",
|
||||
list: "/posts",
|
||||
}),
|
||||
}),
|
||||
queryOptions: undefined,
|
||||
});
|
||||
|
||||
expect(queryByText("Accessible")).not.toBeInTheDocument();
|
||||
|
||||
await findByText("Access Denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when pick resource is undefined", () => {
|
||||
it("should work without resource", async () => {
|
||||
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
|
||||
|
||||
const { container, queryByText, findByText } = render(
|
||||
<CanAccess fallback={<p>Access Denied</p>}>
|
||||
<p>Accessible</p>
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider: mockRouterProvider({
|
||||
id: undefined,
|
||||
action: "list",
|
||||
resource: undefined,
|
||||
}),
|
||||
accessControlProvider: {
|
||||
can: async () => {
|
||||
return { can: false };
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
expect(useCanSpy).toHaveBeenCalledWith({
|
||||
resource: undefined,
|
||||
action: "list",
|
||||
params: expect.objectContaining({
|
||||
id: undefined,
|
||||
resource: undefined,
|
||||
}),
|
||||
queryOptions: undefined,
|
||||
});
|
||||
|
||||
expect(queryByText("Accessible")).not.toBeInTheDocument();
|
||||
|
||||
await findByText("Access Denied");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when legacy router", () => {
|
||||
it("should deny access", async () => {
|
||||
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
|
||||
|
||||
const { container, queryByText, findByText } = render(
|
||||
<CanAccess fallback={<p>Access Denied</p>}>
|
||||
<p>Accessible</p>
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: {
|
||||
...mockLegacyRouterProvider(),
|
||||
useParams: () =>
|
||||
({
|
||||
resource: "posts",
|
||||
id: undefined,
|
||||
action: "list",
|
||||
}) as any,
|
||||
},
|
||||
accessControlProvider: {
|
||||
can: async () => {
|
||||
return { can: false };
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
expect(useCanSpy).toHaveBeenCalledWith({
|
||||
resource: "posts",
|
||||
action: "list",
|
||||
params: expect.objectContaining({
|
||||
id: undefined,
|
||||
resource: expect.objectContaining({
|
||||
name: "posts",
|
||||
}),
|
||||
}),
|
||||
queryOptions: undefined,
|
||||
});
|
||||
|
||||
expect(queryByText("Accessible")).not.toBeInTheDocument();
|
||||
|
||||
await findByText("Access Denied");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should respect queryOptions from component prop", async () => {
|
||||
const onUnauthorized = jest.fn();
|
||||
|
||||
const { container, queryByText } = render(
|
||||
<CanAccess
|
||||
action="list"
|
||||
resource="posts"
|
||||
queryOptions={{ cacheTime: 10000 }}
|
||||
onUnauthorized={(args) => onUnauthorized(args)}
|
||||
>
|
||||
Accessible
|
||||
</CanAccess>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
accessControlProvider: {
|
||||
can: async () => ({
|
||||
can: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(queryByText("Accessible")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const useCanSpy = jest.spyOn(UseCanHook, "useCan");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useCanSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryOptions: expect.objectContaining({
|
||||
cacheTime: 10000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
118
packages/core/src/components/canAccess/index.tsx
Normal file
118
packages/core/src/components/canAccess/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect } from "react";
|
||||
import type { UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { useCan, useResourceParams } from "@hooks";
|
||||
|
||||
import type { CanReturnType } from "../../contexts/accessControl/types";
|
||||
import type { BaseKey } from "../../contexts/data/types";
|
||||
import type { IResourceItem, ITreeMenu } from "../../contexts/resource/types";
|
||||
|
||||
type CanParams = {
|
||||
resource?: IResourceItem & { children?: ITreeMenu[] };
|
||||
id?: BaseKey;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type OnUnauthorizedProps = {
|
||||
resource?: string;
|
||||
reason?: string;
|
||||
action: string;
|
||||
params: CanParams;
|
||||
};
|
||||
|
||||
type CanAccessBaseProps = {
|
||||
/**
|
||||
* Resource name for API data interactions
|
||||
*/
|
||||
resource?: string;
|
||||
/**
|
||||
* Intended action on resource
|
||||
*/
|
||||
action: string;
|
||||
/**
|
||||
* Parameters associated with the resource
|
||||
* @type { resource?: [IResourceItem](https://refine.dev/docs/api-reference/core/interfaceReferences/#canparams), id?: [BaseKey](https://refine.dev/docs/api-reference/core/interfaceReferences/#basekey), [key: string]: any }
|
||||
*/
|
||||
params?: CanParams;
|
||||
/**
|
||||
* Content to show if access control returns `false`
|
||||
*/
|
||||
fallback?: React.ReactNode;
|
||||
/**
|
||||
* Callback function to be called if access control returns `can: false`
|
||||
*/
|
||||
onUnauthorized?: (props: OnUnauthorizedProps) => void;
|
||||
children: React.ReactNode;
|
||||
queryOptions?: UseQueryOptions<CanReturnType>;
|
||||
};
|
||||
|
||||
type CanAccessWithoutParamsProps = {
|
||||
[key in Exclude<
|
||||
keyof CanAccessBaseProps,
|
||||
"fallback" | "children"
|
||||
>]?: undefined;
|
||||
} & {
|
||||
[key in "fallback" | "children"]?: CanAccessBaseProps[key];
|
||||
};
|
||||
|
||||
export type CanAccessProps = CanAccessBaseProps | CanAccessWithoutParamsProps;
|
||||
|
||||
export const CanAccess: React.FC<CanAccessProps> = ({
|
||||
resource: resourceFromProp,
|
||||
action: actionFromProp,
|
||||
params: paramsFromProp,
|
||||
fallback,
|
||||
onUnauthorized,
|
||||
children,
|
||||
queryOptions: componentQueryOptions,
|
||||
...rest
|
||||
}) => {
|
||||
const {
|
||||
id,
|
||||
resource,
|
||||
action: fallbackAction = "",
|
||||
} = useResourceParams({
|
||||
resource: resourceFromProp,
|
||||
id: paramsFromProp?.id,
|
||||
});
|
||||
|
||||
const action = actionFromProp ?? fallbackAction;
|
||||
|
||||
const params = paramsFromProp ?? {
|
||||
id,
|
||||
resource,
|
||||
};
|
||||
|
||||
const { data } = useCan({
|
||||
resource: resource?.name,
|
||||
action,
|
||||
params,
|
||||
queryOptions: componentQueryOptions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onUnauthorized && data?.can === false) {
|
||||
onUnauthorized({
|
||||
resource: resource?.name,
|
||||
action,
|
||||
reason: data?.reason,
|
||||
params,
|
||||
});
|
||||
}
|
||||
}, [data?.can]);
|
||||
|
||||
if (data?.can) {
|
||||
if (React.isValidElement(children)) {
|
||||
const Children = React.cloneElement(children, rest);
|
||||
return Children;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (data?.can === false) {
|
||||
return <>{fallback ?? null}</>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
1
packages/core/src/components/containers/index.ts
Normal file
1
packages/core/src/components/containers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Refine } from "./refine";
|
||||
113
packages/core/src/components/containers/refine/index.spec.tsx
Normal file
113
packages/core/src/components/containers/refine/index.spec.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
MockJSONServer,
|
||||
TestWrapper,
|
||||
mockLegacyRouterProvider,
|
||||
mockRouterProvider,
|
||||
render,
|
||||
} from "@test";
|
||||
|
||||
import { Refine } from "./index";
|
||||
|
||||
const mockAuthProvider = {
|
||||
login: (params: any) => {
|
||||
if (params.username === "admin") {
|
||||
localStorage.setItem("username", params.username);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return Promise.reject();
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem("username");
|
||||
return Promise.resolve();
|
||||
},
|
||||
checkError: () => Promise.resolve(),
|
||||
checkAuth: () =>
|
||||
localStorage.getItem("username") ? Promise.resolve() : Promise.reject(),
|
||||
getPermissions: () => Promise.resolve(["admin"]),
|
||||
getUserIdentity: () =>
|
||||
Promise.resolve({
|
||||
id: 1,
|
||||
fullName: "Jane Doe",
|
||||
avatar:
|
||||
"https://unsplash.com/photos/IWLOvomUmWU/download?force=true&w=640",
|
||||
}),
|
||||
};
|
||||
|
||||
describe("Refine Container", () => {
|
||||
it("should render without resource [legacy router provider]", async () => {
|
||||
const { getByText } = render(
|
||||
<Refine
|
||||
legacyAuthProvider={mockAuthProvider}
|
||||
dataProvider={MockJSONServer}
|
||||
legacyRouterProvider={mockLegacyRouterProvider()}
|
||||
/>,
|
||||
);
|
||||
|
||||
getByText("Welcome on board");
|
||||
});
|
||||
|
||||
it("should render correctly readyPage with ready prop [legacy router provider]", async () => {
|
||||
const readyPage = () => {
|
||||
return (
|
||||
<div data-testid="readyContainer">
|
||||
<p>readyPage rendered with ready prop</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const { getByTestId, getByText } = render(
|
||||
<Refine
|
||||
legacyAuthProvider={mockAuthProvider}
|
||||
dataProvider={MockJSONServer}
|
||||
legacyRouterProvider={mockLegacyRouterProvider()}
|
||||
ReadyPage={readyPage}
|
||||
/>,
|
||||
);
|
||||
expect(getByTestId("readyContainer")).toBeTruthy();
|
||||
getByText("readyPage rendered with ready prop");
|
||||
});
|
||||
|
||||
it("should render resource prop list page [legacy router provider]", async () => {
|
||||
const PostList = () => {
|
||||
return (
|
||||
<>
|
||||
<h1>Posts</h1>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>foo</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const { container, getByText } = render(<PostList />, {
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts" }],
|
||||
legacyRouterProvider: mockLegacyRouterProvider(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(container).toBeDefined();
|
||||
getByText("Posts");
|
||||
});
|
||||
|
||||
it("should render the children", async () => {
|
||||
const { getByTestId } = render(
|
||||
<Refine
|
||||
legacyAuthProvider={mockAuthProvider}
|
||||
dataProvider={MockJSONServer}
|
||||
routerProvider={mockRouterProvider()}
|
||||
>
|
||||
<div data-testid="children">Children</div>
|
||||
</Refine>,
|
||||
);
|
||||
|
||||
expect(getByTestId("children")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
217
packages/core/src/components/containers/refine/index.tsx
Normal file
217
packages/core/src/components/containers/refine/index.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React from "react";
|
||||
|
||||
import { useQuerySubscription } from "@refinedev/devtools-internal";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { ReadyPage as DefaultReadyPage, RouteChangeHandler } from "@components";
|
||||
import { Telemetry } from "@components/telemetry";
|
||||
import { handleRefineOptions } from "@definitions";
|
||||
import { useDeepMemo } from "@hooks/deepMemo";
|
||||
|
||||
import { AccessControlContextProvider } from "../../../contexts/accessControl";
|
||||
import { AuditLogContextProvider } from "../../../contexts/auditLog";
|
||||
import {
|
||||
AuthBindingsContextProvider,
|
||||
LegacyAuthContextProvider,
|
||||
} from "../../../contexts/auth";
|
||||
import { DataContextProvider } from "../../../contexts/data";
|
||||
import { I18nContextProvider } from "../../../contexts/i18n";
|
||||
import { LiveContextProvider } from "../../../contexts/live";
|
||||
import { NotificationContextProvider } from "../../../contexts/notification";
|
||||
import { RefineContextProvider } from "../../../contexts/refine";
|
||||
import { ResourceContextProvider } from "../../../contexts/resource";
|
||||
import { RouterContextProvider } from "../../../contexts/router";
|
||||
import { LegacyRouterContextProvider } from "../../../contexts/router/legacy";
|
||||
import { RouterPickerProvider } from "../../../contexts/router/picker";
|
||||
import { UndoableQueueContextProvider } from "../../../contexts/undoableQueue";
|
||||
import { UnsavedWarnContextProvider } from "../../../contexts/unsavedWarn";
|
||||
|
||||
import type { RefineProps } from "../../../contexts/refine/types";
|
||||
import { useRouterMisuseWarning } from "../../../hooks/router/use-router-misuse-warning/index";
|
||||
|
||||
/**
|
||||
* {@link https://refine.dev/docs/api-reference/core/components/refine-config `<Refine> component`} is the entry point of a refine app.
|
||||
* It is where the highest level of configuration of the app occurs.
|
||||
* Only a dataProvider is required to bootstrap the app. After adding a dataProvider, resources can be added as property.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/core/components/refine-config} for more details.
|
||||
*/
|
||||
export const Refine: React.FC<RefineProps> = ({
|
||||
legacyAuthProvider,
|
||||
authProvider,
|
||||
dataProvider,
|
||||
legacyRouterProvider,
|
||||
routerProvider,
|
||||
notificationProvider,
|
||||
accessControlProvider,
|
||||
auditLogProvider,
|
||||
resources,
|
||||
DashboardPage,
|
||||
ReadyPage,
|
||||
LoginPage,
|
||||
catchAll,
|
||||
children,
|
||||
liveProvider,
|
||||
i18nProvider,
|
||||
Title,
|
||||
Layout,
|
||||
Sider,
|
||||
Header,
|
||||
Footer,
|
||||
OffLayoutArea,
|
||||
onLiveEvent,
|
||||
options,
|
||||
}) => {
|
||||
const {
|
||||
optionsWithDefaults,
|
||||
disableTelemetryWithDefault,
|
||||
reactQueryWithDefaults,
|
||||
} = handleRefineOptions({
|
||||
options,
|
||||
});
|
||||
|
||||
const queryClient = useDeepMemo(() => {
|
||||
if (reactQueryWithDefaults.clientConfig instanceof QueryClient) {
|
||||
return reactQueryWithDefaults.clientConfig;
|
||||
}
|
||||
|
||||
return new QueryClient({
|
||||
...reactQueryWithDefaults.clientConfig,
|
||||
defaultOptions: {
|
||||
...reactQueryWithDefaults.clientConfig.defaultOptions,
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
keepPreviousData: true,
|
||||
...reactQueryWithDefaults.clientConfig.defaultOptions?.queries,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [reactQueryWithDefaults.clientConfig]);
|
||||
|
||||
useQuerySubscription(queryClient);
|
||||
|
||||
const useNotificationProviderValues = React.useMemo(() => {
|
||||
return typeof notificationProvider === "function"
|
||||
? notificationProvider
|
||||
: () => notificationProvider;
|
||||
}, [notificationProvider]);
|
||||
|
||||
const notificationProviderContextValues = useNotificationProviderValues();
|
||||
|
||||
/**
|
||||
* Warn our users if they are using the old way of routing in the wrong prop.
|
||||
*/
|
||||
useRouterMisuseWarning(routerProvider);
|
||||
/** */
|
||||
|
||||
/**
|
||||
* `<ReadyPage />` is only used in the legacy routing and is not used in the new routing.
|
||||
* If `legacyRouterProvider` is provided and `routerProvider` is not, we'll check for the `resources` prop to be empty.
|
||||
* If `resources` is empty, then we'll render `<ReadyPage />` component.
|
||||
*/
|
||||
if (
|
||||
legacyRouterProvider &&
|
||||
!routerProvider &&
|
||||
(resources ?? []).length === 0
|
||||
) {
|
||||
return ReadyPage ? <ReadyPage /> : <DefaultReadyPage />;
|
||||
}
|
||||
|
||||
/** Router
|
||||
*
|
||||
* Handle routing from `RouterContextProvider` and `router` prop for the brand new way
|
||||
* If `router` is not provided, then we'r checking for `routerProvider` prop
|
||||
* If `routerProvider` is provided, then `RouterContextProvider` is used
|
||||
* If none of them is provided, then `RouterContextProvider` is used because it supports undefined router
|
||||
*
|
||||
* `RouterContextProvider` is skipped whenever possible and by this way,
|
||||
* we can achieve backward compability only when its provided by user
|
||||
*
|
||||
*/
|
||||
const { RouterComponent = React.Fragment } = !routerProvider
|
||||
? legacyRouterProvider ?? {}
|
||||
: {};
|
||||
/** */
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NotificationContextProvider {...notificationProviderContextValues}>
|
||||
<LegacyAuthContextProvider
|
||||
{...(legacyAuthProvider ?? {})}
|
||||
isProvided={Boolean(legacyAuthProvider)}
|
||||
>
|
||||
<AuthBindingsContextProvider
|
||||
{...(authProvider ?? {})}
|
||||
isProvided={Boolean(authProvider)}
|
||||
>
|
||||
<DataContextProvider dataProvider={dataProvider}>
|
||||
<LiveContextProvider liveProvider={liveProvider}>
|
||||
<RouterPickerProvider
|
||||
value={
|
||||
legacyRouterProvider && !routerProvider ? "legacy" : "new"
|
||||
}
|
||||
>
|
||||
<RouterContextProvider router={routerProvider}>
|
||||
<LegacyRouterContextProvider {...legacyRouterProvider}>
|
||||
<ResourceContextProvider resources={resources ?? []}>
|
||||
<I18nContextProvider i18nProvider={i18nProvider}>
|
||||
<AccessControlContextProvider
|
||||
{...(accessControlProvider ?? {})}
|
||||
>
|
||||
<AuditLogContextProvider
|
||||
{...(auditLogProvider ?? {})}
|
||||
>
|
||||
<UndoableQueueContextProvider>
|
||||
<RefineContextProvider
|
||||
mutationMode={
|
||||
optionsWithDefaults.mutationMode
|
||||
}
|
||||
warnWhenUnsavedChanges={
|
||||
optionsWithDefaults.warnWhenUnsavedChanges
|
||||
}
|
||||
syncWithLocation={
|
||||
optionsWithDefaults.syncWithLocation
|
||||
}
|
||||
Title={Title}
|
||||
undoableTimeout={
|
||||
optionsWithDefaults.undoableTimeout
|
||||
}
|
||||
catchAll={catchAll}
|
||||
DashboardPage={DashboardPage}
|
||||
LoginPage={LoginPage}
|
||||
Layout={Layout}
|
||||
Sider={Sider}
|
||||
Footer={Footer}
|
||||
Header={Header}
|
||||
OffLayoutArea={OffLayoutArea}
|
||||
hasDashboard={!!DashboardPage}
|
||||
liveMode={optionsWithDefaults.liveMode}
|
||||
onLiveEvent={onLiveEvent}
|
||||
options={optionsWithDefaults}
|
||||
>
|
||||
<UnsavedWarnContextProvider>
|
||||
<RouterComponent>
|
||||
{children}
|
||||
{!disableTelemetryWithDefault && (
|
||||
<Telemetry />
|
||||
)}
|
||||
<RouteChangeHandler />
|
||||
</RouterComponent>
|
||||
</UnsavedWarnContextProvider>
|
||||
</RefineContextProvider>
|
||||
</UndoableQueueContextProvider>
|
||||
</AuditLogContextProvider>
|
||||
</AccessControlContextProvider>
|
||||
</I18nContextProvider>
|
||||
</ResourceContextProvider>
|
||||
</LegacyRouterContextProvider>
|
||||
</RouterContextProvider>
|
||||
</RouterPickerProvider>
|
||||
</LiveContextProvider>
|
||||
</DataContextProvider>
|
||||
</AuthBindingsContextProvider>
|
||||
</LegacyAuthContextProvider>
|
||||
</NotificationContextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
268
packages/core/src/components/gh-banner/index.tsx
Normal file
268
packages/core/src/components/gh-banner/index.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import React, { type CSSProperties, type SVGProps, useEffect } from "react";
|
||||
import { CSSRules } from "./styles";
|
||||
|
||||
const text =
|
||||
"If you find Refine useful, you can contribute to its growth by giving it a star on GitHub";
|
||||
|
||||
type Props = {
|
||||
containerStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
export const GitHubBanner = ({ containerStyle }: Props) => {
|
||||
useEffect(() => {
|
||||
const styleTag = document.createElement("style");
|
||||
document.head.appendChild(styleTag);
|
||||
CSSRules.forEach((rule) =>
|
||||
styleTag.sheet?.insertRule(rule, styleTag.sheet.cssRules.length),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="banner bg-top-announcement"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "48px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingLeft: "200px",
|
||||
width: "100%",
|
||||
maxWidth: "100vw",
|
||||
height: "100%",
|
||||
borderBottom: "1px solid #47ebeb26",
|
||||
...containerStyle,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="top-announcement-mask"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderBottom: "1px solid #47ebeb26",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "960px",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "calc(50% - 300px)",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<GlowSmall
|
||||
style={{
|
||||
animationDelay: "1.5s",
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
right: "220px",
|
||||
}}
|
||||
id={"1"}
|
||||
/>
|
||||
<GlowSmall
|
||||
style={{
|
||||
animationDelay: "1s",
|
||||
position: "absolute",
|
||||
top: "8px",
|
||||
right: "100px",
|
||||
transform: "rotate(180deg)",
|
||||
}}
|
||||
id={"2"}
|
||||
/>
|
||||
<GlowBig
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "10px",
|
||||
}}
|
||||
id={"3"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: "calc(50% - 300px)",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<GlowSmall
|
||||
style={{
|
||||
animationDelay: "2s",
|
||||
position: "absolute",
|
||||
top: "6px",
|
||||
right: "180px",
|
||||
transform: "rotate(180deg)",
|
||||
}}
|
||||
id={"4"}
|
||||
/>
|
||||
<GlowSmall
|
||||
style={{
|
||||
animationDelay: "0.5s",
|
||||
transitionDelay: "1.3s",
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
right: "40px",
|
||||
}}
|
||||
id={"5"}
|
||||
/>
|
||||
<GlowBig
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "-70px",
|
||||
}}
|
||||
id={"6"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Text text={text} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Text = ({ text }: { text: string }) => {
|
||||
return (
|
||||
<a
|
||||
className="gh-link"
|
||||
href="https://s.refine.dev/github-support"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
position: "absolute",
|
||||
height: "100%",
|
||||
padding: "0 60px",
|
||||
display: "flex",
|
||||
flexWrap: "nowrap",
|
||||
whiteSpace: "nowrap",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, rgba(31, 63, 72, 0.00) 0%, #1F3F48 10%, #1F3F48 90%, rgba(31, 63, 72, 0.00) 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
⭐️
|
||||
</span>
|
||||
<span
|
||||
className="text"
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
⭐️
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const GlowSmall = ({ style, ...props }: SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={80}
|
||||
height={40}
|
||||
fill="none"
|
||||
style={{
|
||||
opacity: 1,
|
||||
animation: "top-announcement-glow 1s ease-in-out infinite alternate",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<circle cx={40} r={40} fill={`url(#${props.id}-a)`} fillOpacity={0.5} />
|
||||
<defs>
|
||||
<radialGradient
|
||||
id={`${props.id}-a`}
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="matrix(0 40 -40 0 40 0)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#47EBEB" />
|
||||
<stop offset={1} stopColor="#47EBEB" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const GlowBig = ({ style, ...props }: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={120}
|
||||
height={48}
|
||||
fill="none"
|
||||
{...props}
|
||||
style={{
|
||||
opacity: 1,
|
||||
animation: "top-announcement-glow 1s ease-in-out infinite alternate",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<circle
|
||||
cx={60}
|
||||
cy={24}
|
||||
r={60}
|
||||
fill={`url(#${props.id}-a)`}
|
||||
fillOpacity={0.5}
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id={`${props.id}-a`}
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="matrix(0 60 -60 0 60 24)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#47EBEB" />
|
||||
<stop offset={1} stopColor="#47EBEB" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
46
packages/core/src/components/gh-banner/styles.ts
Normal file
46
packages/core/src/components/gh-banner/styles.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export const CSSRules = [
|
||||
`
|
||||
.bg-top-announcement {
|
||||
border-bottom: 1px solid rgba(71, 235, 235, 0.15);
|
||||
background: radial-gradient(
|
||||
218.19% 111.8% at 0% 0%,
|
||||
rgba(71, 235, 235, 0.1) 0%,
|
||||
rgba(71, 235, 235, 0.2) 100%
|
||||
),
|
||||
#14141f;
|
||||
}
|
||||
`,
|
||||
`
|
||||
.top-announcement-mask {
|
||||
mask-image: url(https://refine.ams3.cdn.digitaloceanspaces.com/website/static/assets/hexagon.svg);
|
||||
-webkit-mask-image: url(https://refine.ams3.cdn.digitaloceanspaces.com/website/static/assets/hexagon.svg);
|
||||
mask-repeat: repeat;
|
||||
-webkit-mask-repeat: repeat;
|
||||
background: rgba(71, 235, 235, 0.25);
|
||||
}
|
||||
`,
|
||||
`
|
||||
.banner {
|
||||
display: flex;
|
||||
@media (max-width: 1000px) {
|
||||
display: none;
|
||||
}
|
||||
}`,
|
||||
`
|
||||
.gh-link, .gh-link:hover, .gh-link:active, .gh-link:visited, .gh-link:focus {
|
||||
text-decoration: none;
|
||||
z-index: 9;
|
||||
}
|
||||
`,
|
||||
`
|
||||
@keyframes top-announcement-glow {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
11
packages/core/src/components/index.ts
Normal file
11
packages/core/src/components/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from "./pages";
|
||||
export * from "./containers";
|
||||
export * from "./undoableQueue";
|
||||
|
||||
export { LayoutWrapper } from "./layoutWrapper";
|
||||
export { Authenticated } from "./authenticated";
|
||||
export { RouteChangeHandler } from "./routeChangeHandler";
|
||||
export { CanAccess, CanAccessProps } from "./canAccess";
|
||||
export { GitHubBanner } from "./gh-banner";
|
||||
export { AutoSaveIndicator, AutoSaveIndicatorProps } from "./autoSaveIndicator";
|
||||
export { Link, LinkProps } from "./link";
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import type { LayoutProps } from "../../../contexts/refine/types";
|
||||
|
||||
export const DefaultLayout: React.FC<LayoutProps> = ({ children }) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
190
packages/core/src/components/layoutWrapper/index.spec.tsx
Normal file
190
packages/core/src/components/layoutWrapper/index.spec.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React from "react";
|
||||
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
|
||||
import { LayoutWrapper } from "@components/layoutWrapper";
|
||||
import { defaultRefineOptions } from "@contexts/refine";
|
||||
import {
|
||||
MockJSONServer,
|
||||
TestWrapper,
|
||||
mockLegacyRouterProvider,
|
||||
render,
|
||||
} from "@test";
|
||||
|
||||
import type {
|
||||
IRefineContextProvider,
|
||||
LayoutProps,
|
||||
} from "../../contexts/refine/types";
|
||||
|
||||
const renderWithRefineContext = (
|
||||
children: React.ReactNode,
|
||||
refineProvider: IRefineContextProvider,
|
||||
) => {
|
||||
return render(<>{children}</>, {
|
||||
wrapper: TestWrapper({
|
||||
dataProvider: MockJSONServer,
|
||||
resources: [{ name: "posts", route: "posts" }],
|
||||
legacyRouterProvider: mockLegacyRouterProvider(),
|
||||
refineProvider,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
describe("LayoutWrapper", () => {
|
||||
it("LayoutWrapper successfully pass the custom components to Layout component as a props", () => {
|
||||
const customSiderContent = "customSiderContent";
|
||||
const CustomSider = () => <p>{customSiderContent}</p>;
|
||||
|
||||
const customHeaderContent = "customHeaderContent";
|
||||
const CustomHeader = () => <p>{customHeaderContent}</p>;
|
||||
|
||||
const customFooterContent = "customFooterContent";
|
||||
const CustomFooter = () => <p>{customFooterContent}</p>;
|
||||
|
||||
const customOffLayoutAreaContent = "customOffLayoutAreaContent";
|
||||
const CustomOffLayoutArea = () => <p>{customOffLayoutAreaContent}</p>;
|
||||
|
||||
const customTitleContent = "customTitleContent";
|
||||
const CustomTitle = () => <p>{customTitleContent}</p>;
|
||||
|
||||
const CustomLayout: React.FC<LayoutProps> = ({
|
||||
Header,
|
||||
Sider,
|
||||
Footer,
|
||||
OffLayoutArea,
|
||||
Title,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
{Header && <Header />}
|
||||
{Title && <Title collapsed={true} />}
|
||||
{Sider && <Sider />}
|
||||
{children}
|
||||
{Footer && <Footer />}
|
||||
{OffLayoutArea && <OffLayoutArea />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = renderWithRefineContext(
|
||||
<LayoutWrapper>
|
||||
<div>test </div>
|
||||
</LayoutWrapper>,
|
||||
{
|
||||
hasDashboard: false,
|
||||
Layout: CustomLayout,
|
||||
Sider: CustomSider,
|
||||
Header: CustomHeader,
|
||||
Footer: CustomFooter,
|
||||
OffLayoutArea: CustomOffLayoutArea,
|
||||
Title: CustomTitle,
|
||||
...defaultRefineOptions,
|
||||
options: defaultRefineOptions,
|
||||
},
|
||||
);
|
||||
|
||||
getByText(customSiderContent);
|
||||
getByText(customHeaderContent);
|
||||
getByText(customFooterContent);
|
||||
getByText(customOffLayoutAreaContent);
|
||||
getByText(customTitleContent);
|
||||
});
|
||||
|
||||
it("By default, LayoutWrapper not renders the custom components", () => {
|
||||
const customSiderContent = "customSiderContent";
|
||||
const CustomSider = () => <p>{customSiderContent}</p>;
|
||||
|
||||
const customHeaderContent = "customHeaderContent";
|
||||
const CustomHeader = () => <p>{customHeaderContent}</p>;
|
||||
|
||||
const customFooterContent = "customFooterContent";
|
||||
const CustomFooter = () => <p>{customFooterContent}</p>;
|
||||
|
||||
const customOffLayoutAreaContent = "customOffLayoutAreaContent";
|
||||
const CustomOffLayoutArea = () => <p>{customOffLayoutAreaContent}</p>;
|
||||
|
||||
const customTitleContent = "customTitleContent";
|
||||
const CustomTitle = () => <p>{customTitleContent}</p>;
|
||||
|
||||
const { queryByText } = renderWithRefineContext(
|
||||
<LayoutWrapper>
|
||||
<div>test </div>
|
||||
</LayoutWrapper>,
|
||||
{
|
||||
hasDashboard: false,
|
||||
Sider: CustomSider,
|
||||
Header: CustomHeader,
|
||||
Footer: CustomFooter,
|
||||
OffLayoutArea: CustomOffLayoutArea,
|
||||
Title: CustomTitle,
|
||||
...defaultRefineOptions,
|
||||
options: defaultRefineOptions,
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByText(customSiderContent)).toBeNull();
|
||||
expect(queryByText(customHeaderContent)).toBeNull();
|
||||
expect(queryByText(customFooterContent)).toBeNull();
|
||||
expect(queryByText(customOffLayoutAreaContent)).toBeNull();
|
||||
expect(queryByText(customTitleContent)).toBeNull();
|
||||
});
|
||||
|
||||
it("LayoutWrapper renders custom layout, sider, header, footer, title, offLayoutArea if given props", () => {
|
||||
const customTitleContent = "customTitleContent";
|
||||
const CustomTitle = () => <p>{customTitleContent}</p>;
|
||||
|
||||
const CustomLayout: React.FC<LayoutProps> = ({
|
||||
Header,
|
||||
Sider,
|
||||
Footer,
|
||||
OffLayoutArea,
|
||||
children,
|
||||
}) => (
|
||||
<div>
|
||||
{Header && <Header />}
|
||||
{Sider && <Sider />}
|
||||
{children}
|
||||
<div>custom layout</div>
|
||||
{Footer && <Footer />}
|
||||
{OffLayoutArea && <OffLayoutArea />}
|
||||
</div>
|
||||
);
|
||||
|
||||
const customSiderContent = "customSiderContent";
|
||||
const CustomSider = () => <p>{customSiderContent}</p>;
|
||||
|
||||
const customHeaderContent = "customHeaderContent";
|
||||
const CustomHeader = () => <p>{customHeaderContent}</p>;
|
||||
|
||||
const customFooterContent = "customFooterContent";
|
||||
const CustomFooter = () => <p>{customFooterContent}</p>;
|
||||
|
||||
const customOffLayoutAreaContent = "customOffLayoutAreaContent";
|
||||
const CustomOffLayoutArea = () => <p>{customOffLayoutAreaContent}</p>;
|
||||
|
||||
const { getByText } = renderWithRefineContext(
|
||||
<LayoutWrapper
|
||||
Layout={CustomLayout}
|
||||
Title={CustomTitle}
|
||||
Sider={CustomSider}
|
||||
Header={CustomHeader}
|
||||
Footer={CustomFooter}
|
||||
OffLayoutArea={CustomOffLayoutArea}
|
||||
>
|
||||
<div>test</div>
|
||||
</LayoutWrapper>,
|
||||
{
|
||||
...defaultRefineOptions,
|
||||
options: defaultRefineOptions,
|
||||
hasDashboard: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText(customSiderContent));
|
||||
expect(getByText(customHeaderContent));
|
||||
expect(getByText(customFooterContent));
|
||||
expect(getByText(customOffLayoutAreaContent));
|
||||
expect(getByText("custom layout"));
|
||||
});
|
||||
});
|
||||
122
packages/core/src/components/layoutWrapper/index.tsx
Normal file
122
packages/core/src/components/layoutWrapper/index.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import {
|
||||
useRefineContext,
|
||||
useRouterContext,
|
||||
useTranslate,
|
||||
useWarnAboutChange,
|
||||
} from "@hooks";
|
||||
|
||||
import type { LayoutProps, TitleProps } from "../../contexts/refine/types";
|
||||
|
||||
export interface LayoutWrapperProps {
|
||||
/**
|
||||
* Outer component that renders other components
|
||||
* @default *
|
||||
*/
|
||||
Layout?: React.FC<LayoutProps>;
|
||||
/**
|
||||
* [Custom sider to use](/api-reference/core/components/refine-config.md#sider)
|
||||
* @default *
|
||||
*/
|
||||
Sider?: React.FC;
|
||||
/**
|
||||
* [Custom header to use](/api-reference/core/components/refine-config.md#header)
|
||||
* @default *
|
||||
*/
|
||||
Header?: React.FC;
|
||||
/**
|
||||
* [Custom title to use](/api-reference/core/components/refine-config.md#title)
|
||||
* @default *
|
||||
*/
|
||||
Title?: React.FC<TitleProps>;
|
||||
/**
|
||||
* [Custom footer to use](/api-reference/core/components/refine-config.md#footer)
|
||||
* @default *
|
||||
*/
|
||||
Footer?: React.FC;
|
||||
/**
|
||||
* [Custom off layout area to use](/api-reference/core/components/refine-config.md#offlayoutarea)
|
||||
* @default *
|
||||
*/
|
||||
OffLayoutArea?: React.FC;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<LayoutWrapper>` wraps its contents in **refine's** layout with all customizations made in {@link https://refine.dev/docs/core/components/refine-config `<Refine>`} component.
|
||||
* It is the default layout used in resource pages.
|
||||
* It can be used in custom pages to use global layout.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/core/components/layout-wrapper} for more details.
|
||||
*
|
||||
* @deprecated This component is obsolete and only works with the legacy router providers.
|
||||
*/
|
||||
export const LayoutWrapper: React.FC<LayoutWrapperProps> = ({
|
||||
children,
|
||||
Layout: LayoutFromProps,
|
||||
Sider: SiderFromProps,
|
||||
Header: HeaderFromProps,
|
||||
Title: TitleFromProps,
|
||||
Footer: FooterFromProps,
|
||||
OffLayoutArea: OffLayoutAreaFromProps,
|
||||
}) => {
|
||||
const { Layout, Footer, Header, Sider, Title, OffLayoutArea } =
|
||||
useRefineContext();
|
||||
|
||||
const LayoutToRender = LayoutFromProps ?? Layout;
|
||||
|
||||
return (
|
||||
<LayoutToRender
|
||||
Sider={SiderFromProps ?? Sider}
|
||||
Header={HeaderFromProps ?? Header}
|
||||
Footer={FooterFromProps ?? Footer}
|
||||
Title={TitleFromProps ?? Title}
|
||||
OffLayoutArea={OffLayoutAreaFromProps ?? OffLayoutArea}
|
||||
>
|
||||
{children}
|
||||
<UnsavedPrompt />
|
||||
</LayoutToRender>
|
||||
);
|
||||
};
|
||||
|
||||
const UnsavedPrompt: React.FC = () => {
|
||||
const { Prompt } = useRouterContext();
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
const { warnWhen, setWarnWhen } = useWarnAboutChange();
|
||||
|
||||
const warnWhenListener = (e: {
|
||||
preventDefault: () => void;
|
||||
returnValue: string;
|
||||
}) => {
|
||||
e.preventDefault();
|
||||
|
||||
e.returnValue = translate(
|
||||
"warnWhenUnsavedChanges",
|
||||
"Are you sure you want to leave? You have unsaved changes.",
|
||||
);
|
||||
|
||||
return e.returnValue;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (warnWhen) {
|
||||
window.addEventListener("beforeunload", warnWhenListener);
|
||||
}
|
||||
|
||||
return window.removeEventListener("beforeunload", warnWhenListener);
|
||||
}, [warnWhen]);
|
||||
|
||||
return (
|
||||
<Prompt
|
||||
when={warnWhen}
|
||||
message={translate(
|
||||
"warnWhenUnsavedChanges",
|
||||
"Are you sure you want to leave? You have unsaved changes.",
|
||||
)}
|
||||
setWarnWhen={setWarnWhen}
|
||||
/>
|
||||
);
|
||||
};
|
||||
124
packages/core/src/components/link/index.spec.tsx
Normal file
124
packages/core/src/components/link/index.spec.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from "react";
|
||||
import { TestWrapper, render } from "@test/index";
|
||||
import { Link } from "./index";
|
||||
|
||||
describe("Link", () => {
|
||||
describe("with `to`", () => {
|
||||
it("should render a tag without router provider", () => {
|
||||
const { getByText } = render(<Link to="/test">Test</Link>);
|
||||
|
||||
const link = getByText("Test");
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toBe("/test");
|
||||
});
|
||||
|
||||
it("should render a tag with router provider", () => {
|
||||
const { getByTestId } = render(
|
||||
<Link<{ foo: "bar" }> foo="bar" to="/test" aria-label="test-label">
|
||||
Test
|
||||
</Link>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider: {
|
||||
Link: ({ to, children, ...props }) => (
|
||||
<a href={to} data-testid="test-link" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const link = getByTestId("test-link");
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toBe("/test");
|
||||
expect(link.getAttribute("aria-label")).toBe("test-label");
|
||||
expect(link.getAttribute("foo")).toBe("bar");
|
||||
});
|
||||
|
||||
it("should prioritize 'to' over 'go' when both are provided", () => {
|
||||
const { getByText } = render(
|
||||
<Link to="/with-to" go={{ to: "/with-go" }}>
|
||||
Test
|
||||
</Link>,
|
||||
);
|
||||
|
||||
const link = getByText("Test");
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toBe("/with-to");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with `go`", () => {
|
||||
it("should render a tag go.to as object", () => {
|
||||
const { getByTestId } = render(
|
||||
<Link
|
||||
go={{
|
||||
to: {
|
||||
resource: "test",
|
||||
action: "show",
|
||||
id: 1,
|
||||
},
|
||||
options: { keepQuery: true },
|
||||
}}
|
||||
aria-label="test-label"
|
||||
>
|
||||
Test
|
||||
</Link>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "test", show: "/test/:id" }],
|
||||
routerProvider: {
|
||||
go: () => () => {
|
||||
return "/test/1";
|
||||
},
|
||||
Link: ({ to, children, ...props }) => (
|
||||
<a href={to} data-testid="test-link" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const link = getByTestId("test-link");
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toBe("/test/1");
|
||||
expect(link.getAttribute("aria-label")).toBe("test-label");
|
||||
});
|
||||
|
||||
it("should render a tag go.to as string", () => {
|
||||
const { getByTestId } = render(
|
||||
<Link
|
||||
go={{
|
||||
to: "/test/1",
|
||||
}}
|
||||
aria-label="test-label"
|
||||
>
|
||||
Test
|
||||
</Link>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerProvider: {
|
||||
go: () => () => {
|
||||
return "/test/1";
|
||||
},
|
||||
Link: ({ to, children, ...props }) => (
|
||||
<a href={to} data-testid="test-link" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const link = getByTestId("test-link");
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toBe("/test/1");
|
||||
expect(link.getAttribute("aria-label")).toBe("test-label");
|
||||
});
|
||||
});
|
||||
});
|
||||
72
packages/core/src/components/link/index.tsx
Normal file
72
packages/core/src/components/link/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { type Ref, forwardRef, useContext } from "react";
|
||||
import { useGo } from "@hooks/router";
|
||||
import { RouterContext } from "@contexts/router";
|
||||
import type { GoConfigWithResource } from "../../hooks/router/use-go";
|
||||
import warnOnce from "warn-once";
|
||||
|
||||
type LinkPropsWithGo = {
|
||||
go: Omit<GoConfigWithResource, "type">;
|
||||
};
|
||||
|
||||
type LinkPropsWithTo = {
|
||||
to: string;
|
||||
};
|
||||
|
||||
export type LinkProps<TProps = {}> = React.PropsWithChildren<
|
||||
(LinkPropsWithGo | LinkPropsWithTo) & TProps
|
||||
>;
|
||||
|
||||
/**
|
||||
* @param to The path to navigate to.
|
||||
* @param go The useGo.go params to navigate to. If `to` provided, this will be ignored.
|
||||
* @returns routerProvider.Link if it is provided, otherwise an anchor tag.
|
||||
*/
|
||||
const LinkComponent = <TProps = {}>(
|
||||
props: LinkProps<TProps>,
|
||||
ref: Ref<Element>,
|
||||
) => {
|
||||
const routerContext = useContext(RouterContext);
|
||||
const LinkFromContext = routerContext?.Link;
|
||||
|
||||
const goFunction = useGo();
|
||||
|
||||
let resolvedTo = "";
|
||||
if ("go" in props) {
|
||||
if (!routerContext?.go) {
|
||||
warnOnce(
|
||||
true,
|
||||
"[Link]: `routerProvider` is not found. To use `go`, Please make sure that you have provided the `routerProvider` for `<Refine />` https://refine.dev/docs/routing/router-provider/ \n",
|
||||
);
|
||||
}
|
||||
resolvedTo = goFunction({ ...props.go, type: "path" }) as string;
|
||||
}
|
||||
if ("to" in props) {
|
||||
resolvedTo = props.to;
|
||||
}
|
||||
|
||||
if (LinkFromContext) {
|
||||
return (
|
||||
<LinkFromContext
|
||||
ref={ref}
|
||||
{...props}
|
||||
to={resolvedTo}
|
||||
// This is a workaround to avoid passing `go` to the Link component.
|
||||
go={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
ref={ref}
|
||||
href={resolvedTo}
|
||||
{...props}
|
||||
// This is a workaround to avoid passing `go` and `to` to the anchor tag.
|
||||
to={undefined}
|
||||
go={undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Link = forwardRef(LinkComponent) as <T = {}>(
|
||||
props: LinkProps<T> & { ref?: Ref<Element> },
|
||||
) => ReturnType<typeof LinkComponent>;
|
||||
@@ -0,0 +1,210 @@
|
||||
import React from "react";
|
||||
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
|
||||
import { TestWrapper, mockLegacyRouterProvider } from "@test/index";
|
||||
|
||||
import { ForgotPasswordPage } from ".";
|
||||
import type { AuthProvider } from "../../../../../contexts/auth/types";
|
||||
|
||||
const mockAuthProvider: AuthProvider = {
|
||||
login: async () => ({ success: true }),
|
||||
check: async () => ({ authenticated: true }),
|
||||
onError: async () => ({}),
|
||||
logout: async () => ({ success: true }),
|
||||
};
|
||||
|
||||
describe("Auth Page Forgot Password", () => {
|
||||
it("should render card title", async () => {
|
||||
const { getByText } = render(<ForgotPasswordPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(getByText(/forgot your password?/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render email input", async () => {
|
||||
const { getByLabelText } = render(<ForgotPasswordPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should login link", async () => {
|
||||
const { getByRole } = render(<ForgotPasswordPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
getByRole("link", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render login link when is false", async () => {
|
||||
const { queryByRole } = render(<ForgotPasswordPage loginLink={false} />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
queryByRole("link", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render reset button", async () => {
|
||||
const { getByRole } = render(<ForgotPasswordPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
getByRole("button", {
|
||||
name: /send reset/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should renderContent only", async () => {
|
||||
const { queryByText, queryByTestId, queryByRole, queryByLabelText } =
|
||||
render(
|
||||
<ForgotPasswordPage
|
||||
renderContent={() => <div data-testid="custom-content" />}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByLabelText(/email/i)).not.toBeInTheDocument();
|
||||
expect(queryByText(/refine project/i)).not.toBeInTheDocument();
|
||||
expect(queryByTestId("refine-logo")).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("button", {
|
||||
name: /reset/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should customizable with renderContent", async () => {
|
||||
const { queryByText, queryByTestId, queryByRole, queryByLabelText } =
|
||||
render(
|
||||
<ForgotPasswordPage
|
||||
renderContent={(content: any, title: any) => (
|
||||
<div>
|
||||
{title}
|
||||
<div data-testid="custom-content">
|
||||
<div>Custom Content</div>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByText(/custom content/i)).toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
expect(queryByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("button", {
|
||||
name: /reset/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should run forgotPassword mutation when form is submitted", async () => {
|
||||
const forgotPasswordMock = jest.fn();
|
||||
const { getByLabelText, getByDisplayValue } = render(
|
||||
<ForgotPasswordPage />,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
forgotPassword: forgotPasswordMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getByLabelText(/email/i), {
|
||||
target: { value: "demo@refine.dev" },
|
||||
});
|
||||
|
||||
fireEvent.click(getByDisplayValue(/send reset/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(forgotPasswordMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(forgotPasswordMock).toBeCalledWith({
|
||||
email: "demo@refine.dev",
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with legacy router provider Link", async () => {
|
||||
const LinkComponentMock = jest.fn();
|
||||
|
||||
render(<ForgotPasswordPage />, {
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: {
|
||||
...mockLegacyRouterProvider(),
|
||||
Link: LinkComponentMock,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(LinkComponentMock).toBeCalledWith(
|
||||
{
|
||||
to: "/login",
|
||||
children: "Sign in",
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("should should accept 'mutationVariables'", async () => {
|
||||
const forgotPasswordMock = jest.fn().mockResolvedValue({ success: true });
|
||||
|
||||
const { getByRole, getByLabelText } = render(
|
||||
<ForgotPasswordPage
|
||||
mutationVariables={{
|
||||
foo: "bar",
|
||||
xyz: "abc",
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
forgotPassword: forgotPasswordMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getByLabelText(/email/i), {
|
||||
target: { value: "demo@refine.dev" },
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
getByRole("button", {
|
||||
name: /reset/i,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(forgotPasswordMock).toHaveBeenCalledWith({
|
||||
foo: "bar",
|
||||
xyz: "abc",
|
||||
email: "demo@refine.dev",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import {
|
||||
useForgotPassword,
|
||||
useLink,
|
||||
useRouterContext,
|
||||
useRouterType,
|
||||
useTranslate,
|
||||
} from "@hooks";
|
||||
|
||||
import type { DivPropsType, FormPropsType } from "../..";
|
||||
import type {
|
||||
ForgotPasswordFormTypes,
|
||||
ForgotPasswordPageProps,
|
||||
} from "../../types";
|
||||
|
||||
type ForgotPasswordProps = ForgotPasswordPageProps<
|
||||
DivPropsType,
|
||||
DivPropsType,
|
||||
FormPropsType
|
||||
>;
|
||||
|
||||
export const ForgotPasswordPage: React.FC<ForgotPasswordProps> = ({
|
||||
loginLink,
|
||||
wrapperProps,
|
||||
contentProps,
|
||||
renderContent,
|
||||
formProps,
|
||||
title = undefined,
|
||||
mutationVariables,
|
||||
}) => {
|
||||
const translate = useTranslate();
|
||||
const routerType = useRouterType();
|
||||
const Link = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
const { mutate: forgotPassword, isLoading } =
|
||||
useForgotPassword<ForgotPasswordFormTypes>();
|
||||
|
||||
const renderLink = (link: string, text?: string) => {
|
||||
return <ActiveLink to={link}>{text}</ActiveLink>;
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div {...contentProps}>
|
||||
<h1 style={{ textAlign: "center" }}>
|
||||
{translate("pages.forgotPassword.title", "Forgot your password?")}
|
||||
</h1>
|
||||
<hr />
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
forgotPassword({ ...mutationVariables, email });
|
||||
}}
|
||||
{...formProps}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: 25,
|
||||
}}
|
||||
>
|
||||
<label htmlFor="email-input">
|
||||
{translate("pages.forgotPassword.fields.email", "Email")}
|
||||
</label>
|
||||
<input
|
||||
id="email-input"
|
||||
name="email"
|
||||
type="mail"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
value={translate(
|
||||
"pages.forgotPassword.buttons.submit",
|
||||
"Send reset instructions",
|
||||
)}
|
||||
/>
|
||||
<br />
|
||||
{loginLink ?? (
|
||||
<span>
|
||||
{translate(
|
||||
"pages.register.buttons.haveAccount",
|
||||
"Have an account? ",
|
||||
)}{" "}
|
||||
{renderLink("/login", translate("pages.login.signin", "Sign in"))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...wrapperProps}>
|
||||
{renderContent ? renderContent(content, title) : content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./login";
|
||||
export * from "./register";
|
||||
export * from "./forgotPassword";
|
||||
export * from "./updatePassword";
|
||||
@@ -0,0 +1,394 @@
|
||||
import React from "react";
|
||||
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
|
||||
import { TestWrapper, mockLegacyRouterProvider } from "@test/index";
|
||||
|
||||
import { LoginPage } from ".";
|
||||
import type { AuthProvider } from "../../../../../contexts/auth/types";
|
||||
|
||||
const mockAuthProvider: AuthProvider = {
|
||||
login: async () => ({ success: true }),
|
||||
check: async () => ({ authenticated: true }),
|
||||
onError: async () => ({}),
|
||||
logout: async () => ({ success: true }),
|
||||
};
|
||||
|
||||
describe("Auth Page Login", () => {
|
||||
it("should render card title", async () => {
|
||||
const { getByText } = render(<LoginPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(getByText(/sign in to your account/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render card email and password input", async () => {
|
||||
const { getByLabelText } = render(<LoginPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render providers", async () => {
|
||||
const { getByText, queryByText } = render(
|
||||
<LoginPage
|
||||
providers={[
|
||||
{
|
||||
name: "Google",
|
||||
label: "Google",
|
||||
},
|
||||
{
|
||||
name: "Github",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText(/google/i)).toBeInTheDocument();
|
||||
expect(queryByText(/github/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should register link", async () => {
|
||||
const { getByRole } = render(<LoginPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
getByRole("link", {
|
||||
name: /sign up/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render register link when is false", async () => {
|
||||
const { queryByRole } = render(<LoginPage registerLink={false} />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
queryByRole("link", {
|
||||
name: /sign up/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should forgotPassword link", async () => {
|
||||
const { getByRole } = render(<LoginPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
getByRole("link", {
|
||||
name: /forgot password?/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render forgotPassword link when is false", async () => {
|
||||
const { queryByRole } = render(<LoginPage forgotPasswordLink={false} />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
queryByRole("link", {
|
||||
name: /forgot password/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render remember me", async () => {
|
||||
const { queryByRole } = render(<LoginPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
queryByRole("checkbox", {
|
||||
name: /remember me/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render remember me when is false", async () => {
|
||||
const { queryByRole } = render(<LoginPage rememberMe={false} />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
queryByRole("checkbox", {
|
||||
name: /remember me/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render sign in button", async () => {
|
||||
const { getByRole } = render(<LoginPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
getByRole("button", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should renderContent only", async () => {
|
||||
const { queryByText, queryByTestId, queryByRole, queryByLabelText } =
|
||||
render(
|
||||
<LoginPage
|
||||
renderContent={() => <div data-testid="custom-content" />}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByLabelText(/email/i)).not.toBeInTheDocument();
|
||||
expect(queryByLabelText(/password/i)).not.toBeInTheDocument();
|
||||
expect(queryByText(/refine project/i)).not.toBeInTheDocument();
|
||||
expect(queryByTestId("refine-logo")).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("button", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should customizable with renderContent", async () => {
|
||||
const { queryByText, queryByTestId, queryByRole, queryByLabelText } =
|
||||
render(
|
||||
<LoginPage
|
||||
renderContent={(content: any, title: any) => (
|
||||
<div>
|
||||
{title}
|
||||
<div data-testid="custom-content">
|
||||
<div>Custom Content</div>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByText(/custom content/i)).toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
expect(queryByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(queryByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("button", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should run login mutation when form is submitted", async () => {
|
||||
const loginMock = jest.fn();
|
||||
const { getByLabelText, getByDisplayValue } = render(<LoginPage />, {
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
login: loginMock,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/email/i), {
|
||||
target: { value: "demo@refine.dev" },
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/password/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.click(getByLabelText(/remember me/i));
|
||||
|
||||
fireEvent.click(getByDisplayValue(/sign in/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(loginMock).toBeCalledWith({
|
||||
email: "demo@refine.dev",
|
||||
password: "demo",
|
||||
remember: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with legacy router provider Link", async () => {
|
||||
const LinkComponentMock = jest.fn();
|
||||
|
||||
render(<LoginPage />, {
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: {
|
||||
...mockLegacyRouterProvider(),
|
||||
Link: LinkComponentMock,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(LinkComponentMock).toBeCalledWith(
|
||||
{
|
||||
to: "/forgot-password",
|
||||
children: "Forgot password?",
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(LinkComponentMock).toBeCalledWith(
|
||||
{
|
||||
to: "/register",
|
||||
children: "Sign up",
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("should run login mutation when provider button is clicked", async () => {
|
||||
const loginMock = jest.fn();
|
||||
const { getByText } = render(
|
||||
<LoginPage
|
||||
providers={[
|
||||
{
|
||||
name: "Google",
|
||||
label: "Google",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
login: loginMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText(/google/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByText(/google/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(loginMock).toBeCalledWith({
|
||||
providerName: "Google",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render form when `hideForm` is true", async () => {
|
||||
const { queryByLabelText, getByText, queryByRole } = render(
|
||||
<LoginPage
|
||||
hideForm
|
||||
providers={[
|
||||
{
|
||||
name: "google",
|
||||
label: "Google",
|
||||
},
|
||||
{ name: "github", label: "GitHub" },
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByLabelText(/email/i)).not.toBeInTheDocument();
|
||||
expect(queryByLabelText(/password/i)).not.toBeInTheDocument();
|
||||
expect(queryByLabelText(/remember/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("link", {
|
||||
name: /forgot password/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("button", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(getByText(/google/i)).toBeInTheDocument();
|
||||
expect(getByText(/github/i)).toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("link", {
|
||||
name: /sign up/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([true, false])("should has default links", async (hideForm) => {
|
||||
const { getByRole } = render(<LoginPage hideForm={hideForm} />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
expect(
|
||||
getByRole("link", {
|
||||
name: /sign up/i,
|
||||
}),
|
||||
).toHaveAttribute("href", "/register");
|
||||
|
||||
if (hideForm === false) {
|
||||
expect(
|
||||
getByRole("link", {
|
||||
name: /forgot password/i,
|
||||
}),
|
||||
).toHaveAttribute("href", "/forgot-password");
|
||||
}
|
||||
});
|
||||
|
||||
it("should should accept 'mutationVariables'", async () => {
|
||||
const loginMock = jest.fn().mockResolvedValue({ success: true });
|
||||
|
||||
const { getByRole, getByLabelText } = render(
|
||||
<LoginPage
|
||||
mutationVariables={{
|
||||
foo: "bar",
|
||||
xyz: "abc",
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
login: loginMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getByLabelText(/email/i), {
|
||||
target: { value: "demo@refine.dev" },
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/password/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
getByRole("button", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginMock).toHaveBeenCalledWith({
|
||||
foo: "bar",
|
||||
xyz: "abc",
|
||||
email: "demo@refine.dev",
|
||||
password: "demo",
|
||||
remember: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useActiveAuthProvider } from "@definitions/helpers";
|
||||
import {
|
||||
useLink,
|
||||
useLogin,
|
||||
useRouterContext,
|
||||
useRouterType,
|
||||
useTranslate,
|
||||
} from "@hooks";
|
||||
|
||||
import type { DivPropsType, FormPropsType } from "../..";
|
||||
import type { LoginFormTypes, LoginPageProps } from "../../types";
|
||||
|
||||
type LoginProps = LoginPageProps<DivPropsType, DivPropsType, FormPropsType>;
|
||||
|
||||
export const LoginPage: React.FC<LoginProps> = ({
|
||||
providers,
|
||||
registerLink,
|
||||
forgotPasswordLink,
|
||||
rememberMe,
|
||||
contentProps,
|
||||
wrapperProps,
|
||||
renderContent,
|
||||
formProps,
|
||||
title = undefined,
|
||||
hideForm,
|
||||
mutationVariables,
|
||||
}) => {
|
||||
const routerType = useRouterType();
|
||||
const Link = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(false);
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { mutate: login } = useLogin<LoginFormTypes>({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const renderLink = (link: string, text?: string) => {
|
||||
return <ActiveLink to={link}>{text}</ActiveLink>;
|
||||
};
|
||||
|
||||
const renderProviders = () => {
|
||||
if (providers) {
|
||||
return providers.map((provider) => (
|
||||
<div
|
||||
key={provider.name}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() =>
|
||||
login({
|
||||
...mutationVariables,
|
||||
providerName: provider.name,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{provider?.icon}
|
||||
{provider.label ?? <label>{provider.label}</label>}
|
||||
</button>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div {...contentProps}>
|
||||
<h1 style={{ textAlign: "center" }}>
|
||||
{translate("pages.login.title", "Sign in to your account")}
|
||||
</h1>
|
||||
{renderProviders()}
|
||||
{!hideForm && (
|
||||
<>
|
||||
<hr />
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
login({ ...mutationVariables, email, password, remember });
|
||||
}}
|
||||
{...formProps}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: 25,
|
||||
}}
|
||||
>
|
||||
<label htmlFor="email-input">
|
||||
{translate("pages.login.fields.email", "Email")}
|
||||
</label>
|
||||
<input
|
||||
id="email-input"
|
||||
name="email"
|
||||
type="text"
|
||||
size={20}
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="password-input">
|
||||
{translate("pages.login.fields.password", "Password")}
|
||||
</label>
|
||||
<input
|
||||
id="password-input"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
size={20}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{rememberMe ?? (
|
||||
<>
|
||||
<label htmlFor="remember-me-input">
|
||||
{translate("pages.login.buttons.rememberMe", "Remember me")}
|
||||
<input
|
||||
id="remember-me-input"
|
||||
name="remember"
|
||||
type="checkbox"
|
||||
size={20}
|
||||
checked={remember}
|
||||
value={remember.toString()}
|
||||
onChange={() => {
|
||||
setRemember(!remember);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
{forgotPasswordLink ??
|
||||
renderLink(
|
||||
"/forgot-password",
|
||||
translate(
|
||||
"pages.login.buttons.forgotPassword",
|
||||
"Forgot password?",
|
||||
),
|
||||
)}
|
||||
<input
|
||||
type="submit"
|
||||
value={translate("pages.login.signin", "Sign in")}
|
||||
/>
|
||||
{registerLink ?? (
|
||||
<span>
|
||||
{translate(
|
||||
"pages.login.buttons.noAccount",
|
||||
"Don’t have an account?",
|
||||
)}{" "}
|
||||
{renderLink(
|
||||
"/register",
|
||||
translate("pages.login.register", "Sign up"),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
{registerLink !== false && hideForm && (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
{translate("pages.login.buttons.noAccount", "Don’t have an account?")}{" "}
|
||||
{renderLink(
|
||||
"/register",
|
||||
translate("pages.login.register", "Sign up"),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...wrapperProps}>
|
||||
{renderContent ? renderContent(content, title) : content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
import React from "react";
|
||||
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
|
||||
import { TestWrapper, mockLegacyRouterProvider } from "@test/index";
|
||||
|
||||
import { RegisterPage } from ".";
|
||||
import type { AuthProvider } from "../../../../../contexts/auth/types";
|
||||
|
||||
const mockAuthProvider: AuthProvider = {
|
||||
login: async () => ({ success: true }),
|
||||
check: async () => ({ authenticated: true }),
|
||||
onError: async () => ({}),
|
||||
logout: async () => ({ success: true }),
|
||||
};
|
||||
|
||||
describe("Auth Page Register", () => {
|
||||
it("should render card title", async () => {
|
||||
const { getByText } = render(<RegisterPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(getByText(/sign up for your account/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render card email and password input", async () => {
|
||||
const { getByLabelText } = render(<RegisterPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render providers", async () => {
|
||||
const { getByText, queryByText } = render(
|
||||
<RegisterPage
|
||||
providers={[
|
||||
{
|
||||
name: "Google",
|
||||
label: "Google",
|
||||
},
|
||||
{
|
||||
name: "Github",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText(/google/i)).toBeInTheDocument();
|
||||
expect(queryByText(/github/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should login link", async () => {
|
||||
const { getByRole } = render(<RegisterPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
getByRole("link", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render login link when is false", async () => {
|
||||
const { queryByRole } = render(<RegisterPage loginLink={false} />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
queryByRole("link", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render sign up button", async () => {
|
||||
const { getByRole } = render(<RegisterPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(
|
||||
getByRole("button", {
|
||||
name: /sign up/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should renderContent only", async () => {
|
||||
const { queryByText, queryByTestId, queryByRole, queryByLabelText } =
|
||||
render(
|
||||
<RegisterPage
|
||||
renderContent={() => <div data-testid="custom-content" />}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByLabelText(/email/i)).not.toBeInTheDocument();
|
||||
expect(queryByLabelText(/password/i)).not.toBeInTheDocument();
|
||||
expect(queryByText(/refine project/i)).not.toBeInTheDocument();
|
||||
expect(queryByTestId("refine-logo")).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("button", {
|
||||
name: /sign up/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should customizable with renderContent", async () => {
|
||||
const { queryByText, queryByTestId, queryByRole, queryByLabelText } =
|
||||
render(
|
||||
<RegisterPage
|
||||
renderContent={(content: any, title: any) => (
|
||||
<div>
|
||||
{title}
|
||||
<div data-testid="custom-content">
|
||||
<div>Custom Content</div>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByText(/custom content/i)).toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
expect(queryByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(queryByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("button", {
|
||||
name: /sign up/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should run register mutation when form is submitted", async () => {
|
||||
const registerMock = jest.fn();
|
||||
const { getByLabelText, getByDisplayValue } = render(<RegisterPage />, {
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
register: registerMock,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/email/i), {
|
||||
target: { value: "demo@refine.dev" },
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/password/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.click(getByDisplayValue(/sign up/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(registerMock).toBeCalledWith({
|
||||
email: "demo@refine.dev",
|
||||
password: "demo",
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with legacy router provider Link", async () => {
|
||||
const LinkComponentMock = jest.fn();
|
||||
|
||||
render(<RegisterPage />, {
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: {
|
||||
...mockLegacyRouterProvider(),
|
||||
useLocation: jest.fn(),
|
||||
Link: LinkComponentMock,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(LinkComponentMock).toBeCalledWith(
|
||||
{
|
||||
to: "/login",
|
||||
children: "Sign in",
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("should run register mutation when provider button is clicked", async () => {
|
||||
const registerMock = jest.fn();
|
||||
const { getByText } = render(
|
||||
<RegisterPage
|
||||
providers={[
|
||||
{
|
||||
name: "Google",
|
||||
label: "Google",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
register: registerMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText(/google/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByText(/google/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(registerMock).toBeCalledWith({
|
||||
providerName: "Google",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render form when `hideForm` is true", async () => {
|
||||
const { queryByLabelText, getByText, queryByRole } = render(
|
||||
<RegisterPage
|
||||
hideForm
|
||||
providers={[
|
||||
{
|
||||
name: "google",
|
||||
label: "Google",
|
||||
},
|
||||
{ name: "github", label: "GitHub" },
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByLabelText(/email/i)).not.toBeInTheDocument();
|
||||
expect(queryByLabelText(/password/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("link", {
|
||||
name: /forgot password/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("button", {
|
||||
name: /sign up/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(getByText(/google/i)).toBeInTheDocument();
|
||||
expect(getByText(/github/i)).toBeInTheDocument();
|
||||
expect(
|
||||
queryByRole("link", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([true, false])("should has default links", async (hideForm) => {
|
||||
const { getByRole } = render(<RegisterPage hideForm={hideForm} />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
expect(
|
||||
getByRole("link", {
|
||||
name: /sign in/i,
|
||||
}),
|
||||
).toHaveAttribute("href", "/login");
|
||||
});
|
||||
|
||||
it("should should accept 'mutationVariables'", async () => {
|
||||
const registerMock = jest.fn().mockResolvedValue({ success: true });
|
||||
|
||||
const { getByRole, getByLabelText } = render(
|
||||
<RegisterPage
|
||||
mutationVariables={{
|
||||
foo: "bar",
|
||||
xyz: "abc",
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
register: registerMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getByLabelText(/email/i), {
|
||||
target: { value: "demo@refine.dev" },
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/password/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
getByRole("button", {
|
||||
name: /sign up/i,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerMock).toHaveBeenCalledWith({
|
||||
foo: "bar",
|
||||
xyz: "abc",
|
||||
email: "demo@refine.dev",
|
||||
password: "demo",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import {
|
||||
useLink,
|
||||
useRegister,
|
||||
useRouterContext,
|
||||
useRouterType,
|
||||
useTranslate,
|
||||
} from "@hooks";
|
||||
|
||||
import { useActiveAuthProvider } from "@definitions/helpers";
|
||||
|
||||
import type { DivPropsType, FormPropsType } from "../..";
|
||||
import type { RegisterPageProps } from "../../types";
|
||||
|
||||
type RegisterProps = RegisterPageProps<
|
||||
DivPropsType,
|
||||
DivPropsType,
|
||||
FormPropsType
|
||||
>;
|
||||
|
||||
export const RegisterPage: React.FC<RegisterProps> = ({
|
||||
providers,
|
||||
loginLink,
|
||||
wrapperProps,
|
||||
contentProps,
|
||||
renderContent,
|
||||
formProps,
|
||||
title = undefined,
|
||||
hideForm,
|
||||
mutationVariables,
|
||||
}) => {
|
||||
const routerType = useRouterType();
|
||||
const Link = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { mutate: register, isLoading } = useRegister({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const renderLink = (link: string, text?: string) => {
|
||||
return <ActiveLink to={link}>{text}</ActiveLink>;
|
||||
};
|
||||
|
||||
const renderProviders = () => {
|
||||
if (providers) {
|
||||
return providers.map((provider) => (
|
||||
<div
|
||||
key={provider.name}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() =>
|
||||
register({
|
||||
...mutationVariables,
|
||||
providerName: provider.name,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{provider?.icon}
|
||||
{provider.label ?? <label>{provider.label}</label>}
|
||||
</button>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div {...contentProps}>
|
||||
<h1 style={{ textAlign: "center" }}>
|
||||
{translate("pages.register.title", "Sign up for your account")}
|
||||
</h1>
|
||||
{renderProviders()}
|
||||
{!hideForm && (
|
||||
<>
|
||||
<hr />
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
register({ ...mutationVariables, email, password });
|
||||
}}
|
||||
{...formProps}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: 25,
|
||||
}}
|
||||
>
|
||||
<label htmlFor="email-input">
|
||||
{translate("pages.register.fields.email", "Email")}
|
||||
</label>
|
||||
<input
|
||||
id="email-input"
|
||||
name="email"
|
||||
type="email"
|
||||
size={20}
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="password-input">
|
||||
{translate("pages.register.fields.password", "Password")}
|
||||
</label>
|
||||
<input
|
||||
id="password-input"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
size={20}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
value={translate("pages.register.buttons.submit", "Sign up")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{loginLink ?? (
|
||||
<>
|
||||
<span>
|
||||
{translate(
|
||||
"pages.login.buttons.haveAccount",
|
||||
"Have an account?",
|
||||
)}{" "}
|
||||
{renderLink(
|
||||
"/login",
|
||||
translate("pages.login.signin", "Sign in"),
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
{loginLink !== false && hideForm && (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
{translate("pages.login.buttons.haveAccount", "Have an account?")}{" "}
|
||||
{renderLink("/login", translate("pages.login.signin", "Sign in"))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...wrapperProps}>
|
||||
{renderContent ? renderContent(content, title) : content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import React from "react";
|
||||
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
|
||||
import { TestWrapper } from "@test/index";
|
||||
|
||||
import { UpdatePasswordPage } from ".";
|
||||
import type { AuthProvider } from "../../../../../contexts/auth/types";
|
||||
|
||||
const mockAuthProvider: AuthProvider = {
|
||||
login: async () => ({ success: true }),
|
||||
check: async () => ({ authenticated: true }),
|
||||
onError: async () => ({}),
|
||||
logout: async () => ({ success: true }),
|
||||
};
|
||||
|
||||
describe("Auth Page Update Password", () => {
|
||||
it("should render card title", async () => {
|
||||
const { getByText } = render(<UpdatePasswordPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(getByText(/Update Password?/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render password input", async () => {
|
||||
const { getByLabelText } = render(<UpdatePasswordPage />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(getByLabelText("New Password")).toBeInTheDocument();
|
||||
expect(getByLabelText("Confirm New Password")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should renderContent only", async () => {
|
||||
const { queryByText, queryByTestId, queryByLabelText } = render(
|
||||
<UpdatePasswordPage
|
||||
renderContent={() => <div data-testid="custom-content" />}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByText(/refine project/i)).not.toBeInTheDocument();
|
||||
expect(queryByTestId("refine-logo")).not.toBeInTheDocument();
|
||||
expect(queryByLabelText("New Password")).not.toBeInTheDocument();
|
||||
expect(queryByLabelText("Confirm New Password")).not.toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should customizable with renderContent", async () => {
|
||||
const { queryByText, queryByTestId, getByLabelText } = render(
|
||||
<UpdatePasswordPage
|
||||
renderContent={(content: any, title: any) => (
|
||||
<div>
|
||||
{title}
|
||||
<div data-testid="custom-content">
|
||||
<div>Custom Content</div>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByText(/custom content/i)).toBeInTheDocument();
|
||||
expect(queryByTestId("custom-content")).toBeInTheDocument();
|
||||
expect(getByLabelText("New Password")).toBeInTheDocument();
|
||||
expect(getByLabelText("Confirm New Password")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should run updatePassword mutation when form is submitted", async () => {
|
||||
const updatePasswordMock = jest.fn();
|
||||
const { getAllByLabelText, getByLabelText, getByDisplayValue } = render(
|
||||
<UpdatePasswordPage />,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
updatePassword: updatePasswordMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getAllByLabelText(/password/i)[0], {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/confirm new password/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.click(getByDisplayValue(/update/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updatePasswordMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(updatePasswordMock).toBeCalledWith({
|
||||
password: "demo",
|
||||
confirmPassword: "demo",
|
||||
});
|
||||
});
|
||||
|
||||
it("should should accept 'mutationVariables'", async () => {
|
||||
const updatePasswordMock = jest.fn().mockResolvedValue({ success: true });
|
||||
|
||||
const { getByRole, getByLabelText, getAllByLabelText } = render(
|
||||
<UpdatePasswordPage
|
||||
mutationVariables={{
|
||||
foo: "bar",
|
||||
xyz: "abc",
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
updatePassword: updatePasswordMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getAllByLabelText(/password/i)[0], {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/confirm new password/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
getByRole("button", {
|
||||
name: /update/i,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(updatePasswordMock).toHaveBeenCalledWith({
|
||||
foo: "bar",
|
||||
xyz: "abc",
|
||||
password: "demo",
|
||||
confirmPassword: "demo",
|
||||
});
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useActiveAuthProvider } from "@definitions/helpers";
|
||||
import { useTranslate, useUpdatePassword } from "@hooks";
|
||||
|
||||
import type { DivPropsType, FormPropsType } from "../..";
|
||||
import type {
|
||||
UpdatePasswordFormTypes,
|
||||
UpdatePasswordPageProps,
|
||||
} from "../../types";
|
||||
|
||||
type UpdatePasswordProps = UpdatePasswordPageProps<
|
||||
DivPropsType,
|
||||
DivPropsType,
|
||||
FormPropsType
|
||||
>;
|
||||
|
||||
export const UpdatePasswordPage: React.FC<UpdatePasswordProps> = ({
|
||||
wrapperProps,
|
||||
contentProps,
|
||||
renderContent,
|
||||
formProps,
|
||||
title = undefined,
|
||||
mutationVariables,
|
||||
}) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { mutate: updatePassword, isLoading } =
|
||||
useUpdatePassword<UpdatePasswordFormTypes>({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const content = (
|
||||
<div {...contentProps}>
|
||||
<h1 style={{ textAlign: "center" }}>
|
||||
{translate("pages.updatePassword.title", "Update Password")}
|
||||
</h1>
|
||||
<hr />
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
updatePassword({
|
||||
...mutationVariables,
|
||||
password: newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
}}
|
||||
{...formProps}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: 25,
|
||||
}}
|
||||
>
|
||||
<label htmlFor="password-input">
|
||||
{translate("pages.updatePassword.fields.password", "New Password")}
|
||||
</label>
|
||||
<input
|
||||
id="password-input"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
size={20}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="confirm-password-input">
|
||||
{translate(
|
||||
"pages.updatePassword.fields.confirmPassword",
|
||||
"Confirm New Password",
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password-input"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
size={20}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
value={translate("pages.updatePassword.buttons.submit", "Update")}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...wrapperProps}>
|
||||
{renderContent ? renderContent(content, title) : content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
packages/core/src/components/pages/auth/index.spec.tsx
Normal file
31
packages/core/src/components/pages/auth/index.spec.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
import { AuthPage } from ".";
|
||||
import { TestWrapper } from "@test/index";
|
||||
|
||||
describe("Auth Page Index", () => {
|
||||
it.each(["register", "forgotPassword", "updatePassword", "login"] as const)(
|
||||
"should render %s type",
|
||||
async (type) => {
|
||||
const { getByText } = render(<AuthPage type={type} />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
switch (type) {
|
||||
case "register":
|
||||
expect(getByText(/sign up for your account/i)).toBeInTheDocument();
|
||||
break;
|
||||
case "forgotPassword":
|
||||
expect(getByText(/forgot your password?/i)).toBeInTheDocument();
|
||||
break;
|
||||
case "updatePassword":
|
||||
expect(getByText(/update password/i)).toBeInTheDocument();
|
||||
break;
|
||||
default:
|
||||
expect(getByText(/Sign in to your account/i)).toBeInTheDocument();
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
52
packages/core/src/components/pages/auth/index.tsx
Normal file
52
packages/core/src/components/pages/auth/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, {
|
||||
type DetailedHTMLProps,
|
||||
type HTMLAttributes,
|
||||
type FormHTMLAttributes,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
ForgotPasswordPage,
|
||||
LoginPage,
|
||||
RegisterPage,
|
||||
UpdatePasswordPage,
|
||||
} from "./components";
|
||||
|
||||
import type { AuthPageProps } from "./types";
|
||||
|
||||
export type DivPropsType = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
export type FormPropsType = DetailedHTMLProps<
|
||||
FormHTMLAttributes<HTMLFormElement>,
|
||||
HTMLFormElement
|
||||
>;
|
||||
|
||||
export type AuthProps = AuthPageProps<
|
||||
DivPropsType,
|
||||
DivPropsType,
|
||||
FormPropsType
|
||||
>;
|
||||
|
||||
/**
|
||||
* **refine** has a default auth page form which is served on `/login` route when the `authProvider` configuration is provided.
|
||||
* @param title is not implemented yet.
|
||||
* @see {@link https://refine.dev/docs/api-reference/core/components/auth-page/} for more details.
|
||||
*/
|
||||
export const AuthPage: React.FC<AuthProps> = (props) => {
|
||||
const { type } = props;
|
||||
const renderView = () => {
|
||||
switch (type) {
|
||||
case "register":
|
||||
return <RegisterPage {...props} />;
|
||||
case "forgotPassword":
|
||||
return <ForgotPasswordPage {...props} />;
|
||||
case "updatePassword":
|
||||
return <UpdatePasswordPage {...props} />;
|
||||
default:
|
||||
return <LoginPage {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
return <>{renderView()}</>;
|
||||
};
|
||||
236
packages/core/src/components/pages/auth/types.tsx
Normal file
236
packages/core/src/components/pages/auth/types.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React, { type PropsWithChildren } from "react";
|
||||
|
||||
export type OAuthProvider = {
|
||||
name: string;
|
||||
icon?: React.ReactNode;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export interface LoginFormTypes {
|
||||
email?: string;
|
||||
password?: string;
|
||||
remember?: boolean;
|
||||
providerName?: string;
|
||||
redirectPath?: string;
|
||||
}
|
||||
|
||||
export interface RegisterFormTypes {
|
||||
email?: string;
|
||||
password?: string;
|
||||
providerName?: string;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordFormTypes {
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePasswordFormTypes {
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This should be the base type for `AuthPage` component implementations in UI integrations.
|
||||
*/
|
||||
export type AuthPageProps<
|
||||
TWrapperProps extends {} = Record<keyof any, unknown>,
|
||||
TContentProps extends {} = Record<keyof any, unknown>,
|
||||
TFormProps extends {} = Record<keyof any, unknown>,
|
||||
> = (
|
||||
| PropsWithChildren<{
|
||||
/**
|
||||
* @description The type of the auth page.
|
||||
* @default "login"
|
||||
* @optional
|
||||
*/
|
||||
type?: "login";
|
||||
/**
|
||||
* @description Providers array for login with third party auth services.
|
||||
* @type [OAuthProvider](/docs/api-reference/core/components/auth-page/#interface)
|
||||
* @optional
|
||||
*/
|
||||
providers?: OAuthProvider[];
|
||||
/**
|
||||
* @description Render a redirect to register page button node. If set to false, register button will not be rendered.
|
||||
* @default `"/register"`
|
||||
* @optional
|
||||
*/
|
||||
registerLink?: React.ReactNode;
|
||||
/**
|
||||
* @description Render a redirect to forgot password page button node. If set to false, forgot password button will not be rendered.
|
||||
* @default `"/forgot-password"`
|
||||
* @optional
|
||||
*/
|
||||
forgotPasswordLink?: React.ReactNode;
|
||||
/**
|
||||
* @description Render a remember me button node. If set to false, remember me button will not be rendered.
|
||||
* @optional
|
||||
*/
|
||||
rememberMe?: React.ReactNode;
|
||||
/**
|
||||
* @description Can be used to hide the form components
|
||||
* @optional
|
||||
*/
|
||||
hideForm?: boolean;
|
||||
}>
|
||||
| PropsWithChildren<{
|
||||
/**
|
||||
* @description The type of the auth page.
|
||||
* @optional
|
||||
*/
|
||||
type: "register";
|
||||
/**
|
||||
* @description Providers array for login with third party auth services.
|
||||
* @optional
|
||||
*/
|
||||
providers?: OAuthProvider[];
|
||||
/**
|
||||
* @description Render a redirect to login page button node. If set to false, login button will not be rendered.
|
||||
* @default `"/login"`
|
||||
* @optional
|
||||
*/
|
||||
loginLink?: React.ReactNode;
|
||||
/**
|
||||
* @description Can be used to hide the form components
|
||||
* @optional
|
||||
*/
|
||||
hideForm?: boolean;
|
||||
}>
|
||||
| PropsWithChildren<{
|
||||
/**
|
||||
* @description The type of the auth page.
|
||||
* @optional
|
||||
*/
|
||||
type: "forgotPassword";
|
||||
/**
|
||||
* @description render a redirect to login page button node. If set to false, login button will not be rendered.
|
||||
* @optional
|
||||
*/
|
||||
loginLink?: React.ReactNode;
|
||||
}>
|
||||
| PropsWithChildren<{
|
||||
/**
|
||||
* @description The type of the auth page.
|
||||
* @optional
|
||||
*/
|
||||
type: "updatePassword";
|
||||
}>
|
||||
) & {
|
||||
/**
|
||||
* @description The props that will be passed to the wrapper component.
|
||||
* @optional
|
||||
*/
|
||||
wrapperProps?: TWrapperProps;
|
||||
/**
|
||||
* @description The props that will be passed to the content component.
|
||||
* @optional
|
||||
*/
|
||||
contentProps?: TContentProps;
|
||||
/**
|
||||
* @description This method gives you the ability to render a custom content node.
|
||||
* @optional
|
||||
*/
|
||||
renderContent?: (
|
||||
content: React.ReactNode,
|
||||
title: React.ReactNode,
|
||||
) => React.ReactNode;
|
||||
/**
|
||||
* @description Can be used to pass additional properties for the `Form`
|
||||
* @optional
|
||||
*/
|
||||
formProps?: TFormProps;
|
||||
/**
|
||||
* @description Can be used to pass `Title`
|
||||
* @optional
|
||||
* */
|
||||
title?: React.ReactNode;
|
||||
/**
|
||||
* @description Can be used to pass additional variables to the mutation. This is useful when you need to pass other variables to the authProvider.
|
||||
*/
|
||||
mutationVariables?: Record<string, any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* This should be the base type for `AuthPage` `Login` component implementations in UI integrations.
|
||||
*/
|
||||
export type LoginPageProps<
|
||||
TWrapperProps extends {} = Record<keyof any, unknown>,
|
||||
TContentProps extends {} = Record<keyof any, unknown>,
|
||||
TFormProps extends {} = Record<keyof any, unknown>,
|
||||
> = PropsWithChildren<{
|
||||
providers?: OAuthProvider[];
|
||||
registerLink?: React.ReactNode;
|
||||
forgotPasswordLink?: React.ReactNode;
|
||||
rememberMe?: React.ReactNode;
|
||||
wrapperProps?: TWrapperProps;
|
||||
renderContent?: (
|
||||
content: React.ReactNode,
|
||||
title: React.ReactNode,
|
||||
) => React.ReactNode;
|
||||
contentProps?: TContentProps;
|
||||
formProps?: TFormProps;
|
||||
title?: React.ReactNode;
|
||||
hideForm?: boolean;
|
||||
mutationVariables?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* This should be the base type for `AuthPage` `Register` component implementations in UI integrations.
|
||||
*/
|
||||
export type RegisterPageProps<
|
||||
TWrapperProps extends {} = Record<keyof any, unknown>,
|
||||
TContentProps extends {} = Record<keyof any, unknown>,
|
||||
TFormProps extends {} = Record<keyof any, unknown>,
|
||||
> = PropsWithChildren<{
|
||||
providers?: OAuthProvider[];
|
||||
loginLink?: React.ReactNode;
|
||||
wrapperProps?: TWrapperProps;
|
||||
renderContent?: (
|
||||
content: React.ReactNode,
|
||||
title: React.ReactNode,
|
||||
) => React.ReactNode;
|
||||
contentProps?: TContentProps;
|
||||
formProps?: TFormProps;
|
||||
title?: React.ReactNode;
|
||||
hideForm?: boolean;
|
||||
mutationVariables?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* This should be the base type for `AuthPage` `Reset Password` component implementations in UI integrations.
|
||||
*/
|
||||
export type ForgotPasswordPageProps<
|
||||
TWrapperProps extends {} = Record<keyof any, unknown>,
|
||||
TContentProps extends {} = Record<keyof any, unknown>,
|
||||
TFormProps extends {} = Record<keyof any, unknown>,
|
||||
> = PropsWithChildren<{
|
||||
loginLink?: React.ReactNode;
|
||||
wrapperProps?: TWrapperProps;
|
||||
renderContent?: (
|
||||
content: React.ReactNode,
|
||||
title: React.ReactNode,
|
||||
) => React.ReactNode;
|
||||
contentProps?: TContentProps;
|
||||
formProps?: TFormProps;
|
||||
title?: React.ReactNode;
|
||||
mutationVariables?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* This should be the base type for `AuthPage` `Update Password` component implementations in UI integrations.
|
||||
*/
|
||||
export type UpdatePasswordPageProps<
|
||||
TWrapperProps extends {} = Record<keyof any, unknown>,
|
||||
TContentProps extends {} = Record<keyof any, unknown>,
|
||||
TFormProps extends {} = Record<keyof any, unknown>,
|
||||
> = PropsWithChildren<{
|
||||
wrapperProps?: TWrapperProps;
|
||||
renderContent?: (
|
||||
content: React.ReactNode,
|
||||
title: React.ReactNode,
|
||||
) => React.ReactNode;
|
||||
contentProps?: TContentProps;
|
||||
formProps?: TFormProps;
|
||||
title?: React.ReactNode;
|
||||
mutationVariables?: Record<string, unknown>;
|
||||
}>;
|
||||
444
packages/core/src/components/pages/config-error/index.tsx
Normal file
444
packages/core/src/components/pages/config-error/index.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import React from "react";
|
||||
|
||||
export const ConfigErrorPage = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 11,
|
||||
inset: 0,
|
||||
overflow: "auto",
|
||||
width: "100dvw",
|
||||
height: "100dvh",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "24px",
|
||||
background: "#14141FBF",
|
||||
backdropFilter: "blur(3px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "640px",
|
||||
width: "100%",
|
||||
background: "#1D1E30",
|
||||
borderRadius: "16px",
|
||||
border: "1px solid #303450",
|
||||
boxShadow: "0px 0px 120px -24px #000000",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 20px",
|
||||
borderBottom: "1px solid #303450",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<ErrorGradient
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
lineHeight: "24px",
|
||||
fontSize: "16px",
|
||||
color: "#FFFFFF",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<ErrorIcon />
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Configuration Error
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "20px",
|
||||
color: "#A3ADC2",
|
||||
lineHeight: "20px",
|
||||
fontSize: "14px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "20px",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
lineHeight: "28px",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
<code
|
||||
style={{
|
||||
display: "inline-block",
|
||||
background: "#30345080",
|
||||
padding: "0 4px",
|
||||
lineHeight: "24px",
|
||||
fontSize: "16px",
|
||||
borderRadius: "4px",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{"<Refine />"}
|
||||
</code>{" "}
|
||||
is not initialized. Please make sure you have it mounted in your
|
||||
app and placed your components inside it.
|
||||
</p>
|
||||
<div>
|
||||
<ExampleImplementation />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExampleImplementation = () => {
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
display: "block",
|
||||
overflowX: "auto",
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
lineHeight: "24px",
|
||||
backgroundColor: "#14141F",
|
||||
color: "#E5ECF2",
|
||||
padding: "16px",
|
||||
margin: "0",
|
||||
maxHeight: "400px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#FF7B72" }}>import</span> {"{"} Refine, WelcomePage{" "}
|
||||
{"}"} <span style={{ color: "#FF7B72" }}>from</span>{" "}
|
||||
<span style={{ color: "#A5D6FF" }}>{'"@refinedev/core"'}</span>;{"\n"}
|
||||
{"\n"}
|
||||
<span style={{ color: "#FF7B72" }}>export</span>{" "}
|
||||
<span style={{ color: "#FF7B72" }}>default</span>{" "}
|
||||
<span>
|
||||
<span style={{ color: "#FF7B72" }}>function</span>{" "}
|
||||
<span style={{ color: "#FFA657" }}>App</span>
|
||||
(
|
||||
<span style={{ color: "rgb(222, 147, 95)" }} />){" "}
|
||||
</span>
|
||||
{"{"}
|
||||
{"\n"}
|
||||
{" "}
|
||||
<span style={{ color: "#FF7B72" }}>return</span> ({"\n"}
|
||||
{" "}
|
||||
<span>
|
||||
<span style={{ color: "#79C0FF" }}>
|
||||
<
|
||||
<span style={{ color: "#79C0FF" }}>Refine</span>
|
||||
{"\n"}
|
||||
{" "}
|
||||
<span style={{ color: "#E5ECF2", opacity: 0.6 }}>
|
||||
{"// "}
|
||||
<span>...</span>
|
||||
</span>
|
||||
{"\n"}
|
||||
{" "}>
|
||||
</span>
|
||||
{"\n"}
|
||||
{" "}
|
||||
<span style={{ opacity: 0.6 }}>
|
||||
{"{"}
|
||||
{"/* ... */"}
|
||||
{"}"}
|
||||
</span>
|
||||
{"\n"}
|
||||
{" "}
|
||||
<span style={{ color: "#79C0FF" }}>
|
||||
<
|
||||
<span style={{ color: "#79C0FF" }}>WelcomePage</span> />
|
||||
</span>
|
||||
{"\n"}
|
||||
{" "}
|
||||
<span style={{ opacity: 0.6 }}>
|
||||
{"{"}
|
||||
{"/* ... */"}
|
||||
{"}"}
|
||||
</span>
|
||||
{"\n"}
|
||||
{" "}
|
||||
<span style={{ color: "#79C0FF" }}>
|
||||
</
|
||||
<span style={{ color: "#79C0FF" }}>Refine</span>
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
{"\n"}
|
||||
{" "});{"\n"}
|
||||
{"}"}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorGradient = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={204}
|
||||
height={56}
|
||||
viewBox="0 0 204 56"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path fill="url(#welcome-page-error-gradient-a)" d="M12 0H0v12L12 0Z" />
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-b)"
|
||||
d="M28 0h-8L0 20v8L28 0Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-c)"
|
||||
d="M36 0h8L0 44v-8L36 0Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-d)"
|
||||
d="M60 0h-8L0 52v4h4L60 0Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-e)"
|
||||
d="M68 0h8L20 56h-8L68 0Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-f)"
|
||||
d="M92 0h-8L28 56h8L92 0Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-g)"
|
||||
d="M100 0h8L52 56h-8l56-56Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-h)"
|
||||
d="M124 0h-8L60 56h8l56-56Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-i)"
|
||||
d="M140 0h-8L76 56h8l56-56Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-j)"
|
||||
d="M132 0h8L84 56h-8l56-56Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-k)"
|
||||
d="M156 0h-8L92 56h8l56-56Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-l)"
|
||||
d="M164 0h8l-56 56h-8l56-56Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-m)"
|
||||
d="M188 0h-8l-56 56h8l56-56Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#welcome-page-error-gradient-n)"
|
||||
d="M204 0h-8l-56 56h8l56-56Z"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-a"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-b"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-c"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-d"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-e"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-f"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-g"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-h"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-i"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-j"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-k"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-l"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-m"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="welcome-page-error-gradient-n"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientTransform="scale(124)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF4C4D" stopOpacity={0.1} />
|
||||
<stop offset={1} stopColor="#FF4C4D" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ErrorIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#FF4C4D"
|
||||
fillRule="evenodd"
|
||||
d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
d="M7 8a1 1 0 1 0 2 0V5a1 1 0 1 0-2 0v3Zm0 3a1 1 0 1 1 2 0 1 1 0 0 1-2 0Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
import { render } from "@test";
|
||||
|
||||
import { ConfigSuccessPage } from "./index";
|
||||
|
||||
describe("ConfigSuccessPage", () => {
|
||||
it("should render page successfuly", async () => {
|
||||
const { getByText } = render(<ConfigSuccessPage />);
|
||||
|
||||
getByText("Welcome Aboard!");
|
||||
getByText("Your configuration is completed.");
|
||||
});
|
||||
|
||||
const cases = [
|
||||
["Documentation", "https://refine.dev/docs"],
|
||||
["Tutorial", "https://refine.dev/tutorial"],
|
||||
["Templates", "https://refine.dev/templates"],
|
||||
["Community", "https://discord.gg/refine"],
|
||||
];
|
||||
|
||||
it.each(cases)("should render correct %s href", async (text, expected) => {
|
||||
const { getByText } = render(<ConfigSuccessPage />);
|
||||
|
||||
expect(getByText(text).closest("a")).toHaveAttribute("href", expected);
|
||||
});
|
||||
});
|
||||
283
packages/core/src/components/pages/config-success/index.tsx
Normal file
283
packages/core/src/components/pages/config-success/index.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useMediaQuery } from "@definitions/helpers";
|
||||
|
||||
type CardInfo = {
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
iconUrl: string;
|
||||
};
|
||||
|
||||
const cards: CardInfo[] = [
|
||||
{
|
||||
title: "Documentation",
|
||||
description:
|
||||
"Learn about the technical details of using Refine in your projects.",
|
||||
link: "https://refine.dev/docs",
|
||||
iconUrl:
|
||||
"https://refine.ams3.cdn.digitaloceanspaces.com/welcome-page/book.svg",
|
||||
},
|
||||
{
|
||||
title: "Tutorial",
|
||||
description:
|
||||
"Learn how to use Refine by building a fully-functioning CRUD app, from scratch to full launch.",
|
||||
link: "https://refine.dev/tutorial",
|
||||
iconUrl:
|
||||
"https://refine.ams3.cdn.digitaloceanspaces.com/welcome-page/hat.svg",
|
||||
},
|
||||
{
|
||||
title: "Templates",
|
||||
description:
|
||||
"Explore a range of pre-built templates, perfect everything from admin panels to dashboards and CRMs.",
|
||||
link: "https://refine.dev/templates",
|
||||
iconUrl:
|
||||
"https://refine.ams3.cdn.digitaloceanspaces.com/welcome-page/application.svg",
|
||||
},
|
||||
{
|
||||
title: "Community",
|
||||
description: "Join our Discord community and keep up with the latest news.",
|
||||
link: "https://discord.gg/refine",
|
||||
iconUrl:
|
||||
"https://refine.ams3.cdn.digitaloceanspaces.com/welcome-page/discord.svg",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* It is a page that welcomes you after the configuration is completed.
|
||||
*/
|
||||
export const ConfigSuccessPage: React.FC = () => {
|
||||
const isTablet = useMediaQuery("(max-width: 1010px)");
|
||||
const isMobile = useMediaQuery("(max-width: 650px)");
|
||||
|
||||
const getGridTemplateColumns = () => {
|
||||
if (isMobile) {
|
||||
return "1, 280px";
|
||||
}
|
||||
if (isTablet) {
|
||||
return "2, 280px";
|
||||
}
|
||||
return "4, 1fr";
|
||||
};
|
||||
|
||||
const getHeaderFontSize = () => {
|
||||
if (isMobile) {
|
||||
return "32px";
|
||||
}
|
||||
if (isTablet) {
|
||||
return "40px";
|
||||
}
|
||||
return "48px";
|
||||
};
|
||||
|
||||
const getSubHeaderFontSize = () => {
|
||||
if (isMobile) {
|
||||
return "16px";
|
||||
}
|
||||
if (isTablet) {
|
||||
return "20px";
|
||||
}
|
||||
return "24px";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 10,
|
||||
inset: 0,
|
||||
overflow: "auto",
|
||||
width: "100dvw",
|
||||
height: "100dvh",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
backgroundSize: "cover",
|
||||
backgroundRepeat: "no-repeat",
|
||||
background: isMobile
|
||||
? "url(https://refine.ams3.cdn.digitaloceanspaces.com/website/static/assets/landing-noise.webp), radial-gradient(88.89% 50% at 50% 100%, rgba(38, 217, 127, 0.10) 0%, rgba(38, 217, 127, 0.00) 100%), radial-gradient(88.89% 50% at 50% 0%, rgba(71, 235, 235, 0.15) 0%, rgba(71, 235, 235, 0.00) 100%), #1D1E30"
|
||||
: isTablet
|
||||
? "url(https://refine.ams3.cdn.digitaloceanspaces.com/website/static/assets/landing-noise.webp), radial-gradient(66.67% 50% at 50% 100%, rgba(38, 217, 127, 0.10) 0%, rgba(38, 217, 127, 0.00) 100%), radial-gradient(66.67% 50% at 50% 0%, rgba(71, 235, 235, 0.15) 0%, rgba(71, 235, 235, 0.00) 100%), #1D1E30"
|
||||
: "url(https://refine.ams3.cdn.digitaloceanspaces.com/website/static/assets/landing-noise.webp), radial-gradient(35.56% 50% at 50% 100%, rgba(38, 217, 127, 0.12) 0%, rgba(38, 217, 127, 0) 100%), radial-gradient(35.56% 50% at 50% 0%, rgba(71, 235, 235, 0.18) 0%, rgba(71, 235, 235, 0) 100%), #1D1E30",
|
||||
minHeight: "100%",
|
||||
minWidth: "100%",
|
||||
fontFamily: "Arial",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
zIndex: 2,
|
||||
position: "absolute",
|
||||
width: isMobile ? "400px" : "800px",
|
||||
height: "552px",
|
||||
opacity: "0.5",
|
||||
background:
|
||||
"url(https://refine.ams3.cdn.digitaloceanspaces.com/assets/welcome-page-hexagon.png)",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "contain",
|
||||
top: "0",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
/>
|
||||
<div style={{ height: isMobile ? "40px" : "80px" }} />
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: isMobile ? "112px 58px" : "224px 116px",
|
||||
backgroundImage:
|
||||
"url(https://refine.ams3.cdn.digitaloceanspaces.com/assets/refine-logo.svg)",
|
||||
width: isMobile ? 112 : 224,
|
||||
height: isMobile ? 58 : 116,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: isMobile ? "120px" : isTablet ? "200px" : "30vh",
|
||||
minHeight: isMobile ? "120px" : isTablet ? "200px" : "200px",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: getHeaderFontSize(),
|
||||
fontWeight: 700,
|
||||
margin: "0px",
|
||||
}}
|
||||
>
|
||||
Welcome Aboard!
|
||||
</h1>
|
||||
<h4
|
||||
style={{
|
||||
fontSize: getSubHeaderFontSize(),
|
||||
fontWeight: 400,
|
||||
margin: "0px",
|
||||
}}
|
||||
>
|
||||
Your configuration is completed.
|
||||
</h4>
|
||||
</div>
|
||||
<div style={{ height: "64px" }} />
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${getGridTemplateColumns()})`,
|
||||
justifyContent: "center",
|
||||
gap: "48px",
|
||||
paddingRight: "16px",
|
||||
paddingLeft: "16px",
|
||||
paddingBottom: "32px",
|
||||
maxWidth: "976px",
|
||||
margin: "auto",
|
||||
}}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<Card key={`welcome-page-${card.title}`} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CardProps = {
|
||||
card: CardInfo;
|
||||
};
|
||||
|
||||
const Card: React.FC<CardProps> = ({ card }) => {
|
||||
const { title, description, iconUrl, link } = card;
|
||||
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<a
|
||||
onPointerEnter={() => setIsHover(true)}
|
||||
onPointerLeave={() => setIsHover(false)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "#fff",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
href={link}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: "contain",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundImage: `url(${iconUrl})`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
fontWeight: 700,
|
||||
marginLeft: "13px",
|
||||
marginRight: "14px",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<svg
|
||||
style={{
|
||||
transition:
|
||||
"transform 0.5s ease-in-out, opacity 0.2s ease-in-out",
|
||||
...(isHover && {
|
||||
transform: "translateX(4px)",
|
||||
opacity: 1,
|
||||
}),
|
||||
}}
|
||||
width="12"
|
||||
height="8"
|
||||
fill="none"
|
||||
opacity="0.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.293.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 0 1-1.414-1.414L8.586 5H1a1 1 0 0 1 0-2h7.586L7.293 1.707a1 1 0 0 1 0-1.414Z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
opacity: 0.5,
|
||||
lineHeight: "16px",
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
93
packages/core/src/components/pages/error/index.spec.tsx
Normal file
93
packages/core/src/components/pages/error/index.spec.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
TestWrapper,
|
||||
mockLegacyRouterProvider,
|
||||
mockRouterProvider,
|
||||
} from "@test";
|
||||
|
||||
import { ErrorComponent } from ".";
|
||||
|
||||
describe("ErrorComponent", () => {
|
||||
it("renders subtitle successfully", () => {
|
||||
const { getByText } = render(<ErrorComponent />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
getByText("Sorry, the page you visited does not exist.");
|
||||
});
|
||||
|
||||
it("renders button successfully", () => {
|
||||
const { container, getByText } = render(<ErrorComponent />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(container.querySelector("button")).toBeTruthy();
|
||||
getByText("Back Home");
|
||||
});
|
||||
|
||||
it("render error message according to the resource and action", () => {
|
||||
const { getByText } = render(<ErrorComponent />, {
|
||||
wrapper: TestWrapper({
|
||||
routerProvider: mockRouterProvider({
|
||||
action: "create",
|
||||
resource: { name: "posts" },
|
||||
pathname: "/posts/create",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
getByText(
|
||||
`You may have forgotten to add the "create" component to "posts" resource.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("back home button should work with legacy router provider", async () => {
|
||||
const pushMock = jest.fn();
|
||||
|
||||
const { getByText } = render(<ErrorComponent />, {
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: {
|
||||
...mockLegacyRouterProvider(),
|
||||
useHistory: () => ({
|
||||
goBack: jest.fn(),
|
||||
push: pushMock,
|
||||
replace: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
fireEvent.click(getByText("Back Home"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(pushMock).toBeCalledWith("/");
|
||||
});
|
||||
|
||||
it("back home button should work with router provider", async () => {
|
||||
const goMock = jest.fn();
|
||||
|
||||
const { getByText } = render(<ErrorComponent />, {
|
||||
wrapper: TestWrapper({
|
||||
routerProvider: mockRouterProvider({
|
||||
fns: {
|
||||
go: () => goMock,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
fireEvent.click(getByText("Back Home"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(goMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(goMock).toBeCalledWith({ to: "/" });
|
||||
});
|
||||
});
|
||||
64
packages/core/src/components/pages/error/index.tsx
Normal file
64
packages/core/src/components/pages/error/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
useNavigation,
|
||||
useTranslate,
|
||||
useResource,
|
||||
useGo,
|
||||
useRouterType,
|
||||
} from "@hooks";
|
||||
|
||||
/**
|
||||
* When the app is navigated to a non-existent route, refine shows a default error page.
|
||||
* A custom error component can be used for this error page.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/packages/documentation/routers/} for more details.
|
||||
*/
|
||||
export const ErrorComponent: React.FC = () => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const translate = useTranslate();
|
||||
const { push } = useNavigation();
|
||||
const go = useGo();
|
||||
const routerType = useRouterType();
|
||||
|
||||
const { resource, action } = useResource();
|
||||
|
||||
useEffect(() => {
|
||||
if (resource && action) {
|
||||
setErrorMessage(
|
||||
translate(
|
||||
"pages.error.info",
|
||||
{
|
||||
action: action,
|
||||
resource: resource.name,
|
||||
},
|
||||
`You may have forgotten to add the "${action}" component to "${resource.name}" resource.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [resource, action]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>
|
||||
{translate(
|
||||
"pages.error.404",
|
||||
undefined,
|
||||
"Sorry, the page you visited does not exist.",
|
||||
)}
|
||||
</h1>
|
||||
{errorMessage && <p>{errorMessage}</p>}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (routerType === "legacy") {
|
||||
push("/");
|
||||
} else {
|
||||
go({ to: "/" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translate("pages.error.backHome", undefined, "Back Home")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
7
packages/core/src/components/pages/index.tsx
Normal file
7
packages/core/src/components/pages/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export { ErrorComponent } from "./error";
|
||||
export { LoginPage } from "./login";
|
||||
export { AuthPage } from "./auth";
|
||||
export { ReadyPage } from "./ready";
|
||||
export { WelcomePage } from "./welcome";
|
||||
|
||||
export type { AuthProps } from "./auth";
|
||||
77
packages/core/src/components/pages/login/index.tsx
Normal file
77
packages/core/src/components/pages/login/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useLogin, useTranslate } from "@hooks";
|
||||
import { useActiveAuthProvider } from "@definitions/helpers";
|
||||
export interface ILoginForm {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated LoginPage is deprecated. Use AuthPage instead. @see {@link https://refine.dev/docs/core/components/auth-page} for more details.
|
||||
* **refine** has a default login page form which is served on `/login` route when the `authProvider` configuration is provided.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/core/components/refine-config/#loginpage} for more details.
|
||||
*/
|
||||
export const LoginPage: React.FC = () => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { mutate: login } = useLogin<ILoginForm>({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{translate("pages.login.title", "Sign in your account")}</h1>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
login({ username, password });
|
||||
}}
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{translate("pages.login.username", undefined, "username")}:
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
size={20}
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoFocus
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{translate("pages.login.password", undefined, "password")}:
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
size={20}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<br />
|
||||
<input type="submit" value="login" />
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
37
packages/core/src/components/pages/ready/index.spec.tsx
Normal file
37
packages/core/src/components/pages/ready/index.spec.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
|
||||
import { render } from "@test";
|
||||
|
||||
import { ReadyPage } from "./index";
|
||||
|
||||
describe("ReadyPage", () => {
|
||||
it("should render page successfuly", async () => {
|
||||
const { getByText } = render(<ReadyPage />);
|
||||
|
||||
getByText("Welcome on board");
|
||||
getByText("Your configuration is completed.");
|
||||
expect(
|
||||
<p>
|
||||
Now you can get started by adding your resources to the{" "}
|
||||
<code>`resources`</code> property of <code>{"`<Refine>`"}</code>
|
||||
</p>,
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("should render 3 buttons", async () => {
|
||||
const { getByText } = render(<ReadyPage />);
|
||||
|
||||
expect(getByText("Documentation").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"https://refine.dev",
|
||||
);
|
||||
expect(getByText("Examples").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"https://refine.dev/examples",
|
||||
);
|
||||
expect(getByText("Community").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"https://discord.gg/refine",
|
||||
);
|
||||
});
|
||||
});
|
||||
30
packages/core/src/components/pages/ready/index.tsx
Normal file
30
packages/core/src/components/pages/ready/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* **refine** shows a default ready page on root route when no `resources` is passed to the `<Refine>` component as a property.
|
||||
*
|
||||
* @deprecated `ReadyPage` is deprecated and will be removed in the next major release.
|
||||
*/
|
||||
export const ReadyPage: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<h1>Welcome on board</h1>
|
||||
<p>Your configuration is completed.</p>
|
||||
<p>
|
||||
Now you can get started by adding your resources to the{" "}
|
||||
<code>`resources`</code> property of <code>{"`<Refine>`"}</code>
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<a href="https://refine.dev" target="_blank" rel="noreferrer">
|
||||
<button>Documentation</button>
|
||||
</a>
|
||||
<a href="https://refine.dev/examples" target="_blank" rel="noreferrer">
|
||||
<button>Examples</button>
|
||||
</a>
|
||||
<a href="https://discord.gg/refine" target="_blank" rel="noreferrer">
|
||||
<button>Community</button>
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
15
packages/core/src/components/pages/welcome/index.tsx
Normal file
15
packages/core/src/components/pages/welcome/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import { ConfigSuccessPage } from "../config-success";
|
||||
import { useRefineContext } from "@hooks/refine";
|
||||
import { ConfigErrorPage } from "../config-error";
|
||||
|
||||
export const WelcomePage = () => {
|
||||
const { __initialized } = useRefineContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigSuccessPage />
|
||||
{!__initialized && <ConfigErrorPage />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { render, TestWrapper, act } from "@test";
|
||||
|
||||
import { RouteChangeHandler } from "./index";
|
||||
|
||||
const mockAuthProvider = {
|
||||
login: () => Promise.resolve(),
|
||||
logout: () => Promise.resolve(),
|
||||
checkError: () => Promise.resolve(),
|
||||
checkAuth: () => Promise.resolve(),
|
||||
getPermissions: () => Promise.resolve(["admin"]),
|
||||
getUserIdentity: () => Promise.resolve(),
|
||||
isProvided: true,
|
||||
};
|
||||
|
||||
describe("routeChangeHandler", () => {
|
||||
it("should render successful", () => {
|
||||
const { container } = render(<RouteChangeHandler />, {
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts" }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should call checkAuth on route change", async () => {
|
||||
const checkAuthMockedAuthProvider = {
|
||||
...mockAuthProvider,
|
||||
checkAuth: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
render(<RouteChangeHandler />, {
|
||||
wrapper: TestWrapper({
|
||||
legacyAuthProvider: checkAuthMockedAuthProvider,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
expect(checkAuthMockedAuthProvider.checkAuth).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should ignore checkAuth Promise.reject", async () => {
|
||||
const checkAuthMockedAuthProvider = {
|
||||
...mockAuthProvider,
|
||||
checkAuth: jest.fn().mockImplementation(() => Promise.reject()),
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
render(<RouteChangeHandler />, {
|
||||
wrapper: TestWrapper({
|
||||
legacyAuthProvider: checkAuthMockedAuthProvider,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
expect(checkAuthMockedAuthProvider.checkAuth).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
16
packages/core/src/components/routeChangeHandler/index.tsx
Normal file
16
packages/core/src/components/routeChangeHandler/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouterContext } from "@hooks";
|
||||
import { useLegacyAuthContext } from "@contexts/auth";
|
||||
|
||||
export const RouteChangeHandler: React.FC = () => {
|
||||
const { useLocation } = useRouterContext();
|
||||
|
||||
const { checkAuth } = useLegacyAuthContext();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth?.().catch(() => false);
|
||||
}, [location?.pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
108
packages/core/src/components/telemetry/index.spec.tsx
Normal file
108
packages/core/src/components/telemetry/index.spec.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import { render, TestWrapper } from "@test";
|
||||
import * as UseTelemetryData from "../../hooks/useTelemetryData";
|
||||
|
||||
import { Telemetry } from "./index";
|
||||
|
||||
describe("Telemetry", () => {
|
||||
const originalImage = global.Image;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
const imageMock = jest.fn();
|
||||
global.Image = imageMock;
|
||||
|
||||
const fetchMock = jest.fn();
|
||||
global.fetch = fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
global.Image = imageMock;
|
||||
global.fetch = fetchMock;
|
||||
|
||||
jest.spyOn(UseTelemetryData, "useTelemetryData").mockReturnValue({
|
||||
providers: {},
|
||||
version: "1",
|
||||
resourceCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
imageMock.mockClear();
|
||||
fetchMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.Image = originalImage;
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("should not crash", async () => {
|
||||
const { container } = render(<Telemetry />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
expect(imageMock).toBeCalledTimes(1);
|
||||
expect(fetchMock).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("should encode payload", async () => {
|
||||
const imageMockInstance = {} as any;
|
||||
|
||||
imageMock.mockImplementation(() => imageMockInstance);
|
||||
|
||||
render(<Telemetry />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(imageMock).toBeCalledTimes(1);
|
||||
expect(fetchMock).not.toBeCalled();
|
||||
|
||||
expect(imageMockInstance.src).toBe(
|
||||
"https://telemetry.refine.dev/telemetry?payload=eyJwcm92aWRlcnMiOnt9LCJ2ZXJzaW9uIjoiMSIsInJlc291cmNlQ291bnQiOjF9",
|
||||
);
|
||||
});
|
||||
|
||||
it("should use fetch when image is undefined", async () => {
|
||||
global.Image = undefined as any;
|
||||
|
||||
render(<Telemetry />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(imageMock).not.toBeCalled();
|
||||
expect(fetchMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should encode payload when using fetch", async () => {
|
||||
global.Image = undefined as any;
|
||||
|
||||
render(<Telemetry />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(imageMock).not.toBeCalled();
|
||||
expect(fetchMock).toBeCalledTimes(1);
|
||||
|
||||
expect(fetchMock).toBeCalledWith(
|
||||
"https://telemetry.refine.dev/telemetry?payload=eyJwcm92aWRlcnMiOnt9LCJ2ZXJzaW9uIjoiMSIsInJlc291cmNlQ291bnQiOjF9",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not call endpoints if encoding fails", async () => {
|
||||
const originalBtoa = global.btoa;
|
||||
|
||||
global.btoa = () => {
|
||||
throw new Error("error");
|
||||
};
|
||||
|
||||
render(<Telemetry />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(imageMock).not.toBeCalled();
|
||||
expect(fetchMock).not.toBeCalled();
|
||||
|
||||
global.btoa = originalBtoa;
|
||||
});
|
||||
});
|
||||
58
packages/core/src/components/telemetry/index.tsx
Normal file
58
packages/core/src/components/telemetry/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
|
||||
import { useTelemetryData } from "@hooks/useTelemetryData";
|
||||
|
||||
import type { ITelemetryData } from "./types";
|
||||
|
||||
const encode = (payload: ITelemetryData): string | undefined => {
|
||||
try {
|
||||
const stringifiedPayload = JSON.stringify(payload || {});
|
||||
|
||||
if (typeof btoa !== "undefined") {
|
||||
return btoa(stringifiedPayload);
|
||||
}
|
||||
|
||||
return Buffer.from(stringifiedPayload).toString("base64");
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const throughImage = (src: string) => {
|
||||
const img = new Image();
|
||||
|
||||
img.src = src;
|
||||
};
|
||||
|
||||
const throughFetch = (src: string) => {
|
||||
fetch(src);
|
||||
};
|
||||
|
||||
const transport = (src: string) => {
|
||||
if (typeof Image !== "undefined") {
|
||||
throughImage(src);
|
||||
} else if (typeof fetch !== "undefined") {
|
||||
throughFetch(src);
|
||||
}
|
||||
};
|
||||
|
||||
export const Telemetry: React.FC<{}> = () => {
|
||||
const payload = useTelemetryData();
|
||||
const sent = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (sent.current) {
|
||||
return;
|
||||
}
|
||||
const encoded = encode(payload);
|
||||
|
||||
if (!encoded) {
|
||||
return;
|
||||
}
|
||||
|
||||
transport(`https://telemetry.refine.dev/telemetry?payload=${encoded}`);
|
||||
sent.current = true;
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
15
packages/core/src/components/telemetry/types.ts
Normal file
15
packages/core/src/components/telemetry/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type ITelemetryData = {
|
||||
providers: {
|
||||
auth?: boolean;
|
||||
data?: boolean;
|
||||
router?: boolean;
|
||||
notification?: boolean;
|
||||
live?: boolean;
|
||||
auditLog?: boolean;
|
||||
i18n?: boolean;
|
||||
accessControl?: boolean;
|
||||
};
|
||||
version: string;
|
||||
resourceCount: number;
|
||||
projectId?: string;
|
||||
};
|
||||
79
packages/core/src/components/undoableQueue/index.spec.tsx
Normal file
79
packages/core/src/components/undoableQueue/index.spec.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
|
||||
import { UndoableQueueContext } from "@contexts/undoableQueue";
|
||||
import { TestWrapper, render } from "@test";
|
||||
|
||||
import { UndoableQueue } from ".";
|
||||
|
||||
const doMutation = jest.fn();
|
||||
const cancelMutation = jest.fn();
|
||||
|
||||
const openMock = jest.fn();
|
||||
const closeMock = jest.fn();
|
||||
|
||||
const notificationDispatch = jest.fn();
|
||||
|
||||
const mockNotification = {
|
||||
id: "1",
|
||||
resource: "posts",
|
||||
cancelMutation,
|
||||
doMutation,
|
||||
seconds: 5000,
|
||||
isRunning: true,
|
||||
isSilent: false,
|
||||
};
|
||||
describe("Cancel Notification", () => {
|
||||
it("should trigger notification open function", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
render(
|
||||
<UndoableQueueContext.Provider
|
||||
value={{
|
||||
notificationDispatch,
|
||||
notifications: [mockNotification],
|
||||
}}
|
||||
>
|
||||
<UndoableQueue notification={mockNotification} />
|
||||
</UndoableQueueContext.Provider>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
notificationProvider: {
|
||||
open: openMock,
|
||||
close: closeMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(openMock).toBeCalledTimes(1);
|
||||
expect(openMock).toBeCalledWith({
|
||||
cancelMutation: cancelMutation,
|
||||
key: "1-posts-notification",
|
||||
message: "You have 5 seconds to undo",
|
||||
type: "progress",
|
||||
undoableTimeout: 5,
|
||||
});
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(notificationDispatch).toBeCalledTimes(1);
|
||||
expect(notificationDispatch).toBeCalledWith({
|
||||
payload: {
|
||||
id: "1",
|
||||
resource: "posts",
|
||||
seconds: 5000,
|
||||
},
|
||||
type: "DECREASE_NOTIFICATION_SECOND",
|
||||
});
|
||||
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should call doMutation on seconds zero", async () => {
|
||||
mockNotification.seconds = 0;
|
||||
render(<UndoableQueue notification={mockNotification} />);
|
||||
|
||||
expect(doMutation).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
70
packages/core/src/components/undoableQueue/index.tsx
Normal file
70
packages/core/src/components/undoableQueue/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useCancelNotification, useNotification, useTranslate } from "@hooks";
|
||||
|
||||
import { userFriendlySecond } from "@definitions/helpers";
|
||||
import {
|
||||
ActionTypes,
|
||||
type IUndoableQueue,
|
||||
} from "../../contexts/undoableQueue/types";
|
||||
|
||||
export const UndoableQueue: React.FC<{
|
||||
notification: IUndoableQueue;
|
||||
}> = ({ notification }) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
const { notificationDispatch } = useCancelNotification();
|
||||
const { open } = useNotification();
|
||||
|
||||
const [timeoutId, setTimeoutId] = useState<number | undefined>();
|
||||
|
||||
const cancelNotification = () => {
|
||||
if (notification.isRunning === true) {
|
||||
if (notification.seconds === 0) {
|
||||
notification.doMutation();
|
||||
}
|
||||
if (!notification.isSilent) {
|
||||
open?.({
|
||||
key: `${notification.id}-${notification.resource}-notification`,
|
||||
type: "progress",
|
||||
message: translate(
|
||||
"notifications.undoable",
|
||||
{
|
||||
seconds: userFriendlySecond(notification.seconds),
|
||||
},
|
||||
`You have ${userFriendlySecond(
|
||||
notification.seconds,
|
||||
)} seconds to undo`,
|
||||
),
|
||||
cancelMutation: notification.cancelMutation,
|
||||
undoableTimeout: userFriendlySecond(notification.seconds),
|
||||
});
|
||||
}
|
||||
|
||||
if (notification.seconds > 0) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const newTimeoutId = setTimeout(() => {
|
||||
notificationDispatch({
|
||||
type: ActionTypes.DECREASE_NOTIFICATION_SECOND,
|
||||
payload: {
|
||||
id: notification.id,
|
||||
seconds: notification.seconds,
|
||||
resource: notification.resource,
|
||||
},
|
||||
});
|
||||
}, 1000) as unknown as number;
|
||||
|
||||
setTimeoutId(newTimeoutId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
cancelNotification();
|
||||
}, [notification]);
|
||||
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user