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

View File

@@ -0,0 +1,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",
}),
}),
),
);
});
});

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

View File

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

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

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

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

View File

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

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

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

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export * from "./login";
export * from "./register";
export * from "./forgotPassword";
export * from "./updatePassword";

View File

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

View File

@@ -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",
"Dont 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", "Dont have an account?")}{" "}
{renderLink(
"/register",
translate("pages.login.register", "Sign up"),
)}
</div>
)}
</div>
);
return (
<div {...wrapperProps}>
{renderContent ? renderContent(content, title) : content}
</div>
);
};

View File

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

View File

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

View File

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

View File

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

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

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

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

View 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" }}>
&lt;
<span style={{ color: "#79C0FF" }}>Refine</span>
{"\n"}
{" "}
<span style={{ color: "#E5ECF2", opacity: 0.6 }}>
{"// "}
<span>...</span>
</span>
{"\n"}
{" "}&gt;
</span>
{"\n"}
{" "}
<span style={{ opacity: 0.6 }}>
{"{"}
{"/* ... */"}
{"}"}
</span>
{"\n"}
{" "}
<span style={{ color: "#79C0FF" }}>
&lt;
<span style={{ color: "#79C0FF" }}>WelcomePage</span> /&gt;
</span>
{"\n"}
{" "}
<span style={{ opacity: 0.6 }}>
{"{"}
{"/* ... */"}
{"}"}
</span>
{"\n"}
{" "}
<span style={{ color: "#79C0FF" }}>
&lt;/
<span style={{ color: "#79C0FF" }}>Refine</span>
&gt;
</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>
);

View File

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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