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