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

11
packages/core/.npmignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.DS_Store
test
jest.config.js
**/*.spec.ts
**/*.spec.tsx
**/*.test.ts
**/*.test.tsx
tsup.config.ts
tsconfig.test.json
tsconfig.declarations.json

1
packages/core/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

5878
packages/core/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

221
packages/core/README.md Normal file
View File

@@ -0,0 +1,221 @@
<div align="center">
<a href="https://refine.dev/">
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
</a>
<br/>
<br/>
<div align="center">
<a href="https://refine.dev">Home Page</a> |
<a href="https://refine.dev/docs/">Documentation</a> |
<a href="https://refine.dev/examples/">Examples</a> |
<a href="https://discord.gg/refine">Discord</a> |
<a href="https://refine.dev/blog/">Blog</a>
</div>
</div>
<br/>
<br/>
<div align="center"><strong>The sweet spot between the low/no code and “starting from scratch” for CRUD-heavy applications.</strong><br> Refine is as an open source, React meta-framework for enterprise. It provides a headless solution for everything from admin panels to dashboards and internal tools.
<br />
<br />
</div>
<div align="center">
[![Awesome](https://github.com/refinedev/awesome-refine/raw/main/images/badge.svg)](https://github.com/refinedev/awesome-refine)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/8101/badge)](https://www.bestpractices.dev/projects/8101)
[![npm version](https://img.shields.io/npm/v/@refinedev/core.svg)](https://www.npmjs.com/package/@refinedev/core)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.0-4baaaa.svg)](CODE_OF_CONDUCT.md)
[![Discord](https://img.shields.io/discord/837692625737613362.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/refine)
[![Twitter Follow](https://img.shields.io/twitter/follow/refine_dev?style=social)](https://twitter.com/refine_dev)
</div>
<br/>
[![how-refine-works](https://refine.ams3.cdn.digitaloceanspaces.com/website/static/img/diagram-3.png)](https://refine.dev)
## What is Refine?
**Refine** is a React meta-framework for CRUD-heavy web applications. It addresses a wide range of enterprise use cases including internal tools, admin panels, dashboards and B2B apps.
Refine's core hooks and components streamline the development process by offering industry-standard solutions for crucial aspects of a project, including **authentication**, **access control**, **routing**, **networking**, **state management**, and **i18n**.
Refine's headless architecture enables the building of highly customizable applications by decoupling business logic from UI and routing. This allows integration with:
- Any custom designs or UI frameworks like [TailwindCSS](https://tailwindcss.com/), along with built-in support for [Ant Design](https://ant.design/), [Material UI](https://mui.com/material-ui/getting-started/overview/), [Mantine](https://mantine.dev/), and [Chakra UI](https://chakra-ui.com/).
- Various platforms, including Next.js, Remix, React Native, Electron, etc., by a simple routing interface without the need for additional setup steps.
## ⚡ Try Refine
Start a new project with Refine in seconds using the following command:
```sh
npm create refine-app@latest my-refine-app
```
Or you can create a new project on your browser:
<a href="https://refine.dev/?playground=true" target="_blank">
<img height="48" width="245" src="https://refine.ams3.cdn.digitaloceanspaces.com/assets/try-it-in-your-browser.png" />
</a>
## Quick Start
Here's Refine in action, the below code is an example of a simple CRUD application using Refine + React Router + Material UI:
```tsx
import React from "react";
import { Refine, useMany } from "@refinedev/core";
import { ThemedLayoutV2 } from "@refinedev/mui";
import dataProvider from "@refinedev/simple-rest";
import routerProvider from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import CssBaseline from "@mui/material/CssBaseline";
export default function App() {
return (
<BrowserRouter>
<CssBaseline />
<Refine
dataProvider={dataProvider("https://api.fake-rest.refine.dev")}
routerProvider={routerProvider}
resources={[
{
name: "products",
list: "/products",
},
]}
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
<Route path="/products">
<Route index element={<ProductList />} />
</Route>
</Route>
</Routes>
</Refine>
</BrowserRouter>
);
}
// src/pages/products/list.tsx
import { List, useDataGrid, DateField } from "@refinedev/mui";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
export const ProductList = () => {
const { dataGridProps } = useDataGrid();
const { data: categories, isLoading } = useMany({
resource: "categories",
ids:
dataGridProps?.rows?.map((item) => item?.category?.id).filter(Boolean) ??
[],
queryOptions: {
enabled: !!dataGridProps?.rows,
},
});
const columns = React.useMemo<GridColDef[]>(
() => [
{ field: "id", headerName: "ID", type: "number" },
{ field: "name", flex: 1, headerName: "Name" },
{
field: "category",
flex: 1,
headerName: "Category",
renderCell: ({ value }) =>
isLoading
? "Loading..."
: categories?.data?.find((item) => item.id === value?.id)?.title,
},
{
field: "createdAt",
flex: 1,
headerName: "Created at",
renderCell: ({ value }) => <DateField value={value} />,
},
],
[categories?.data, isLoading],
);
return (
<List>
<DataGrid {...dataGridProps} columns={columns} autoHeight />
</List>
);
};
```
The result will look like this:
[![Refine + Material UI Example](https://refine.ams3.cdn.digitaloceanspaces.com/assets/refine-mui-simple-example-screenshot-rounded.webp)](https://refine.new/preview/c85442a8-8df1-4101-a09a-47d3ca641798)
## Use cases
**Refine** shines on _data-intensive⚡_ enterprise B2B applications like **admin panels**, **dashboards** and **internal tools**. Thanks to the built-in **SSR support**, it can also power _customer-facing_ applications like **storefronts**.
You can take a look at some live examples that can be built using **Refine** from scratch:
- [Fully-functional CRM Application](https://refine.dev/templates/crm-application/)
- [Fully-functional Admin Panel](https://refine.dev/templates/react-admin-panel/)
- [Win95 Style Admin panel 🪟](https://refine.dev/templates/win-95-style-admin-panel/)
- [PDF Invoice Generator](https://refine.dev/templates/react-pdf-invoice-generator/)
- [Medium Clone - Real World Example](https://refine.dev/templates/react-crud-app/)
- [Multitenancy Example](https://refine.dev/templates/multitenancy-strapi/)
- [Storefront](https://refine.dev/templates/next-js-ecommerce-store/)
- [Refer to templates page for more examples](https://refine.dev/templates/)
- [More **Refine** powered different usage scenarios can be found here](https://refine.dev/docs/examples#other-examples)
## Key Features
- Refine Devtools - dive deeper into your app and provide useful insights
- Connectors for **15+ backend services** including [REST API](https://github.com/refinedev/refine/tree/master/packages/simple-rest), [GraphQL](https://github.com/refinedev/refine/tree/master/packages/graphql), [NestJs CRUD](https://github.com/refinedev/refine/tree/master/packages/nestjsx-crud), [Airtable](https://github.com/refinedev/refine/tree/master/packages/airtable), [Strapi](https://github.com/refinedev/refine/tree/master/packages/strapi), [Strapi v4](https://github.com/refinedev/refine/tree/master/packages/strapi-v4), [Supabase](https://github.com/refinedev/refine/tree/master/packages/supabase), [Hasura](https://github.com/refinedev/refine/tree/master/packages/hasura), [Appwrite](https://github.com/refinedev/refine/tree/master/packages/appwrite), [Nestjs-Query](https://github.com/refinedev/refine/tree/master/packages/nestjs-query), [Firebase](https://firebase.google.com/), [Sanity](https://www.sanity.io/), and [Directus](https://directus.io/).
- SSR support with Next.js & Remix and Advanced routing with any router library of your choice
- Auto-generation of CRUD UIs based on your API data structure
- Perfect state management & mutations with React Query
- Providers for seamless authentication and access control flows
- Out-of-the-box support for live / real-time applications
- Easy audit logs & document versioning
## Learning Refine
- Navigate to the [Tutorial](https://refine.dev/docs/tutorial/introduction/index/) on building comprehensive CRUD application guides you through each step of the process.
- Visit the [Guides & Concepts](https://refine.dev/docs/guides-concepts/general-concepts/) to get informed about the fundamental concepts.
- Read more on [Advanced Tutorials](https://refine.dev/docs/advanced-tutorials/) for different usage scenarios.
## Contribution
[Refer to the contribution docs for more information.](https://refine.dev/docs/contributing/#ways-to-contribute)
If you have any doubts related to the project or want to discuss something, then join our [Discord server](https://discord.gg/refine).
## Contributors ♥️ Thanks
We extend our gratitude to all our numerous contributors who create plugins, assist with issues and pull requests, and respond to questions on Discord and GitHub Discussions.
Refine is a community-driven project, and your contributions continually improve it.
<br/>
<a href="https://github.com/refinedev/refine/graphs/contributors">
<img src="https://contrib.rocks/image?repo=refinedev/refine&max=400&columns=20" />
</a>
## License
Licensed under the MIT License, Copyright © 2021-present Refinedev

View File

@@ -0,0 +1,35 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("./tsconfig.json");
const paths = compilerOptions.paths ? compilerOptions.paths : {};
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
rootDir: "./",
setupFilesAfterEnv: ["<rootDir>/test/jest.setup.ts"],
testPathIgnorePatterns: [
"<rootDir>/node_modules/",
"<rootDir>/example/",
"<rootDir>/dist/",
],
moduleNameMapper: {
...pathsToModuleNameMapper(paths, { prefix: "<rootDir>/" }),
"\\.css$": "identity-obj-proxy",
},
displayName: "core",
transform: {
"^.+\\.svg$": "<rootDir>/test/svgTransform.ts",
"^.+\\.tsx?$": [
"ts-jest",
{
tsconfig: "<rootDir>/tsconfig.test.json",
diagnostics: {
ignoreCodes: [2578],
},
},
],
},
coveragePathIgnorePatterns: ["<rootDir>/src/index.ts"],
testEnvironment: "jsdom",
};

View File

@@ -0,0 +1,84 @@
{
"name": "@refinedev/core",
"version": "4.56.0",
"private": false,
"description": "refine is a React-based framework for building internal tools, rapidly. It ships with Ant Design System, an enterprise-level UI toolkit.",
"repository": {
"type": "git",
"url": "https://github.com/refinedev/refine.git",
"directory": "packages/core"
},
"license": "MIT",
"author": "refine",
"sideEffects": false,
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"typings": "dist/index.d.ts",
"scripts": {
"attw": "attw --pack .",
"build": "tsup && node ../shared/generate-declarations.js",
"dev": "tsup --watch",
"prepare": "pnpm build",
"publint": "publint --strict=true --level=suggestion",
"test": "jest --passWithNoTests --runInBand",
"test:dev": "jest --silent --noStackTrace",
"types": "node ../shared/generate-declarations.js"
},
"dependencies": {
"@refinedev/devtools-internal": "1.1.14",
"@tanstack/react-query": "^4.10.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"papaparse": "^5.3.0",
"pluralize": "^8.0.0",
"qs": "^6.10.1",
"tslib": "^2.6.2",
"warn-once": "^0.1.0"
},
"devDependencies": {
"@esbuild-plugins/node-resolve": "^0.1.4",
"@testing-library/dom": "^8.5.0",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1.1",
"@testing-library/react-hooks": "^8.0.0",
"@testing-library/user-event": "^14.1.1",
"@types/jest": "^29.2.4",
"@types/lodash": "^4.14.171",
"@types/papaparse": "^5.2.5",
"@types/pluralize": "^0.0.29",
"@types/qs": "^6.9.7",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/react-router-dom": "^5.1.8",
"@types/testing-library__jest-dom": "^5.14.3",
"graphql": "^15.6.1",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"ts-jest": "^29.1.2",
"tsup": "^6.7.0",
"typescript": "^5.4.2"
},
"peerDependencies": {
"@tanstack/react-query": "^4.10.1",
"@types/react": "^17.0.0 || ^18.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,125 @@
const { getImports, appendAfterImports } = require("@refinedev/cli");
/** @type {import('@refinedev/cli').RefineConfig} */
module.exports = {
group: "UI Framework",
swizzle: {
items: [
{
group: "Pages",
label: "ErrorPage",
message: `
**\`Info:\`**
If you want to see an example of error page in use, you can refer to the documentation at https://refine.dev/docs/packages/documentation/routers
`,
files: [
{
src: "./src/components/pages/error/index.tsx",
dest: "./components/pages/error.tsx",
},
],
},
{
group: "Pages",
label: "AuthPage",
message: `
**\`Info:\`**
If you want to see examples of authentication pages in use, you can refer to the documentation at https://refine.dev/docs/packages/documentation/routers
`,
files: [
{
src: "./src/components/pages/auth/index.tsx",
dest: "./components/pages/auth/index.tsx",
},
{
src: "./src/components/pages/auth/components/forgotPassword/index.tsx",
dest: "./components/pages/auth/components/forgotPassword.tsx",
},
{
src: "./src/components/pages/auth/components/login/index.tsx",
dest: "./components/pages/auth/components/login.tsx",
},
{
src: "./src/components/pages/auth/components/register/index.tsx",
dest: "./components/pages/auth/components/register.tsx",
},
{
src: "./src/components/pages/auth/components/updatePassword/index.tsx",
dest: "./components/pages/auth/components/updatePassword.tsx",
},
{
src: "./src/components/pages/auth/components/index.ts",
dest: "./components/pages/auth/components/index.ts",
},
],
},
{
group: "Other",
label: "CanAccess",
files: [
{
src: "./src/components/canAccess/index.tsx",
dest: "./components/canAccess.tsx",
},
],
},
{
group: "Other",
label: "Authenticated",
files: [
{
src: "./src/components/authenticated/index.tsx",
dest: "./components/authenticated.tsx",
},
],
},
{
group: "Other",
label: "AutoSaveIndicator",
files: [
{
src: "./src/components/autoSaveIndicator/index.tsx",
dest: "./components/autoSaveIndicator.tsx",
},
],
},
],
transform: (content) => {
let newContent = content;
const imports = getImports(content);
imports.map((importItem) => {
if (
importItem.importPath.includes("/types") ||
importItem.importPath === "@hooks" ||
importItem.importPath === "@hooks/translate" ||
importItem.importPath === "@definitions/index" ||
importItem.importPath === "@definitions/helpers"
) {
const newStatement = `import ${importItem.namedImports} from "@refinedev/core";`;
newContent = newContent.replace(importItem.statement, newStatement);
}
if (importItem.importPath === "../..") {
const typesLine = `
type DivPropsType = React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
type FormPropsType = React.DetailedHTMLProps<
React.FormHTMLAttributes<HTMLFormElement>,
HTMLFormElement
>;
`;
newContent = newContent.replace(importItem.statement, "");
newContent = appendAfterImports(newContent, typesLine);
}
});
return newContent;
},
},
};

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

View File

@@ -0,0 +1,46 @@
import React, { type PropsWithChildren } from "react";
import type {
IAccessControlContext,
IAccessControlContextReturnType,
} from "./types";
/** @deprecated default value for access control context has no use and is an empty object. */
export const defaultAccessControlContext = {} as IAccessControlContext;
export const AccessControlContext =
React.createContext<IAccessControlContextReturnType>({
options: {
buttons: { enableAccessControl: true, hideIfUnauthorized: false },
},
});
export const AccessControlContextProvider: React.FC<
PropsWithChildren<IAccessControlContext>
> = ({ can, children, options }) => {
return (
<AccessControlContext.Provider
value={{
can,
options: options
? {
...options,
buttons: {
enableAccessControl: true,
hideIfUnauthorized: false,
...options.buttons,
},
}
: {
buttons: {
enableAccessControl: true,
hideIfUnauthorized: false,
},
queryOptions: undefined,
},
}}
>
{children}
</AccessControlContext.Provider>
);
};

View File

@@ -0,0 +1,93 @@
/**
* @author aliemir
*
* `AccessControlProvider` interface, used to define the access control bindings of refine.
*
* Currently, there's no change in the interface, but only in the `params.resource` property.
*
* This also had `{ children?: ITreeMenu[] }` type extension but we can remove it now.
*
* There's an error behind this extension, since we're using `Tanstack Query` to check the `can` function,
* params are stringified and Nodes can't be stringified properly, which throws an error.
*
* These kinds of errors should be handled by the user of the `can` function, not by the `can` function itself.
*
* In this case, its the `CanAccess` component, which wraps the `can` function and is used in the `Sider` components.
* `Sider` should sanitize the `params.resource` property and remove the `children` property (if exists).
*
* This may also apply to `resource.icon` property.
*
*/
import type { UseQueryOptions } from "@tanstack/react-query";
import type { BaseKey } from "../data/types";
import type { IResourceItem, ITreeMenu } from "../resource/types";
export type CanResponse = {
can: boolean;
reason?: string;
[key: string]: unknown;
};
export type CanParams = {
/**
* 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?: {
resource?: IResourceItem & { children?: ITreeMenu[] };
id?: BaseKey;
[key: string]: any;
};
};
export type CanReturnType = {
can: boolean;
reason?: string;
};
export type CanFunction = ({
resource,
action,
params,
}: CanParams) => Promise<CanReturnType>;
type AccessControlOptions = {
buttons?: {
enableAccessControl?: boolean;
hideIfUnauthorized?: boolean;
};
queryOptions?: UseQueryOptions<CanReturnType>;
};
export interface IAccessControlContext {
can?: CanFunction;
options?: AccessControlOptions;
}
export type IAccessControlContextReturnType = {
can?: CanFunction;
options: {
buttons: {
enableAccessControl: boolean;
hideIfUnauthorized: boolean;
};
queryOptions?: UseQueryOptions<CanReturnType>;
};
};
export type AccessControlProvider = {
can: CanFunction;
options?: AccessControlOptions;
};

View File

@@ -0,0 +1,15 @@
import React, { type PropsWithChildren } from "react";
import type { IAuditLogContext } from "./types";
export const AuditLogContext = React.createContext<IAuditLogContext>({});
export const AuditLogContextProvider: React.FC<
PropsWithChildren<IAuditLogContext>
> = ({ create, get, update, children }) => {
return (
<AuditLogContext.Provider value={{ create, get, update }}>
{children}
</AuditLogContext.Provider>
);
};

View File

@@ -0,0 +1,45 @@
import type { BaseKey, MetaDataQuery } from "../data/types";
export type ILog<TData = any> = {
id: BaseKey;
createdAt: string;
author?: Record<number | string, any>;
name?: string;
data: TData;
previousData: TData;
resource: string;
action: string;
meta?: Record<number | string, any>;
};
export type ILogData<TData = any> = ILog<TData>[];
export type LogParams = {
resource: string;
action: string;
data?: any;
author?: {
name?: string;
[key: string]: any;
};
previousData?: any;
meta: Record<number | string, any>;
};
export type IAuditLogContext = {
create?: (params: LogParams) => Promise<any>;
get?: (params: {
resource: string;
action?: string;
meta?: Record<number | string, any>;
author?: Record<number | string, any>;
metaData?: MetaDataQuery;
}) => Promise<any>;
update?: (params: {
id: BaseKey;
name: string;
[key: string]: any;
}) => Promise<any>;
};
export type AuditLogProvider = Required<IAuditLogContext>;

View File

@@ -0,0 +1,200 @@
import React, { type PropsWithChildren } from "react";
import { useNavigation } from "@hooks";
import type { IAuthContext, ILegacyAuthContext } from "./types";
/**
* @deprecated `LegacyAuthContext` is deprecated with refine@4, use `AuthBindingsContext` instead, however, we still support `LegacyAuthContext` for backward compatibility.
*/
export const LegacyAuthContext = React.createContext<ILegacyAuthContext>({});
/**
* @deprecated `LegacyAuthContextProvider` is deprecated with refine@4, use `AuthBindingsContextProvider` instead, however, we still support `LegacyAuthContextProvider` for backward compatibility.
*/
export const LegacyAuthContextProvider: React.FC<
PropsWithChildren<ILegacyAuthContext>
> = ({ children, isProvided, ...authOperations }) => {
const { replace } = useNavigation();
const loginFunc = async (params: any) => {
try {
const result = await authOperations.login?.(params);
return result;
} catch (error) {
return Promise.reject(error);
}
};
const registerFunc = async (params: any) => {
try {
const result = await authOperations.register?.(params);
return result;
} catch (error) {
return Promise.reject(error);
}
};
const logoutFunc = async (params: any) => {
try {
const redirectPath = await authOperations.logout?.(params);
return redirectPath;
} catch (error) {
return Promise.reject(error);
}
};
const checkAuthFunc = async (params: any) => {
try {
await authOperations.checkAuth?.(params);
return Promise.resolve();
} catch (error) {
if ((error as { redirectPath?: string })?.redirectPath) {
replace((error as { redirectPath: string }).redirectPath);
}
return Promise.reject(error);
}
};
return (
<LegacyAuthContext.Provider
value={{
...authOperations,
login: loginFunc,
logout: logoutFunc,
checkAuth: checkAuthFunc,
register: registerFunc,
isProvided,
}}
>
{children}
</LegacyAuthContext.Provider>
);
};
export const AuthBindingsContext = React.createContext<Partial<IAuthContext>>(
{},
);
export const AuthBindingsContextProvider: React.FC<
PropsWithChildren<IAuthContext>
> = ({ children, isProvided, ...authBindings }) => {
const handleLogin = async (params: unknown) => {
try {
const result = await authBindings.login?.(params);
return result;
} catch (error) {
console.warn(
"Unhandled Error in login: refine always expects a resolved promise.",
error,
);
return Promise.reject(error);
}
};
const handleRegister = async (params: unknown) => {
try {
const result = await authBindings.register?.(params);
return result;
} catch (error) {
console.warn(
"Unhandled Error in register: refine always expects a resolved promise.",
error,
);
return Promise.reject(error);
}
};
const handleLogout = async (params: unknown) => {
try {
const result = await authBindings.logout?.(params);
return result;
} catch (error) {
console.warn(
"Unhandled Error in logout: refine always expects a resolved promise.",
error,
);
return Promise.reject(error);
}
};
const handleCheck = async (params: unknown) => {
try {
const result = await authBindings.check?.(params);
return Promise.resolve(result);
} catch (error) {
console.warn(
"Unhandled Error in check: refine always expects a resolved promise.",
error,
);
return Promise.reject(error);
}
};
const handleForgotPassword = async (params: unknown) => {
try {
const result = await authBindings.forgotPassword?.(params);
return Promise.resolve(result);
} catch (error) {
console.warn(
"Unhandled Error in forgotPassword: refine always expects a resolved promise.",
error,
);
return Promise.reject(error);
}
};
const handleUpdatePassword = async (params: unknown) => {
try {
const result = await authBindings.updatePassword?.(params);
return Promise.resolve(result);
} catch (error) {
console.warn(
"Unhandled Error in updatePassword: refine always expects a resolved promise.",
error,
);
return Promise.reject(error);
}
};
return (
<AuthBindingsContext.Provider
value={{
...authBindings,
login: handleLogin as IAuthContext["login"],
logout: handleLogout as IAuthContext["logout"],
check: handleCheck as IAuthContext["check"],
register: handleRegister as IAuthContext["register"],
forgotPassword: handleForgotPassword as IAuthContext["forgotPassword"],
updatePassword: handleUpdatePassword as IAuthContext["updatePassword"],
isProvided,
}}
>
{children}
</AuthBindingsContext.Provider>
);
};
/**
* @deprecated `useLegacyAuthContext` is deprecated with refine@4, use `useAuthBindingsContext` instead, however, we still support `useLegacyAuthContext` for backward compatibility.
*/
export const useLegacyAuthContext = () => {
const context = React.useContext(LegacyAuthContext);
return context;
};
export const useAuthBindingsContext = () => {
const context = React.useContext(AuthBindingsContext);
return context;
};

View File

@@ -0,0 +1,115 @@
/**
* @author aliemir
*
* In the current internal structure, sometimes we pass params and args from one function to another,
* like in case of `check` (formerly `checkAuth`) function, we pass the reject value to `useLogout` hook,
* which handles the redirect after logout.
*
* These actions should be separated,
*
* Apps can exist with an optional auth,
* or do not redirect after logout,
* or do the redirect but not log out,
* or do the redirect to a different page than `/login`.
*
* To cover all those cases, we should return more information from auth functions.
*
* Let's say, they should always resolve, even if user is not authenticated,
* but have the proper information to handle the situation.
*
* like `authenticated: false`, `redirect: '/login'` and `logout: true`
* which will inform refine that user is not authenticated and should be redirected to `/login` and logout.
* In some cases, redirect might need to be transferred to other hooks (like `useLogout` hook),
* but these cases can be handled internally.
*
* If the response from `check` is `{ authenticated: false, logout: false, redirect: "/not-authenticated" }`,
* then the user will be redirected to `/not-authenticated` without logging out.
*
* If the response from `check` is `{ authenticated: false, logout: true, redirect: false }`,
* then the user will be logged out without redirecting.
*
* Same goes for `onError` function, it should always resolve.
*/
import type { RefineError } from "../data/types";
export type CheckResponse = {
authenticated: boolean;
redirectTo?: string;
logout?: boolean;
error?: RefineError | Error;
};
export type OnErrorResponse = {
redirectTo?: string;
logout?: boolean;
error?: RefineError | Error;
};
export type SuccessNotificationResponse = {
message: string;
description?: string;
};
export type AuthActionResponse = {
success: boolean;
redirectTo?: string;
error?: RefineError | Error;
[key: string]: unknown;
successNotification?: SuccessNotificationResponse;
};
export type PermissionResponse = unknown;
export type IdentityResponse = unknown;
export type AuthProvider = {
login: (params: any) => Promise<AuthActionResponse>;
logout: (params: any) => Promise<AuthActionResponse>;
check: (params?: any) => Promise<CheckResponse>;
onError: (error: any) => Promise<OnErrorResponse>;
register?: (params: any) => Promise<AuthActionResponse>;
forgotPassword?: (params: any) => Promise<AuthActionResponse>;
updatePassword?: (params: any) => Promise<AuthActionResponse>;
getPermissions?: (
params?: Record<string, any>,
) => Promise<PermissionResponse>;
getIdentity?: (params?: any) => Promise<IdentityResponse>;
};
/**
* @deprecated use `AuthProvider` instead.
*/
export type AuthBindings = AuthProvider;
export interface IAuthContext extends Partial<AuthProvider> {
isProvided: boolean;
}
export type TLogoutData = void | false | string;
export type TLoginData = void | false | string | object;
export type TRegisterData = void | false | string;
export type TForgotPasswordData = void | false | string;
export type TUpdatePasswordData = void | false | string;
/**
* @deprecated `LegacyAuthProvider` is deprecated with refine@4, use `AuthProvider` instead, however, we still support `LegacyAuthProvider` for backward compatibility.
*/
export interface LegacyAuthProvider {
login: (params: any) => Promise<TLoginData>;
register?: (params: any) => Promise<TRegisterData>;
forgotPassword?: (params: any) => Promise<TForgotPasswordData>;
updatePassword?: (params: any) => Promise<TUpdatePasswordData>;
logout: (params: any) => Promise<TLogoutData>;
checkAuth: (params?: any) => Promise<any>;
checkError: (error: any) => Promise<void>;
getPermissions?: (params?: Record<string, any>) => Promise<any>;
getUserIdentity?: (params?: any) => Promise<any>;
}
/**
* @deprecated `ILegacyAuthContext` is deprecated with refine@4, use `IAuthContext` instead, however, we still support `ILegacyAuthContext` for backward compatibility.
*/
export interface ILegacyAuthContext extends Partial<LegacyAuthProvider> {
isProvided?: boolean;
}

View File

@@ -0,0 +1,40 @@
import React, { type PropsWithChildren } from "react";
import type { DataProvider, DataProviders, IDataContext } from "./types";
export const defaultDataProvider: DataProviders = {
default: {} as DataProvider,
};
export const DataContext =
React.createContext<IDataContext>(defaultDataProvider);
type Props = PropsWithChildren<{
dataProvider?: DataProvider | DataProviders;
}>;
export const DataContextProvider: React.FC<Props> = ({
children,
dataProvider,
}) => {
let providerValue = defaultDataProvider;
if (dataProvider) {
if (
!("default" in dataProvider) &&
("getList" in dataProvider || "getOne" in dataProvider)
) {
providerValue = {
default: dataProvider,
};
} else {
providerValue = dataProvider;
}
}
return (
<DataContext.Provider value={providerValue}>
{children}
</DataContext.Provider>
);
};

View File

@@ -0,0 +1,564 @@
import type { QueryFunctionContext, QueryKey } from "@tanstack/react-query";
import type { DocumentNode } from "graphql";
import type { UseListConfig } from "../../hooks/data/useList";
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export type BaseKey = string | number;
export type BaseRecord = {
id?: BaseKey;
[key: string]: any;
};
export type BaseOption = {
label: any;
value: any;
};
/**
* @deprecated Use `BaseOption` instead.
*/
export interface Option extends BaseOption {}
export type NestedField = {
operation: string;
variables: QueryBuilderOptions[];
fields: Fields;
};
export type Fields = Array<string | object | NestedField>;
export type VariableOptions =
| {
type?: string;
name?: string;
value: any;
list?: boolean;
required?: boolean;
}
| { [k: string]: any };
export interface QueryBuilderOptions {
operation?: string;
fields?: Fields;
variables?: VariableOptions;
}
export type GraphQLQueryOptions = {
/**
* @description GraphQL query to be used by data providers.
* @optional
* @example
* ```tsx
* import gql from 'graphql-tag'
* import { useOne } from '@refinedev/core'
*
* const PRODUCT_QUERY = gql`
* query GetProduct($id: ID!) {
* product(id: $id) {
* id
* name
* }
* }
* `
*
* useOne({
* id: 1,
* meta: { gqlQuery: PRODUCT_QUERY }
* })
* ```
*/
gqlQuery?: DocumentNode;
/**
* @description GraphQL mutation to be used by data providers.
* @optional
* @example
* ```tsx
* import gql from 'graphql-tag'
* import { useCreate } from '@refinedev/core'
*
* const PRODUCT_CREATE_MUTATION = gql`
* mutation CreateProduct($input: CreateOneProductInput!) {
* createProduct(input: $input) {
* id
* name
* }
* }
* `
* const { mutate } = useCreate()
*
* mutate({
* values: { name: "My Product" },
* meta: { gqlQuery: PRODUCT_QUERY }
* })
* ```
*/
gqlMutation?: DocumentNode;
/**
* @description GraphQL Variables to be used for more advanced query filters by data providers. If filters correspond to table columns,
* these variables will not be presented in the initial filters selected and will not be reset or set by table column filtering.
* @optional
* @example
* ```tsx
* import gql from "graphql-tag";
* import { useTable } from "@refinedev/antd";
* import type { GetFieldsFromList } from "@refinedev/hasura";
* import type { GetPostsQuery } from "graphql/types";
*
* const POSTS_QUERY = gql`
* query GetPosts(
* $offset: Int!
* $limit: Int!
* $order_by: [posts_order_by!]
* $where: posts_bool_exp
* ) {
* posts(
* offset: $offset
* limit: $limit
* order_by: $order_by
* where: $where
* ) {
* id
* title
* category {
* id
* title
* }
* }
* posts_aggregate(where: $where) {
* aggregate {
* count
* }
* }
* } `;
*
*
* export const PostList = () => {
* const { tableProps } = useTable<
* GetFieldsFromList<GetPostsQuery>
* >({
* meta: {
* gqlQuery: POSTS_QUERY,
* gqlVariables: {
* where: {
* _and: [
* {
* title: {
* _ilike: "%Updated%",
* },
* },
* {
* created_at: {
* _gte: "2023-08-04T08:26:26.489116+00:00"
* }
* }
* ],
* },
* },
* }
* });
* return ( <Table {...tableProps}/>);
* }
*
* ```
*/
gqlVariables?: {
[key: string]: any;
};
};
export type MetaQuery = {
[k: string]: any;
queryContext?: Omit<QueryFunctionContext, "meta">;
} & QueryBuilderOptions &
GraphQLQueryOptions;
export interface Pagination {
/**
* Initial page index
* @default 1
*/
current?: number;
/**
* Initial number of items per page
* @default 10
*/
pageSize?: number;
/**
* Whether to use server side pagination or not.
* @default "server"
*/
mode?: "client" | "server" | "off";
}
/**
* @deprecated `MetaDataQuery` is deprecated with refine@4, use `MetaQuery` instead, however, we still support `MetaDataQuery` for backward compatibility.
*/
export type MetaDataQuery = {
[k: string]: any;
queryContext?: Omit<QueryFunctionContext, "meta">;
} & QueryBuilderOptions;
export interface IQueryKeys {
all: QueryKey;
resourceAll: QueryKey;
list: (
config?:
| UseListConfig
| {
pagination?: Required<Pagination>;
hasPagination?: boolean;
sorters?: CrudSort[];
filters?: CrudFilter[];
}
| undefined,
) => QueryKey;
many: (ids?: BaseKey[]) => QueryKey;
detail: (id?: BaseKey) => QueryKey;
logList: (meta?: Record<number | string, any>) => QueryKey;
}
export interface ValidationErrors {
[field: string]:
| string
| string[]
| boolean
| { key: string; message: string };
}
export interface HttpError extends Record<string, any> {
message: string;
statusCode: number;
errors?: ValidationErrors;
}
export type RefineError = HttpError;
export type MutationMode = "pessimistic" | "optimistic" | "undoable";
export type QueryResponse<T = BaseRecord> =
| GetListResponse<T>
| GetOneResponse<T>;
export type PreviousQuery<TData> = [QueryKey, TData | unknown];
export type PrevContext<TData> = {
previousQueries: PreviousQuery<TData>[];
/**
* @deprecated `QueryKeys` is deprecated in favor of `keys`. Please use `keys` instead to construct query keys for queries and mutations.
*/
queryKey: IQueryKeys;
};
export type Context = {
previousQueries: ContextQuery[];
};
export type ContextQuery<T = BaseRecord> = {
query: QueryResponse<T>;
queryKey: QueryKey;
};
// Filters are used as a suffix of a field name:
// | Filter | Description |
// | ------------------- | --------------------------------- |
// | `eq` | Equal |
// | ne | Not equal |
// | lt | Less than |
// | gt | Greater than |
// | lte | Less than or equal to |
// | gte | Greater than or equal to |
// | in | Included in an array |
// | nin | Not included in an array |
// | contains | Contains |
// | ncontains | Doesn't contain |
// | containss | Contains, case sensitive |
// | ncontainss | Doesn't contain, case sensitive |
// | null | Is null or not null |
// | startswith | Starts with |
// | nstartswith | Doesn't start with |
// | startswiths | Starts with, case sensitive |
// | nstartswiths | Doesn't start with, case sensitive|
// | endswith | Ends with |
// | nendswith | Doesn't end with |
// | endswiths | Ends with, case sensitive |
// | nendswiths | Doesn't end with, case sensitive |
export type CrudOperators =
| "eq"
| "ne"
| "lt"
| "gt"
| "lte"
| "gte"
| "in"
| "nin"
| "ina"
| "nina"
| "contains"
| "ncontains"
| "containss"
| "ncontainss"
| "between"
| "nbetween"
| "null"
| "nnull"
| "startswith"
| "nstartswith"
| "startswiths"
| "nstartswiths"
| "endswith"
| "nendswith"
| "endswiths"
| "nendswiths"
| "or"
| "and";
export type SortOrder = "desc" | "asc" | null;
export type LogicalFilter = {
field: string;
operator: Exclude<CrudOperators, "or" | "and">;
value: any;
};
export type ConditionalFilter = {
key?: string;
operator: Extract<CrudOperators, "or" | "and">;
value: (LogicalFilter | ConditionalFilter)[];
};
export type CrudFilter = LogicalFilter | ConditionalFilter;
export type CrudSort = {
field: string;
order: "asc" | "desc";
};
export type CrudFilters = CrudFilter[];
export type CrudSorting = CrudSort[];
export interface CustomResponse<TData = BaseRecord> {
data: TData;
}
export interface GetListResponse<TData = BaseRecord> {
data: TData[];
total: number;
[key: string]: any;
}
export interface CreateResponse<TData = BaseRecord> {
data: TData;
}
export interface CreateManyResponse<TData = BaseRecord> {
data: TData[];
}
export interface UpdateResponse<TData = BaseRecord> {
data: TData;
}
export interface UpdateManyResponse<TData = BaseRecord> {
data: TData[];
}
export interface GetOneResponse<TData = BaseRecord> {
data: TData;
}
export interface GetManyResponse<TData = BaseRecord> {
data: TData[];
}
export interface DeleteOneResponse<TData = BaseRecord> {
data: TData;
}
export interface DeleteManyResponse<TData = BaseRecord> {
data: TData[];
}
export interface GetListParams {
resource: string;
pagination?: Pagination;
/**
* @deprecated `hasPagination` is deprecated, use `pagination.mode` instead.
*/
hasPagination?: boolean;
/**
* @deprecated `sort` is deprecated, use `sorters` instead.
*/
sort?: CrudSort[];
sorters?: CrudSort[];
filters?: CrudFilter[];
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
dataProviderName?: string;
}
export interface GetManyParams {
resource: string;
ids: BaseKey[];
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
dataProviderName?: string;
}
export interface GetOneParams {
resource: string;
id: BaseKey;
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
}
export interface CreateParams<TVariables = {}> {
resource: string;
variables: TVariables;
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
}
export interface CreateManyParams<TVariables = {}> {
resource: string;
variables: TVariables[];
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
}
export interface UpdateParams<TVariables = {}> {
resource: string;
id: BaseKey;
variables: TVariables;
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
}
export interface UpdateManyParams<TVariables = {}> {
resource: string;
ids: BaseKey[];
variables: TVariables;
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
}
export interface DeleteOneParams<TVariables = {}> {
resource: string;
id: BaseKey;
variables?: TVariables;
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
}
export interface DeleteManyParams<TVariables = {}> {
resource: string;
ids: BaseKey[];
variables?: TVariables;
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
}
export interface CustomParams<TQuery = unknown, TPayload = unknown> {
url: string;
method: "get" | "delete" | "head" | "options" | "post" | "put" | "patch";
/**
* @deprecated `sort` is deprecated, use `sorters` instead.
*/
sort?: CrudSort[];
sorters?: CrudSort[];
filters?: CrudFilter[];
payload?: TPayload;
query?: TQuery;
headers?: {};
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
}
export type DataProvider = {
getList: <TData extends BaseRecord = BaseRecord>(
params: GetListParams,
) => Promise<GetListResponse<TData>>;
getMany?: <TData extends BaseRecord = BaseRecord>(
params: GetManyParams,
) => Promise<GetManyResponse<TData>>;
getOne: <TData extends BaseRecord = BaseRecord>(
params: GetOneParams,
) => Promise<GetOneResponse<TData>>;
create: <TData extends BaseRecord = BaseRecord, TVariables = {}>(
params: CreateParams<TVariables>,
) => Promise<CreateResponse<TData>>;
createMany?: <TData extends BaseRecord = BaseRecord, TVariables = {}>(
params: CreateManyParams<TVariables>,
) => Promise<CreateManyResponse<TData>>;
update: <TData extends BaseRecord = BaseRecord, TVariables = {}>(
params: UpdateParams<TVariables>,
) => Promise<UpdateResponse<TData>>;
updateMany?: <TData extends BaseRecord = BaseRecord, TVariables = {}>(
params: UpdateManyParams<TVariables>,
) => Promise<UpdateManyResponse<TData>>;
deleteOne: <TData extends BaseRecord = BaseRecord, TVariables = {}>(
params: DeleteOneParams<TVariables>,
) => Promise<DeleteOneResponse<TData>>;
deleteMany?: <TData extends BaseRecord = BaseRecord, TVariables = {}>(
params: DeleteManyParams<TVariables>,
) => Promise<DeleteManyResponse<TData>>;
getApiUrl: () => string;
custom?: <
TData extends BaseRecord = BaseRecord,
TQuery = unknown,
TPayload = unknown,
>(
params: CustomParams<TQuery, TPayload>,
) => Promise<CustomResponse<TData>>;
};
export type DataProviders = {
default: DataProvider;
[key: string]: DataProvider;
};
export type IDataContext = DataProviders;
export type DataBindings = DataProvider | DataProviders;

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { useGetLocale, useTranslate } from "@hooks";
import { render } from "@test";
import { I18nContextProvider } from "./";
describe("I18nContext", () => {
const TestComponent = () => {
const locale = useGetLocale();
const translate = useTranslate();
return (
<div>
<span>{`Current language: ${locale()}`}</span>
<span>
{translate("undefined key", { name: "test" }, "hello test")}
</span>
</div>
);
};
const customRender = (ui: any, providerProps?: any) => {
return render(
<I18nContextProvider {...providerProps}>{ui}</I18nContextProvider>,
providerProps,
);
};
it("should get value from I18nContext ", () => {
const providerProps = {
i18nProvider: {
translate: () => "hello",
changeLocale: () => Promise.resolve(),
getLocale: () => "tr",
},
};
const { getByText } = customRender(<TestComponent />, providerProps);
expect(getByText("Current language: tr"));
});
it("should get options value from I18nContext ", () => {
const providerProps = {
i18nProvider: {
translate: (key: string, options: any) => `hello ${options.name}`,
changeLocale: () => Promise.resolve(),
getLocale: () => "tr",
},
};
const { getByText } = customRender(<TestComponent />, providerProps);
expect(getByText("hello test")).toBeTruthy();
});
});

View File

@@ -0,0 +1,23 @@
import React, { type PropsWithChildren } from "react";
import type { I18nProvider, II18nContext } from "./types";
/** @deprecated default value for translation context has no use and is an empty object. */
export const defaultProvider: Partial<I18nProvider> = {};
export const I18nContext = React.createContext<II18nContext>({});
export const I18nContextProvider: React.FC<PropsWithChildren<II18nContext>> = ({
children,
i18nProvider,
}) => {
return (
<I18nContext.Provider
value={{
i18nProvider,
}}
>
{children}
</I18nContext.Provider>
);
};

View File

@@ -0,0 +1,22 @@
type TranslateFunction = (
key: string,
options?: any,
defaultMessage?: string,
) => string;
type ChangeLocaleFunction = (
locale: string,
options?: any,
) => Promise<any> | any;
type GetLocaleFunction = () => string;
export type I18nProvider = {
translate: TranslateFunction;
changeLocale: ChangeLocaleFunction;
getLocale: GetLocaleFunction;
};
export interface II18nContext {
i18nProvider?: I18nProvider;
}

View File

@@ -0,0 +1,16 @@
import React, { type PropsWithChildren } from "react";
import type { ILiveContext } from "./types";
export const LiveContext = React.createContext<ILiveContext>({});
export const LiveContextProvider: React.FC<PropsWithChildren<ILiveContext>> = ({
liveProvider,
children,
}) => {
return (
<LiveContext.Provider value={{ liveProvider }}>
{children}
</LiveContext.Provider>
);
};

View File

@@ -0,0 +1,113 @@
import type {
BaseKey,
CrudFilter,
CrudSort,
MetaQuery,
Pagination,
} from "../data/types";
export type LiveEvent = {
channel: string;
type: "deleted" | "updated" | "created" | "*" | string;
payload: {
ids?: BaseKey[];
[x: string]: any;
};
date: Date;
meta?: MetaQuery & {
dataProviderName?: string;
};
};
export type LiveModeProps = {
/**
* Whether to update data automatically ("auto") or not ("manual") if a related live event is received. The "off" value is used to avoid creating a subscription.
* @type [`"auto" | "manual" | "off"`](/docs/api-reference/core/providers/live-provider/#livemode)
* @default `"off"`
*/
liveMode?: "auto" | "manual" | "off";
/**
* Callback to handle all related live events of this hook.
* @type [`(event: LiveEvent) => void`](/docs/api-reference/core/interfaceReferences/#livemodeprops)
* @default `undefined`
*/
onLiveEvent?: (event: LiveEvent) => void;
/**
* Params to pass to liveProvider's subscribe method if liveMode is enabled.
* @type [`{ ids?: BaseKey[]; [key: string]: any; }`](/docs/api-reference/core/interfaceReferences/#livemodeprops)
* @default `undefined`
*/
liveParams?: {
ids?: BaseKey[];
[key: string]: any;
};
};
export type ILiveModeContextProvider = LiveModeProps;
export type LiveListParams = {
resource?: string;
pagination?: Pagination;
hasPagination?: boolean;
sorters?: CrudSort[];
filters?: CrudFilter[];
meta?: MetaQuery;
metaData?: MetaQuery;
};
export type LiveOneParams = {
resource?: string;
id?: BaseKey;
};
export type LiveManyParams = {
resource?: string;
ids?: BaseKey[];
};
export type LiveCommonParams = {
subscriptionType?: "useList" | "useOne" | "useMany";
[key: string]: unknown;
};
type LiveSubscribeOptions = {
channel: string;
types: Array<LiveEvent["type"]>;
callback: (event: LiveEvent) => void;
params?: LiveCommonParams & LiveListParams & LiveOneParams & LiveManyParams;
};
type LiveDeprecatedSubscribeOptions = {
/**
* @deprecated use `meta.dataProviderName` instead.
*/
dataProviderName?: string;
/**
* @deprecated `params.meta` is depcerated. Use `meta` directly from the root level instead.
*/
meta?: MetaQuery;
/**
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
/**
* @deprecated `hasPagination` is deprecated, use `pagination.mode` instead.
*/
hasPagination?: boolean;
/**
* @deprecated `sort` is deprecated, use `sorters` instead.
*/
sort?: CrudSort[];
};
export type LiveProvider = {
publish?: (event: LiveEvent) => void;
subscribe: (
options: LiveSubscribeOptions & LiveDeprecatedSubscribeOptions,
) => any;
unsubscribe: (subscription: any) => void;
};
export type ILiveContext = {
liveProvider?: LiveProvider;
};

View File

@@ -0,0 +1,44 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { MetaContextProvider, useMetaContext } from "./index";
import "@testing-library/jest-dom";
describe("MetaContextProvider", () => {
it("provides the correct meta context value to child components", () => {
const TestComponent = () => {
const meta = useMetaContext();
return <div>{meta.testKey}</div>;
};
render(
<MetaContextProvider value={{ testKey: "testValue" }}>
<TestComponent />
</MetaContextProvider>,
);
expect(screen.getByText("testValue")).toBeInTheDocument();
});
it("merges existing context value with new value", () => {
const TestComponent = () => {
const meta = useMetaContext();
return (
<>
<div>{meta.firstKey}</div>
<div>{meta.secondKey}</div>
</>
);
};
render(
<MetaContextProvider value={{ firstKey: "value1" }}>
<MetaContextProvider value={{ secondKey: "value2" }}>
<TestComponent />
</MetaContextProvider>
</MetaContextProvider>,
);
expect(screen.getByText("value1")).toBeInTheDocument();
expect(screen.getByText("value2")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,45 @@
import React, {
type ReactNode,
createContext,
useContext,
useMemo,
} from "react";
type MetaContextValue = Record<string, any>;
export const MetaContext = createContext<MetaContextValue>({});
/**
* Is used to provide meta data to the children components.
* @internal
*/
export const MetaContextProvider = ({
children,
value,
}: { children: ReactNode; value: MetaContextValue }) => {
const currentValue = useMetaContext();
const metaContext = useMemo(() => {
return {
...currentValue,
...value,
};
}, [currentValue, value]);
return (
<MetaContext.Provider value={metaContext}>{children}</MetaContext.Provider>
);
};
/**
* @internal
* @returns The MetaContext value.
*/
export const useMetaContext = () => {
const context = useContext(MetaContext);
if (!context) {
throw new Error("useMetaContext must be used within a MetaContextProvider");
}
return useContext(MetaContext);
};

View File

@@ -0,0 +1,18 @@
import React, { createContext, type PropsWithChildren } from "react";
import type { INotificationContext } from "./types";
/** @deprecated default value for notification context has no use and is an empty object. */
export const defaultNotificationProvider: INotificationContext = {};
export const NotificationContext = createContext<INotificationContext>({});
export const NotificationContextProvider: React.FC<
PropsWithChildren<INotificationContext>
> = ({ open, close, children }) => {
return (
<NotificationContext.Provider value={{ open, close }}>
{children}
</NotificationContext.Provider>
);
};

View File

@@ -0,0 +1,47 @@
export type SuccessErrorNotification<
TData = unknown,
TError = unknown,
TVariables = unknown,
> = {
/**
* Success notification configuration to be displayed when the mutation is successful.
* @default '"There was an error creating resource (status code: `statusCode`)" or "Error when updating resource (status code: statusCode)"'
*/
successNotification?:
| OpenNotificationParams
| false
| ((
data?: TData,
values?: TVariables,
resource?: string,
) => OpenNotificationParams | false | undefined);
/**
* Error notification configuration to be displayed when the mutation fails.
* @default '"There was an error creating resource (status code: `statusCode`)" or "Error when updating resource (status code: statusCode)"'
*/
errorNotification?:
| OpenNotificationParams
| false
| ((
error?: TError,
values?: TVariables,
resource?: string,
) => OpenNotificationParams | false | undefined);
};
export type OpenNotificationParams = {
key?: string;
message: string;
type: "success" | "error" | "progress";
description?: string;
cancelMutation?: () => void;
undoableTimeout?: number;
};
export interface INotificationContext {
open?: (params: OpenNotificationParams) => void;
close?: (key: string) => void;
}
export type NotificationProvider = Required<INotificationContext>;

View File

@@ -0,0 +1,128 @@
import React from "react";
import pluralize from "pluralize";
import { DefaultLayout } from "@components/layoutWrapper/defaultLayout";
import { humanizeString } from "../../definitions/helpers/humanizeString";
import type {
IRefineContext,
IRefineContextOptions,
IRefineContextProvider,
} from "./types";
import { LoginPage as DefaultLoginPage } from "@components/pages";
const defaultTitle: IRefineContextOptions["title"] = {
icon: (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-testid="refine-logo"
id="refine-default-logo"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.7889 0.422291C12.6627 -0.140764 11.3373 -0.140764 10.2111 0.422291L2.21115 4.42229C0.85601 5.09986 0 6.48491 0 8V16C0 17.5151 0.85601 18.9001 2.21115 19.5777L10.2111 23.5777C11.3373 24.1408 12.6627 24.1408 13.7889 23.5777L21.7889 19.5777C23.144 18.9001 24 17.5151 24 16V8C24 6.48491 23.144 5.09986 21.7889 4.42229L13.7889 0.422291ZM8 8C8 5.79086 9.79086 4 12 4C14.2091 4 16 5.79086 16 8V16C16 18.2091 14.2091 20 12 20C9.79086 20 8 18.2091 8 16V8Z"
fill="currentColor"
/>
<path
d="M14 8C14 9.10457 13.1046 10 12 10C10.8954 10 10 9.10457 10 8C10 6.89543 10.8954 6 12 6C13.1046 6 14 6.89543 14 8Z"
fill="currentColor"
/>
</svg>
),
text: "Refine Project",
};
export const defaultRefineOptions: IRefineContextOptions = {
mutationMode: "pessimistic",
syncWithLocation: false,
undoableTimeout: 5000,
warnWhenUnsavedChanges: false,
liveMode: "off",
redirect: {
afterCreate: "list",
afterClone: "list",
afterEdit: "list",
},
overtime: {
interval: 1000,
},
textTransformers: {
humanize: humanizeString,
plural: pluralize.plural,
singular: pluralize.singular,
},
disableServerSideValidation: false,
title: defaultTitle,
};
export const RefineContext = React.createContext<IRefineContext>({
hasDashboard: false,
mutationMode: "pessimistic",
warnWhenUnsavedChanges: false,
syncWithLocation: false,
undoableTimeout: 5000,
Title: undefined,
Sider: undefined,
Header: undefined,
Footer: undefined,
Layout: DefaultLayout,
OffLayoutArea: undefined,
liveMode: "off",
onLiveEvent: undefined,
options: defaultRefineOptions,
});
export const RefineContextProvider: React.FC<IRefineContextProvider> = ({
hasDashboard,
mutationMode,
warnWhenUnsavedChanges,
syncWithLocation,
undoableTimeout,
children,
DashboardPage,
Title,
Layout = DefaultLayout,
Header,
Sider,
Footer,
OffLayoutArea,
LoginPage = DefaultLoginPage,
catchAll,
liveMode = "off",
onLiveEvent,
options,
}) => {
return (
<RefineContext.Provider
value={{
__initialized: true,
hasDashboard,
mutationMode,
warnWhenUnsavedChanges,
syncWithLocation,
Title,
undoableTimeout,
Layout,
Header,
Sider,
Footer,
OffLayoutArea,
DashboardPage,
LoginPage,
catchAll,
liveMode,
onLiveEvent,
options,
}}
>
{children}
</RefineContext.Provider>
);
};

View File

@@ -0,0 +1,335 @@
import React, { type ReactNode } from "react";
import type { QueryClient, QueryClientConfig } from "@tanstack/react-query";
import type { RedirectAction } from "../../hooks/form/types";
import type { UseLoadingOvertimeRefineContext } from "../../hooks/useLoadingOvertime";
import type { AccessControlProvider } from "../accessControl/types";
import type { AuditLogProvider } from "../auditLog/types";
import type { AuthProvider, LegacyAuthProvider } from "../auth/types";
import type { DataProvider, DataProviders, MutationMode } from "../data/types";
import type { I18nProvider } from "../i18n/types";
import type { LiveModeProps, LiveProvider } from "../live/types";
import type { NotificationProvider } from "../notification/types";
import type { ResourceProps } from "../resource/types";
import type { LegacyRouterProvider } from "../router/legacy/types";
import type { RouterProvider } from "../router/types";
export type TitleProps = {
collapsed: boolean;
};
export type LayoutProps = {
Sider?: React.FC<{
Title?: React.FC<TitleProps>;
render?: (props: {
items: JSX.Element[];
logout: React.ReactNode;
dashboard: React.ReactNode;
collapsed: boolean;
}) => React.ReactNode;
meta?: Record<string, unknown>;
}>;
Header?: React.FC;
Title?: React.FC<TitleProps>;
Footer?: React.FC;
OffLayoutArea?: React.FC;
dashboard?: boolean;
children?: ReactNode;
};
export type DashboardPageProps<TCrudData = any> = {
initialData?: TCrudData;
} & Record<any, any>;
export type TextTransformers = {
/**
* Convert a camelized/dasherized/underscored string into a humanized one
* @example
* humanize("some_name") => "Some name"
*/
humanize?: (text: string) => string;
/**
* Pluralize a word
* @example
* plural('regex') => "regexes"
*/
plural?: (word: string) => string;
/**
* Singularize a word
* @example
* singular('singles') => "single"
*/
singular?: (word: string) => string;
};
export interface IRefineOptions {
breadcrumb?: ReactNode;
mutationMode?: MutationMode;
syncWithLocation?: boolean;
warnWhenUnsavedChanges?: boolean;
undoableTimeout?: number;
liveMode?: LiveModeProps["liveMode"];
disableTelemetry?: boolean;
redirect?: {
afterCreate?: RedirectAction;
afterClone?: RedirectAction;
afterEdit?: RedirectAction;
};
reactQuery?: {
clientConfig?: QueryClientConfig | InstanceType<typeof QueryClient>;
/**
* @deprecated `@tanstack/react-query`'s devtools are removed from the core. Please use the `@tanstack/react-query-devtools` package manually in your project. This option will be removed in the next major version and has no effect on the `@tanstack/react-query-devtools` package usage.
*/
devtoolConfig?: any | false;
};
overtime?: UseLoadingOvertimeRefineContext;
textTransformers?: TextTransformers;
/**
* Disables server-side validation globally for the useForm hook
* @default false
* @see {@link https://refine.dev/docs/advanced-tutorials/forms/server-side-form-validation/}
*/
disableServerSideValidation?: boolean;
/**
* The project id of your refine project. Will be set automatically. Don't modify.
*/
projectId?: string;
useNewQueryKeys?: boolean;
/**
* Icon and name for the app title. These values are used as default values in the <ThemedLayoutV2 /> and <AuthPage /> components.
* By default, `icon` is the Refine logo and `text` is "Refine Project".
*/
title?: {
icon?: React.ReactNode;
text?: React.ReactNode;
};
}
export interface IRefineContextOptions {
breadcrumb?: ReactNode;
mutationMode: MutationMode;
syncWithLocation: boolean;
warnWhenUnsavedChanges: boolean;
undoableTimeout: number;
liveMode: LiveModeProps["liveMode"];
redirect: {
afterCreate: RedirectAction;
afterClone: RedirectAction;
afterEdit: RedirectAction;
};
overtime: UseLoadingOvertimeRefineContext;
textTransformers: Required<TextTransformers>;
disableServerSideValidation: boolean;
projectId?: string;
useNewQueryKeys?: boolean;
title: {
icon?: React.ReactNode;
text?: React.ReactNode;
};
}
export interface IRefineContext {
__initialized?: boolean;
hasDashboard: boolean;
mutationMode: MutationMode;
/**
* @deprecated Please use `UnsavedChangesNotifier` components from router packages instead.
*/
warnWhenUnsavedChanges: boolean;
syncWithLocation: boolean;
undoableTimeout: number;
catchAll?: React.ReactNode;
DashboardPage?: RefineProps["DashboardPage"];
LoginPage?: React.FC | false;
Title?: React.FC<TitleProps>;
Layout: React.FC<LayoutProps>;
Sider?: React.FC;
Header?: React.FC;
Footer?: React.FC;
OffLayoutArea?: React.FC;
liveMode: LiveModeProps["liveMode"];
onLiveEvent?: LiveModeProps["onLiveEvent"];
options: IRefineContextOptions;
}
export interface IRefineContextProvider {
__initialized?: boolean;
hasDashboard: boolean;
mutationMode: MutationMode;
warnWhenUnsavedChanges: boolean;
syncWithLocation: boolean;
undoableTimeout: number;
/**
* @deprecated Please use the `catchAll` element in your routes instead.
*/
catchAll?: React.ReactNode;
/**
* @deprecated Please use the `DashboardPage` component in your routes instead.
*/
DashboardPage?: RefineProps["DashboardPage"];
/**
* @deprecated Please use the `LoginPage` component in your routes instead.
*/
LoginPage?: React.FC | false;
/**
* @deprecated Please pass the `Title` component to your `Layout` component.
*/
Title?: React.FC<TitleProps>;
/**
* @deprecated Please use the `Layout` component as a children instead of a prop.
*/
Layout?: React.FC<LayoutProps>;
/**
* @deprecated Please pass the `Sider` component to your `Layout` component.
*/
Sider?: React.FC;
/**
* @deprecated Please pass the `Header` component to your `Layout` component.
*/
Header?: React.FC;
/**
* @deprecated Please pass the `Footer` component to your `Layout` component.
*/
Footer?: React.FC;
/**
* @deprecated Please use your `OffLayoutArea` component as a children instead of a prop.
*/
OffLayoutArea?: React.FC;
liveMode: LiveModeProps["liveMode"];
onLiveEvent?: LiveModeProps["onLiveEvent"];
options: IRefineContextOptions;
children?: ReactNode;
}
export interface RefineProps {
children?: React.ReactNode;
/**
* `resources` is the predefined interaction points for a refine app. A resource represents an entity in an endpoint in the API.
* While this is not a required property, it is used in resource detection and creation of routes for the app.
* @type [`ResourceProps[]`](https://refine.dev/docs/api-reference/core/components/refine-config/#resources)
*/
resources?: ResourceProps[];
/**
* **refine** needs some router functions to create resource pages, handle navigation, etc. This provider allows you to use the router library you want
* @type [`IRouterProvider`](https://refine.dev/docs/api-reference/core/providers/router-provider/)
* @deprecated This property is deprecated and was the legacy way of routing. Please use `routerProvider` with new router bindings instead.
*/
legacyRouterProvider?: LegacyRouterProvider;
/**
* Router bindings for **refine**. A simple interface for **refine** to interact with your router in a flexible way.
* @type [`RouterProvider`](https://refine.dev/docs/routing/router-provider/)
*/
routerProvider?: RouterProvider;
/**
* A `dataProvider` is the place where a refine app communicates with an API. Data providers also act as adapters for refine, making it possible for it to consume different API's and data services.
* @type [`DataProvider` | `DataProviders`](https://refine.dev/docs/api-reference/core/providers/data-provider/)
*/
dataProvider?: DataProvider | DataProviders;
/**
* `authProvider` handles authentication logic like login, logout flow and checking user credentials. It is an object with methods that refine uses when necessary.
* @type [`AuthProvider`](https://refine.dev/docs/api-reference/core/providers/auth-provider/)
*/
authProvider?: AuthProvider;
/**
* `legacyAuthProvider` handles authentication logic like login, logout flow and checking user credentials. It is an object with methods that refine uses when necessary.
* @type [`AuthProvider`](https://refine.dev/docs/api-reference/core/providers/auth-provider/)
* @deprecated `legacyAuthProvider` is deprecated with refine@4, use `authProvider` instead.
*/
legacyAuthProvider?: LegacyAuthProvider;
/**
* **refine** lets you add Realtime support to your app via `liveProvider`. It can be used to update and show data in Realtime throughout your app.
* @type [`LiveProvider`](https://refine.dev/docs/api-reference/core/providers/live-provider/)
*/
liveProvider?: LiveProvider;
/**
* `notificationProvider` handles notification logics. It is an object with methods that refine uses when necessary.
* @type [`NotificationProvider` | `(() => NotificationProvider)`](https://refine.dev/docs/api-reference/core/providers/notification-provider/)
*/
notificationProvider?: NotificationProvider | (() => NotificationProvider);
/**
* `accessControlProvider` is the entry point for implementing access control for refine apps.
* @type [`AccessControlProvider`](https://refine.dev/docs/api-reference/core/providers/accessControl-provider/)
*/
accessControlProvider?: AccessControlProvider;
/**
* **refine** allows you to track changes in your data and keep track of who made the changes.
* @type [`AuditLogProvider`](https://refine.dev/docs/api-reference/core/providers/audit-log-provider#overview)
*/
auditLogProvider?: AuditLogProvider;
/**
* `i18nProvider` property lets you add i18n support to your app. Making you able to use any i18n framework.
* @type [`i18nProvider`](https://refine.dev/docs/api-reference/core/providers/i18n-provider/)
*/
i18nProvider?: I18nProvider;
/**
* A custom error component.
* @type [`ReactNode`](https://refine.dev/docs/api-reference/core/components/refine-config/#catchall)
* @deprecated Please use the `catchAll` element in your routes instead.
*/
catchAll?: React.ReactNode;
/**
* Custom login component can be passed to the `LoginPage` property.
* @type [`React.FC`](https://refine.dev/docs/api-reference/core/components/refine-config/#loginpage)
* @deprecated Please use the `LoginPage` component in your routes instead.
*/
LoginPage?: React.FC;
/**
* A custom dashboard page can be passed to the `DashboardPage` prop which is accessible on root route.
* @type [`React.FC<DashboardPageProps>`](https://refine.dev/docs/api-reference/core/components/refine-config/#dashboardpage)
* @deprecated Please use the `DashboardPage` component in your routes instead.
*/
DashboardPage?: React.FC<DashboardPageProps>;
/**
* Custom ready page component can be set by passing to `ReadyPage` property.
* @type [`React.FC`](https://refine.dev/docs/api-reference/core/components/refine-config/#readypage)
* @deprecated This component is only used with the legacy router and will be removed in the future.
*/
ReadyPage?: React.FC;
/**
* Default layout can be customized by passing the `Layout` property.
* @type [`React.FC<LayoutProps>`](https://refine.dev/docs/api-reference/core/components/refine-config/#layout)
* @deprecated Please use the `Layout` component as a children instead of a prop.
*/
Layout?: React.FC<LayoutProps>;
/**
* The default sidebar can be customized by using refine hooks and passing custom components to `Sider` property.
* @type [`React.FC`](https://refine.dev/docs/api-reference/core/components/refine-config/#sider)
* @deprecated Please pass the `Sider` component to your `Layout` component.
*/
Sider?: React.FC;
/**
* The default app header can be customized by passing the `Header` property.
* @type [`React.FC`](https://refine.dev/docs/api-reference/core/components/refine-config/#header)
* @deprecated Please pass the `Header` component to your `Layout` component.
*/
Header?: React.FC;
/**
*The default app footer can be customized by passing the `Footer` property.
* @type [`React.FC`](https://refine.dev/docs/api-reference/core/components/refine-config/#footer)
* @deprecated Please pass the `Footer` component to your `Layout` component.
*/
Footer?: React.FC;
/**
* The component wanted to be placed out of app layout structure can be set by passing to `OffLayoutArea` prop.
* @type [`React.FC`](https://refine.dev/docs/api-reference/core/components/refine-config/#offlayoutarea)
* @deprecated Please use your `OffLayoutArea` component as a children instead of a prop.
*/
OffLayoutArea?: React.FC;
/**
* TThe app title can be set by passing the `Title` property.
* @type [`React.FC<TitleProps>`](https://refine.dev/docs/api-reference/core/components/refine-config/#title)
* @deprecated Please pass the `Title` component to your `Layout` component.
*/
Title?: React.FC<TitleProps>;
/**
* Callback to handle all live events.
* @type [`(event: LiveEvent) => void`](https://refine.dev/docs/api-reference/core/providers/live-provider/#onliveevent)
*/
onLiveEvent?: LiveModeProps["onLiveEvent"];
/**
* `options` is used to configure the app.
* @type [`IRefineOptions`](https://refine.dev/docs/api-reference/core/components/refine-config/#options)
* */
options?: IRefineOptions;
}

View File

@@ -0,0 +1,24 @@
import React from "react";
import { legacyResourceTransform } from "@definitions/helpers";
import { useDeepMemo } from "@hooks/deepMemo";
import type { IResourceContext, IResourceItem, ResourceProps } from "./types";
export const ResourceContext = React.createContext<IResourceContext>({
resources: [],
});
export const ResourceContextProvider: React.FC<
React.PropsWithChildren<{ resources: ResourceProps[] }>
> = ({ resources: providedResources, children }) => {
const resources: IResourceItem[] = useDeepMemo(() => {
return legacyResourceTransform(providedResources ?? []);
}, [providedResources]);
return (
<ResourceContext.Provider value={{ resources }}>
{children}
</ResourceContext.Provider>
);
};

View File

@@ -0,0 +1,205 @@
import type { ComponentType, ReactNode } from "react";
import type { UseQueryResult } from "@tanstack/react-query";
import type { ILogData } from "../auditLog/types";
/**
* Resource route components
*/
export type ResourceRouteComponent = ComponentType<
IResourceComponentsProps<any, any>
>;
export type ResourceRoutePath = string;
export type ResourceRouteDefinition = {
path: ResourceRoutePath;
component: ResourceRouteComponent;
};
export type ResourceRouteComposition =
| ResourceRouteDefinition
| ResourceRoutePath
| ResourceRouteComponent;
export interface IResourceComponents {
list?: ResourceRouteComposition;
create?: ResourceRouteComposition;
clone?: ResourceRouteComposition;
edit?: ResourceRouteComposition;
show?: ResourceRouteComposition;
}
export type AnyString = string & { __ignore?: never };
export type ResourceAuditLogPermissions =
| "create"
| "update"
| "delete"
| AnyString;
/** Resource `meta` */
export interface KnownResourceMeta {
/**
* This is used when setting the document title, in breadcrumbs and `<Sider />` components.
* Therefore it will only work if the related components have implemented the `label` property.
*/
label?: string;
/**
* Whether to hide the resource from the sidebar or not.
* This property is checked by the `<Sider />` components.
* Therefore it will only work if the `<Sider />` component has implemented the `hide` property.
*/
hide?: boolean;
/**
* Dedicated data provider name for the resource.
* If not set, the default data provider will be used.
* You can use this property to pick a data provider for a resource when you have multiple data providers.
*/
dataProviderName?: string;
/**
* To nest a resource under another resource, set the parent property to the name of the parent resource.
* This will work even if the parent resource is not explicitly defined.
*/
parent?: string;
/**
* To determine if the resource has ability to delete or not.
*/
canDelete?: boolean;
/**
* To permit the audit log for actions on the resource.
* @default All actions are permitted to be logged.
*/
audit?: ResourceAuditLogPermissions[];
/**
* To pass `icon` to the resource.
*/
icon?: ReactNode;
}
export interface DeprecatedOptions {
/**
* @deprecated Please use `audit` property instead.
*/
auditLog?: {
permissions?: ResourceAuditLogPermissions[];
};
/**
* @deprecated Define the route in the resource components instead
*/
route?: string;
}
export interface ResourceMeta extends KnownResourceMeta {
[key: string]: any;
}
export interface ResourceProps extends IResourceComponents {
name: string;
/**
* This property can be used to identify a resource. In some cases, `name` of the resource might be repeated in different resources.
* To avoid conflicts, you pass the `identifier` property to be used as the key of the resource.
* @default `name` of the resource
*/
identifier?: string;
/**
* @deprecated This property is not used anymore.
*/
key?: string;
/**
* @deprecated Please use the `meta` property instead.
*/
options?: ResourceMeta & DeprecatedOptions;
/**
* To configure the resource, you can set `meta` properties. You can use `meta` to store any data related to the resource.
* There are some known `meta` properties that are used by the core and extension packages.
*/
meta?: ResourceMeta & DeprecatedOptions;
/**
* @deprecated Please use the `meta.canDelete` property instead.
*/
canDelete?: boolean;
/**
* @deprecated Please use the `meta.icon` property instead
*/
icon?: ReactNode;
/**
* @deprecated Please use the `meta.parent` property instead
*/
parentName?: string;
}
export interface RouteableProperties {
/**
* @deprecated Please use action props instead.
*/
canCreate?: boolean;
/**
* @deprecated Please use action props instead.
*/
canEdit?: boolean;
/**
* @deprecated Please use action props instead.
*/
canShow?: boolean;
/**
* @deprecated Please use the `meta.canDelete` property instead.
*/
canDelete?: boolean;
}
export interface IResourceComponentsProps<
TCrudData = any,
TLogQueryResult = ILogData,
> extends RouteableProperties {
name?: string;
initialData?: TCrudData;
options?: ResourceMeta & DeprecatedOptions;
logQueryResult?: UseQueryResult<TLogQueryResult>;
}
export interface IResourceItem
extends IResourceComponents,
RouteableProperties,
ResourceProps {
/**
* @deprecated Please use the `meta.label` property instead.
*/
label?: string;
/**
* @deprecated Please use action components and `getDefaultActionPath` helper instead.
*/
route?: string;
}
export interface IResourceContext {
resources: IResourceItem[];
}
export type ResourceBindings = ResourceProps[];
type MetaProps<TExtends = { [key: string]: any }> = ResourceMeta & TExtends;
export interface RouteableProperties {
canCreate?: boolean;
canEdit?: boolean;
canShow?: boolean;
canDelete?: boolean;
canList?: boolean;
}
export interface IResourceContext {
resources: IResourceItem[];
}
/* Backward compatible version of 'TreeMenuItem' */
export type ITreeMenu = IResourceItem & {
key?: string;
children: ITreeMenu[];
};
export type IMenuItem = IResourceItem & {
key: string;
route: string;
};

View File

@@ -0,0 +1,18 @@
import React, { createContext, type PropsWithChildren } from "react";
import type { RouterProvider } from "./types";
const defaultRouterProvider = {};
export const RouterContext = createContext<RouterProvider>(
defaultRouterProvider,
);
export const RouterContextProvider: React.FC<
PropsWithChildren<{ router?: RouterProvider }>
> = ({ children, router }) => {
return (
<RouterContext.Provider value={router ?? defaultRouterProvider}>
{children}
</RouterContext.Provider>
);
};

View File

@@ -0,0 +1,41 @@
import React, { type PropsWithChildren } from "react";
import type { ILegacyRouterContext } from "./types";
export const defaultProvider: ILegacyRouterContext = {
useHistory: () => false,
useLocation: () => false,
useParams: () => ({}) as any,
Prompt: () => null,
Link: () => null,
};
export const LegacyRouterContext =
React.createContext<ILegacyRouterContext>(defaultProvider);
export const LegacyRouterContextProvider: React.FC<
PropsWithChildren<Partial<ILegacyRouterContext>>
> = ({
children,
useHistory,
useLocation,
useParams,
Prompt,
Link,
routes,
}) => {
return (
<LegacyRouterContext.Provider
value={{
useHistory: useHistory ?? defaultProvider.useHistory,
useLocation: useLocation ?? defaultProvider.useLocation,
useParams: useParams ?? defaultProvider.useParams,
Prompt: Prompt ?? defaultProvider.Prompt,
Link: Link ?? defaultProvider.Link,
routes: routes ?? defaultProvider.routes,
}}
>
{children}
</LegacyRouterContext.Provider>
);
};

View File

@@ -0,0 +1,50 @@
import React from "react";
import type { Action } from "../types";
export interface LegacyRouterProvider {
useHistory: () => {
push: (...args: any) => any;
replace: (...args: any) => any;
goBack: (...args: any) => any;
};
useLocation: () => {
search: string;
pathname: string;
};
useParams: <Params extends { [K in keyof Params]?: string } = {}>() => Params;
Prompt: React.FC<PromptProps>;
Link: React.FC<any>;
RouterComponent?: React.FC<any>;
routes?: any;
}
export interface ILegacyRouterContext {
useHistory: () => any;
useLocation: () => any;
useParams: <Params extends { [K in keyof Params]?: string } = {}>() => Params;
Prompt: React.FC<PromptProps>;
Link: React.FC<any>;
routes?: any;
}
export type PromptProps = {
message: string;
when?: boolean;
setWarnWhen?: (warnWhen: boolean) => void;
};
export type RouteAction = Exclude<Action, "list"> | undefined;
export type ActionWithPage = Extract<Action, "show" | "create" | "edit">;
export type ResourceRouterParams = {
resource: string;
id?: string;
action: RouteAction;
};
export type ResourceErrorRouterParams = {
resource: string;
action: ActionWithPage | undefined;
};

View File

@@ -0,0 +1,21 @@
import React from "react";
/**
* This context is used to determine which router to use.
*
* This is a temporary solution until we remove the legacy router.
*/
export const RouterPickerContext = React.createContext<"legacy" | "new">("new");
export const RouterPickerProvider = RouterPickerContext.Provider;
/**
* This is a temporary hook to determine which router to use.
* It will be removed once the legacy router is removed.
* @internal This is an internal hook.
*/
export const useRouterType = () => {
const value = React.useContext(RouterPickerContext);
return value;
};

View File

@@ -0,0 +1,79 @@
/**
* @author aliemir
*
* Router bindings interface, used to define the router bindings of refine.
*
* We're marking of the functions as optional, some features may not work properly but this is intentional.
* Users can choose to use the router bindings or not, or use their own router bindings.
* Leaving the control to the user is the best way to go.
*
* We're defining the functions as function generators, this is to allow the user to use hooks inside the functions.
*
* `go` function is used to navigate to a specific route. We're expecting a `GoConfig` object as the only parameter.
* Passing `query` as an object, will also let users to stringify the object as they like or ignore it completely or even use a custom logic to handle query strings.
*
* `back` function is used to navigate back to the previous route. It doesn't take any parameters.
* This one is a basic function for the back buttons, absence of this function can also hide the back button,
* but this depends on the UI package implementations.
*
* `parse` function is used to parse the current route, query parameters and other information.
* We're expecting this function to lead refine to the correct resource, action and id (again, not required but recommended).
* Also there's `params` property, which is used in data hooks and other places.
* This property has an interface to match but not restricted to it.
*
* Instead of a single `useNavigation` hook,
* we can separate those functions into three different hooks,
* `useGo`, `useBack` and `useParsed`
*/
import type { BaseKey, CrudFilter, CrudSort } from "../data/types";
import type { IResourceItem } from "../resource/types";
export type Action = "create" | "edit" | "list" | "show" | "clone";
export type GoConfig = {
to?: string;
query?: Record<string, unknown>;
hash?: string;
options?: {
keepQuery?: boolean;
keepHash?: boolean;
};
type?: "push" | "replace" | "path";
};
export type ParsedParams<
TParams extends Record<string, any> = Record<string, any>,
> = {
filters?: CrudFilter[];
sorters?: CrudSort[];
current?: number;
pageSize?: number;
} & TParams;
export type ParseResponse<
TParams extends Record<string, any> = Record<string, any>,
> = {
params?: ParsedParams<TParams>;
resource?: IResourceItem;
id?: BaseKey;
action?: Action;
pathname?: string;
};
export type GoFunction = (config: GoConfig) => void | string;
export type BackFunction = () => void;
export type ParseFunction<
TParams extends Record<string, any> = Record<string, any>,
> = () => ParseResponse<TParams>;
export type RouterProvider = {
go?: () => GoFunction;
back?: () => BackFunction;
parse?: () => ParseFunction;
Link?: React.ComponentType<
React.PropsWithChildren<{ to: string; [prop: string]: any }>
>;
};

View File

@@ -0,0 +1,88 @@
import * as React from "react";
import { renderHook } from "@testing-library/react";
import { act } from "@test";
import { undoableQueueReducer } from ".";
describe("Notification Reducer", () => {
const notificationDispatch = jest.fn();
const providerProps = {
notifications: [
{
id: "1",
resource: "posts",
seconds: 5000,
isRunning: true,
},
],
notificationDispatch: notificationDispatch,
};
it("should render notification item with ADD action", () => {
const { result } = renderHook(() =>
React.useReducer(undoableQueueReducer, []),
);
const [, dispatch] = result.current;
act(() => {
dispatch({ type: "ADD", payload: providerProps.notifications[0] });
});
const [state] = result.current;
expect(state).toEqual([
{
id: "1",
resource: "posts",
seconds: 5000,
isRunning: true,
},
]);
});
it("remove notification item with DELETE action", async () => {
const { result } = renderHook(() =>
React.useReducer(undoableQueueReducer, providerProps.notifications),
);
const [, dispatch] = result.current;
act(() => {
dispatch({
type: "REMOVE",
payload: providerProps.notifications[0],
});
});
const [state] = result.current;
expect(state).toEqual([]);
});
it("decrease notification item by 1 second with DECREASE_NOTIFICATION_SECOND action", async () => {
const { result } = renderHook(() =>
React.useReducer(undoableQueueReducer, providerProps.notifications),
);
const [, dispatch] = result.current;
act(() => {
dispatch({
type: "DECREASE_NOTIFICATION_SECOND",
payload: {
id: providerProps.notifications[0].id,
seconds: providerProps.notifications[0].seconds,
resource: providerProps.notifications[0].resource,
},
});
});
const [state] = result.current;
expect(state[0].seconds).toEqual(
providerProps.notifications[0].seconds - 1000,
);
});
});

View File

@@ -0,0 +1,90 @@
import React, {
createContext,
useReducer,
type PropsWithChildren,
} from "react";
import isEqual from "lodash/isEqual";
import { UndoableQueue } from "../../components";
import {
ActionTypes,
type IUndoableQueue,
type IUndoableQueueContext,
} from "./types";
export const UndoableQueueContext = createContext<IUndoableQueueContext>({
notifications: [],
notificationDispatch: () => false,
});
const initialState: IUndoableQueue[] = [];
export const undoableQueueReducer = (state: IUndoableQueue[], action: any) => {
switch (action.type) {
case ActionTypes.ADD: {
const newState = state.filter((notificationItem: IUndoableQueue) => {
return !(
isEqual(notificationItem.id, action.payload.id) &&
notificationItem.resource === action.payload.resource
);
});
return [
...newState,
{
...action.payload,
isRunning: true,
},
];
}
case ActionTypes.REMOVE:
return state.filter(
(notificationItem: IUndoableQueue) =>
!(
isEqual(notificationItem.id, action.payload.id) &&
notificationItem.resource === action.payload.resource
),
);
case ActionTypes.DECREASE_NOTIFICATION_SECOND:
return state.map((notificationItem: IUndoableQueue) => {
if (
isEqual(notificationItem.id, action.payload.id) &&
notificationItem.resource === action.payload.resource
) {
return {
...notificationItem,
seconds: action.payload.seconds - 1000,
};
}
return notificationItem;
});
default:
return state;
}
};
export const UndoableQueueContextProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
const [notifications, notificationDispatch] = useReducer(
undoableQueueReducer,
initialState,
);
const notificationData = { notifications, notificationDispatch };
return (
<UndoableQueueContext.Provider value={notificationData}>
{children}
{typeof window !== "undefined"
? notifications.map((notification) => (
<UndoableQueue
key={`${notification.id}-${notification.resource}-queue`}
notification={notification}
/>
))
: null}
</UndoableQueueContext.Provider>
);
};

View File

@@ -0,0 +1,22 @@
import type { BaseKey } from "../data/types";
export enum ActionTypes {
ADD = "ADD",
REMOVE = "REMOVE",
DECREASE_NOTIFICATION_SECOND = "DECREASE_NOTIFICATION_SECOND",
}
export interface IUndoableQueue {
id: BaseKey;
resource: string;
cancelMutation: () => void;
doMutation: () => void;
seconds: number;
isRunning: boolean;
isSilent: boolean;
}
export interface IUndoableQueueContext {
notifications: IUndoableQueue[];
notificationDispatch: React.Dispatch<any>;
}

View File

@@ -0,0 +1,17 @@
import React, { useState, type PropsWithChildren } from "react";
import type { IUnsavedWarnContext } from "./types";
export const UnsavedWarnContext = React.createContext<IUnsavedWarnContext>({});
export const UnsavedWarnContextProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
const [warnWhen, setWarnWhen] = useState(false);
return (
<UnsavedWarnContext.Provider value={{ warnWhen, setWarnWhen }}>
{children}
</UnsavedWarnContext.Provider>
);
};

View File

@@ -0,0 +1,4 @@
export interface IUnsavedWarnContext {
warnWhen?: boolean;
setWarnWhen?: (value: boolean) => void;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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