This commit is contained in:
Stefan Pejcic 2024-05-08 19:58:53 +02:00
parent 440d98beff
commit 8595a9f4e5
2479 changed files with 591504 additions and 0 deletions

11
packages/ably/.npmignore Normal file
View file

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

View file

@ -0,0 +1,93 @@
# @refinedev/ably
## 4.1.4
### Patch Changes
- [#5425](https://github.com/refinedev/refine/pull/5425) [`190af9fce2`](https://github.com/refinedev/refine/commit/190af9fce292bc46b169e3e121be6bf1c2a939a5) Thanks [@aliemir](https://github.com/aliemir)! - Updated `@refinedev/core` peer dependencies to latest (`^4.46.1`)
## 4.1.3
### Patch Changes
- [#5330](https://github.com/refinedev/refine/pull/5330) [`7c8827b43d`](https://github.com/refinedev/refine/commit/7c8827b43d9e378818be6ee23032925c97ce02d5) Thanks [@BatuhanW](https://github.com/BatuhanW)! - chore: upgrade nock library version to ^13.4.0
## 4.1.2
### Patch Changes
- [#5022](https://github.com/refinedev/refine/pull/5022) [`80513a4e42f`](https://github.com/refinedev/refine/commit/80513a4e42f8dda39e01157643594a9e4c32001b) Thanks [@BatuhanW](https://github.com/BatuhanW)! - chore: update README.md
- fix grammar errors.
- make all README.md files consistent.
- add code example code snippets.
## 4.1.1
### Patch Changes
- [#5022](https://github.com/refinedev/refine/pull/5022) [`80513a4e42f`](https://github.com/refinedev/refine/commit/80513a4e42f8dda39e01157643594a9e4c32001b) Thanks [@BatuhanW](https://github.com/BatuhanW)! - chore: update README.md
- fix grammar errors.
- make all README.md files consistent.
- add code example code snippets.
## 4.1.0
### Minor Changes
- Thanks [@aliemir](https://github.com/aliemir), [@alicanerdurmaz](https://github.com/alicanerdurmaz), [@batuhanW](https://github.com/batuhanW), [@salihozdemir](https://github.com/salihozdemir), [@yildirayunlu](https://github.com/yildirayunlu), [@recepkutuk](https://github.com/recepkutuk)!
**Moving to the `@refinedev` scope 🎉🎉**
Moved to the `@refinedev` scope and updated our packages to use the new scope. From now on, all packages will be published under the `@refinedev` scope with their new names.
Now, we're also removing the `refine` prefix from all packages. So, the `@pankod/refine-core` package is now `@refinedev/core`, `@pankod/refine-antd` is now `@refinedev/antd`, and so on.
### Patch Changes
## 3.31.0
### Minor Changes
- [#3822](https://github.com/refinedev/refine/pull/3822) [`0baa99ba787`](https://github.com/refinedev/refine/commit/0baa99ba7874394d9d28d0a7b29c082c604258fb) Thanks [@BatuhanW](https://github.com/BatuhanW)! - - refine v4 release announcement added to "postinstall". - refine v4 is released 🎉 The new version is 100% backward compatible. You can upgrade to v4 with a single command! See the migration guide here: https://refine.dev/docs/migration-guide/3x-to-4x
## 3.30.0
### Minor Changes
- [#3822](https://github.com/refinedev/refine/pull/3822) [`0baa99ba787`](https://github.com/refinedev/refine/commit/0baa99ba7874394d9d28d0a7b29c082c604258fb) Thanks [@BatuhanW](https://github.com/BatuhanW)! - - refine v4 release announcement added to "postinstall". - refine v4 is released 🎉 The new version is 100% backward compatible. You can upgrade to v4 with a single command! See the migration guide here: https://refine.dev/docs/migration-guide/3x-to-4x
## 3.29.0
### Minor Changes
- Update type declaration generation with `tsc` instead of `tsup` for better navigation throughout projects source code.
## 3.28.0
### Minor Changes
- [#2440](https://github.com/refinedev/refine/pull/2440) [`0150dcd070`](https://github.com/refinedev/refine/commit/0150dcd0700253f1c4908e7e5f2e178bb122e9af) Thanks [@aliemir](https://github.com/aliemir)! - Update type declaration generation with `tsc` instead of `tsup` for better navigation throughout projects source code.
## 3.27.0
### Minor Changes
- All of the refine packages have dependencies on the `@pankod/refine-core` package. So far we have managed these dependencies with `peerDependencies` + `dependencies` but this causes issues like #2183. (having more than one @pankod/refine-core version in node_modules and creating different instances)
Managing as `peerDependencies` + `devDependencies` seems like the best way for now to avoid such issues.
## 3.26.0
### Minor Changes
- [#2217](https://github.com/refinedev/refine/pull/2217) [`b4aae00f77`](https://github.com/refinedev/refine/commit/b4aae00f77a2476d847994db21298ae25e4cf6e5) Thanks [@omeraplak](https://github.com/omeraplak)! - All of the refine packages have dependencies on the `@pankod/refine-core` package. So far we have managed these dependencies with `peerDependencies` + `dependencies` but this causes issues like #2183. (having more than one @pankod/refine-core version in node_modules and creating different instances)
Managing as `peerDependencies` + `devDependencies` seems like the best way for now to avoid such issues.
## 3.22.2
### Patch Changes
- Updated dependencies [[`2deb19babf`](https://github.com/refinedev/refine/commit/2deb19babfc6db5b00b111ec29aa5ece4c371bbc)]:
- @pankod/refine-core@3.23.2

21
packages/ably/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Refine Development Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

74
packages/ably/README.md Normal file
View file

@ -0,0 +1,74 @@
<div align="center" style="margin: 30px;">
<a href="https://refine.dev">
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
</a>
</div>
<br/>
<div align="center">
<a href="https://refine.dev">Home Page</a> |
<a href="https://discord.gg/refine">Discord</a> |
<a href="https://refine.dev/examples/">Examples</a> |
<a href="https://refine.dev/blog/">Blog</a> |
<a href="https://refine.dev/docs/">Documentation</a>
<br/>
<br/>
[![Discord](https://img.shields.io/discord/837692625737613362.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/refine)
[![Twitter Follow](https://img.shields.io/twitter/follow/refine_dev?style=social)](https://twitter.com/refine_dev)
<a href="https://www.producthunt.com/posts/refine-3?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-refine&#0045;3" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=362220&theme=light&period=daily" alt="refine - 100&#0037;&#0032;open&#0032;source&#0032;React&#0032;framework&#0032;to&#0032;build&#0032;web&#0032;apps&#0032;3x&#0032;faster | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
<br/>
<div align="center">refine is an open-source, headless React framework for developers building enterprise internal tools, admin panels, dashboards, B2B applications.
<br/>
It eliminates repetitive tasks in CRUD operations and provides industry-standard solutions for critical project components like **authentication**, **access control**, **routing**, **networking**, **state management**, and **i18n**.
</div>
<br/>
# Ably integration for refine
[Ably](https://ably.com/) reliably distributes realtime data to your users using the publish/subscribe messaging pattern over WebSocket connections.
[refine](https://refine.dev/) is **headless by design**, offering unlimited styling and customization options. Moreover, refine ships with ready-made integrations 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/) for convenience.
refine has connectors for 15+ backend services, including REST API, [GraphQL](https://graphql.org/), and popular services like [Airtable](https://www.airtable.com/), [Strapi](https://strapi.io/), [Supabase](https://supabase.com/), [Firebase](https://firebase.google.com/), and [NestJS](https://nestjs.com/).
## Installation & Usage
```
npm install @refinedev/ably
```
```tsx
import { liveProvider, Ably } from "@refinedev/ably";
export const ablyClient = new Ably.Realtime("YOUR_API_TOKEN");
const App = () => {
return (
<Refine
liveProvider={liveProvider(ablyClient)}
/* ... */
>
{/* ... */}
</Refine>
);
};
```
## Documentation
- For more detailed information and usage, refer to the [refine live provider documentation](https://refine.dev/docs/api-references/providers/live-provider/).
- Refer to refine & Ably tutorial on [official Ably docs](https://ably.com/tutorials/react-admin-panel-with-ably-and-refine).
- [Refer to documentation for more info about refine](https://refine.dev/docs/).
- [Step up to refine tutorials](https://refine.dev/docs/tutorial/introduction/index/).

View file

@ -0,0 +1,6 @@
module.exports = {
preset: "ts-jest",
rootDir: "./",
displayName: "ably",
testEnvironment: "jsdom",
};

View file

@ -0,0 +1,50 @@
{
"name": "@refinedev/ably",
"description": "refine ably live provider. refine is a React-based framework for building internal tools, rapidly. It ships with Ant Design System, an enterprise-level UI toolkit.",
"version": "4.1.4",
"license": "MIT",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"private": false,
"files": [
"dist",
"src"
],
"engines": {
"node": ">=10"
},
"scripts": {
"start": "tsup --watch --format esm,cjs,iife --legacy-output",
"build": "tsup --format esm,cjs,iife --minify --legacy-output",
"test": "jest --passWithNoTests --runInBand",
"prepare": "npm run build"
},
"author": "refine",
"module": "dist/esm/index.js",
"devDependencies": {
"@refinedev/core": "^4.46.1",
"@esbuild-plugins/node-resolve": "^0.1.4",
"@types/jest": "^29.2.4",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"nock": "^13.4.0",
"ts-jest": "^29.0.3",
"tslib": "^2.3.1",
"tsup": "^6.7.0"
},
"dependencies": {
"ably": "^1.2.15"
},
"peerDependencies": {
"@refinedev/core": "^4.46.1"
},
"repository": {
"type": "git",
"url": "https://github.com/refinedev/refine.git",
"directory": "packages/ably"
},
"gitHead": "829f5a516f98c06f666d6be3e6e6099c75c07719",
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,57 @@
import { LiveProvider, LiveEvent } from "@refinedev/core";
import Ably from "ably/promises";
import { Types } from "ably";
interface MessageType extends Types.Message {
data: LiveEvent;
}
const liveProvider = (client: Ably.Realtime): LiveProvider => {
return {
subscribe: ({ channel, types, params, callback }) => {
const channelInstance = client.channels.get(channel);
const listener = function (message: MessageType) {
if (types.includes("*") || types.includes(message.data.type)) {
if (
message.data.type !== "created" &&
params?.ids !== undefined &&
message.data?.payload?.ids !== undefined
) {
if (
params.ids
.map(String)
.filter((value) =>
message.data.payload.ids
?.map(String)
.includes(value),
).length > 0
) {
callback(message.data as LiveEvent);
}
} else {
callback(message.data);
}
}
};
channelInstance.subscribe(listener);
return { channelInstance, listener };
},
unsubscribe: (payload: {
channelInstance: Types.RealtimeChannelPromise;
listener: () => void;
}) => {
const { channelInstance, listener } = payload;
channelInstance.unsubscribe(listener);
},
publish: (event: LiveEvent) => {
const channelInstance = client.channels.get(event.channel);
channelInstance.publish(event.type, event);
},
};
};
export { liveProvider, Ably };

View file

@ -0,0 +1,21 @@
{
"extends": "./tsconfig.json",
"exclude": [
"node_modules",
"dist",
"test",
"../test/**/*",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx"
],
"compilerOptions": {
"outDir": "dist",
"declarationDir": "dist",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"declarationMap": true
}
}

View file

@ -0,0 +1,11 @@
{
"include": [
"src",
"types"
],
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"rootDir": "./src",
"baseUrl": ".",
}
}

View file

@ -0,0 +1,24 @@
import { defineConfig } from "tsup";
import { NodeResolvePlugin } from "@esbuild-plugins/node-resolve";
export default defineConfig({
entry: ["src/index.ts"],
splitting: false,
sourcemap: true,
clean: false,
platform: "browser",
esbuildPlugins: [
NodeResolvePlugin({
extensions: [".js", "ts", "tsx", "jsx"],
onResolved: (resolved) => {
if (resolved.includes("node_modules")) {
return {
external: true,
};
}
return resolved;
},
}),
],
onSuccess: "tsc --project tsconfig.declarations.json",
});

View file

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

View file

@ -0,0 +1,357 @@
# @refinedev/airtable
## 4.4.6
### Patch Changes
- [#5425](https://github.com/refinedev/refine/pull/5425) [`190af9fce2`](https://github.com/refinedev/refine/commit/190af9fce292bc46b169e3e121be6bf1c2a939a5) Thanks [@aliemir](https://github.com/aliemir)! - Updated `@refinedev/core` peer dependencies to latest (`^4.46.1`)
## 4.4.5
### Patch Changes
- [#5330](https://github.com/refinedev/refine/pull/5330) [`7c8827b43d`](https://github.com/refinedev/refine/commit/7c8827b43d9e378818be6ee23032925c97ce02d5) Thanks [@BatuhanW](https://github.com/BatuhanW)! - chore: upgrade nock library version to ^13.4.0
## 4.4.4
### Patch Changes
- [#5022](https://github.com/refinedev/refine/pull/5022) [`80513a4e42f`](https://github.com/refinedev/refine/commit/80513a4e42f8dda39e01157643594a9e4c32001b) Thanks [@BatuhanW](https://github.com/BatuhanW)! - chore: update README.md
- fix grammar errors.
- make all README.md files consistent.
- add code example code snippets.
## 4.4.3
### Patch Changes
- [#5022](https://github.com/refinedev/refine/pull/5022) [`80513a4e42f`](https://github.com/refinedev/refine/commit/80513a4e42f8dda39e01157643594a9e4c32001b) Thanks [@BatuhanW](https://github.com/BatuhanW)! - chore: update README.md
- fix grammar errors.
- make all README.md files consistent.
- add code example code snippets.
## 4.4.2
### Patch Changes
- [#4285](https://github.com/refinedev/refine/pull/4285) [`b5cd3328504`](https://github.com/refinedev/refine/commit/b5cd332850428383e8b43f997cbb0340ac7f0dc6) Thanks [@alicanerdurmaz](https://github.com/alicanerdurmaz)! - fixed: A bug that prevented data providers from being swizzled.
## 4.4.1
### Patch Changes
- [#4285](https://github.com/refinedev/refine/pull/4285) [`b5cd3328504`](https://github.com/refinedev/refine/commit/b5cd332850428383e8b43f997cbb0340ac7f0dc6) Thanks [@alicanerdurmaz](https://github.com/alicanerdurmaz)! - fixed: A bug that prevented data providers from being swizzled.
## 4.4.0
### Minor Changes
- [#4276](https://github.com/refinedev/refine/pull/4276) [`740cef82e9e`](https://github.com/refinedev/refine/commit/740cef82e9e71274edb4be1d6936a2b74b73b4ec) Thanks [@alicanerdurmaz](https://github.com/alicanerdurmaz)! - feat: added swizzle to airtable
## 4.3.0
### Minor Changes
- [#4276](https://github.com/refinedev/refine/pull/4276) [`740cef82e9e`](https://github.com/refinedev/refine/commit/740cef82e9e71274edb4be1d6936a2b74b73b4ec) Thanks [@alicanerdurmaz](https://github.com/alicanerdurmaz)! - feat: added swizzle to airtable
## 4.2.0
### Minor Changes
- [#4276](https://github.com/refinedev/refine/pull/4276) [`740cef82e9e`](https://github.com/refinedev/refine/commit/740cef82e9e71274edb4be1d6936a2b74b73b4ec) Thanks [@alicanerdurmaz](https://github.com/alicanerdurmaz)! - feat: added swizzle to airtable
## 4.1.0
### Minor Changes
- Thanks [@aliemir](https://github.com/aliemir), [@alicanerdurmaz](https://github.com/alicanerdurmaz), [@batuhanW](https://github.com/batuhanW), [@salihozdemir](https://github.com/salihozdemir), [@yildirayunlu](https://github.com/yildirayunlu), [@recepkutuk](https://github.com/recepkutuk)!
- `metaData` prop is now deprecated for all data provider methods. Use `meta` prop instead.
> For backward compatibility, we still support `metaData` prop with refine v4.
```diff
create: async ({
- metaData
+ meta
}) => {
...
},
```
- `sort`, `hasPagination`, and `metaData` parameters of `getList` method are now deprecated. Use `sorters`, `pagination`, and `meta` parameters instead.
> For backward compatibility, we still support `sort`, `hasPagination` and `metaData` props with refine v4.
```diff
getList: async ({
- sort
+ sorters
- hasPagination
+ pagination: { mode: "off" | "server | "client" }
- metaData
+ meta
}) => {
...
},
```
- Thanks [@aliemir](https://github.com/aliemir), [@alicanerdurmaz](https://github.com/alicanerdurmaz), [@batuhanW](https://github.com/batuhanW), [@salihozdemir](https://github.com/salihozdemir), [@yildirayunlu](https://github.com/yildirayunlu), [@recepkutuk](https://github.com/recepkutuk)!
**Moving to the `@refinedev` scope 🎉🎉**
Moved to the `@refinedev` scope and updated our packages to use the new scope. From now on, all packages will be published under the `@refinedev` scope with their new names.
Now, we're also removing the `refine` prefix from all packages. So, the `@pankod/refine-core` package is now `@refinedev/core`, `@pankod/refine-antd` is now `@refinedev/antd`, and so on.
### Patch Changes
## 3.35.0
### Minor Changes
- [#3822](https://github.com/refinedev/refine/pull/3822) [`0baa99ba787`](https://github.com/refinedev/refine/commit/0baa99ba7874394d9d28d0a7b29c082c604258fb) Thanks [@BatuhanW](https://github.com/BatuhanW)! - - refine v4 release announcement added to "postinstall". - refine v4 is released 🎉 The new version is 100% backward compatible. You can upgrade to v4 with a single command! See the migration guide here: https://refine.dev/docs/migration-guide/3x-to-4x
## 3.34.0
### Minor Changes
- [#3822](https://github.com/refinedev/refine/pull/3822) [`0baa99ba787`](https://github.com/refinedev/refine/commit/0baa99ba7874394d9d28d0a7b29c082c604258fb) Thanks [@BatuhanW](https://github.com/BatuhanW)! - - refine v4 release announcement added to "postinstall". - refine v4 is released 🎉 The new version is 100% backward compatible. You can upgrade to v4 with a single command! See the migration guide here: https://refine.dev/docs/migration-guide/3x-to-4x
## 3.33.0
### Minor Changes
- Only `or` was supported as a conditional filter. Now `and` and `or` can be used together and nested. 🚀
```
{
operator: "or",
value: [
{
operator: "and",
value: [
{
field: "name",
operator: "eq",
value: "John Doe",
},
{
field: "age",
operator: "eq",
value: 30,
},
],
},
{
operator: "and",
value: [
{
field: "name",
operator: "eq",
value: "JR Doe",
},
{
field: "age",
operator: "eq",
value: 1,
},
],
},
],
}
```
## 3.32.0
### Minor Changes
- [#2751](https://github.com/refinedev/refine/pull/2751) [`addff64c77`](https://github.com/refinedev/refine/commit/addff64c777e4c9f044a1a109cb05453e6e9f762) Thanks [@yildirayunlu](https://github.com/yildirayunlu)! - Only `or` was supported as a conditional filter. Now `and` and `or` can be used together and nested. 🚀
```
{
operator: "or",
value: [
{
operator: "and",
value: [
{
field: "name",
operator: "eq",
value: "John Doe",
},
{
field: "age",
operator: "eq",
value: 30,
},
],
},
{
operator: "and",
value: [
{
field: "name",
operator: "eq",
value: "JR Doe",
},
{
field: "age",
operator: "eq",
value: 1,
},
],
},
],
}
```
## 3.31.0
### Minor Changes
- Updated `dataProvider` types with `Required` utility to mark `getMany`, `createMany`, `updateMany` and `deleteMany` as implemented.
## 3.30.0
### Minor Changes
- [#2688](https://github.com/refinedev/refine/pull/2688) [`508045ac30`](https://github.com/refinedev/refine/commit/508045ac30cd3948f68497e13fdf04f7c72ce387) Thanks [@aliemir](https://github.com/aliemir)! - Updated `dataProvider` types with `Required` utility to mark `getMany`, `createMany`, `updateMany` and `deleteMany` as implemented.
## 3.29.0
### Minor Changes
- Update type declaration generation with `tsc` instead of `tsup` for better navigation throughout projects source code.
## 3.28.0
### Minor Changes
- [#2440](https://github.com/refinedev/refine/pull/2440) [`0150dcd070`](https://github.com/refinedev/refine/commit/0150dcd0700253f1c4908e7e5f2e178bb122e9af) Thanks [@aliemir](https://github.com/aliemir)! - Update type declaration generation with `tsc` instead of `tsup` for better navigation throughout projects source code.
## 3.27.0
### Minor Changes
- All of the refine packages have dependencies on the `@pankod/refine-core` package. So far we have managed these dependencies with `peerDependencies` + `dependencies` but this causes issues like #2183. (having more than one @pankod/refine-core version in node_modules and creating different instances)
Managing as `peerDependencies` + `devDependencies` seems like the best way for now to avoid such issues.
## 3.26.0
### Minor Changes
- [#2217](https://github.com/refinedev/refine/pull/2217) [`b4aae00f77`](https://github.com/refinedev/refine/commit/b4aae00f77a2476d847994db21298ae25e4cf6e5) Thanks [@omeraplak](https://github.com/omeraplak)! - All of the refine packages have dependencies on the `@pankod/refine-core` package. So far we have managed these dependencies with `peerDependencies` + `dependencies` but this causes issues like #2183. (having more than one @pankod/refine-core version in node_modules and creating different instances)
Managing as `peerDependencies` + `devDependencies` seems like the best way for now to avoid such issues.
## 3.25.6
### Patch Changes
- Updated pagination parameters default values and added `hasPagination` property to `getList` method of the data providers.
**Implementation**
Updated the `getList` method accordingly to the changes in the `useTable` and `useList` of `@pankod/refine-core`. `hasPagination` is used to disable pagination (defaults to `true`)
**Use Cases**
For some resources, there might be no support for pagination or users might want to see all of the data without any pagination, prior to these changes this was not supported in **refine** data providers.
- Updated dependencies []:
- @pankod/refine-core@3.36.0
## 3.25.5
### Patch Changes
- [#2050](https://github.com/refinedev/refine/pull/2050) [`635cfe9fdb`](https://github.com/refinedev/refine/commit/635cfe9fdbfe5940b950ae99c1f0b686c78bb8e5) Thanks [@ozkalai](https://github.com/ozkalai)! - Updated pagination parameters default values and added `hasPagination` property to `getList` method of the data providers.
**Implementation**
Updated the `getList` method accordingly to the changes in the `useTable` and `useList` of `@pankod/refine-core`. `hasPagination` is used to disable pagination (defaults to `true`)
**Use Cases**
For some resources, there might be no support for pagination or users might want to see all of the data without any pagination, prior to these changes this was not supported in **refine** data providers.
- Updated dependencies [[`ecde34a9b3`](https://github.com/refinedev/refine/commit/ecde34a9b38ef5667fa863f9ebb9dcb1cfff1651), [`635cfe9fdb`](https://github.com/refinedev/refine/commit/635cfe9fdbfe5940b950ae99c1f0b686c78bb8e5)]:
- @pankod/refine-core@3.35.0
## 3.25.4
### Patch Changes
- Updated axios version (0.21.4 to 0.26.1). In this version, the way of sending headers has changed as follows.
```
// old v0.21.4
axiosInstance.defaults.headers = { Authorization: `Bearer ${data.jwt}` };
// new v0.26.1
axiosInstance.defaults.headers.common["Authorization"] = `Bearer ${data.jwt}`;
```
- Updated dependencies []:
- @pankod/refine-core@3.29.0
## 3.25.3
### Patch Changes
- Updated axios version (0.21.4 to 0.26.1). In this version, the way of sending headers has changed as follows.
```
// old v0.21.4
axiosInstance.defaults.headers = { Authorization: `Bearer ${data.jwt}` };
// new v0.26.1
axiosInstance.defaults.headers.common["Authorization"] = `Bearer ${data.jwt}`;
```
- Updated dependencies []:
- @pankod/refine-core@3.28.0
## 3.25.2
### Patch Changes
- Updated axios version (0.21.4 to 0.26.1). In this version, the way of sending headers has changed as follows.
```
// old v0.21.4
axiosInstance.defaults.headers = { Authorization: `Bearer ${data.jwt}` };
// new v0.26.1
axiosInstance.defaults.headers.common["Authorization"] = `Bearer ${data.jwt}`;
```
- Updated dependencies []:
- @pankod/refine-core@3.27.0
## 3.25.1
### Patch Changes
- [#1899](https://github.com/refinedev/refine/pull/1899) [`fbfea418a0`](https://github.com/refinedev/refine/commit/fbfea418a024a527a2b432c634f46a96d4f70d88) Thanks [@yildirayunlu](https://github.com/yildirayunlu)! - Updated axios version (0.21.4 to 0.26.1). In this version, the way of sending headers has changed as follows.
```
// old v0.21.4
axiosInstance.defaults.headers = { Authorization: `Bearer ${data.jwt}` };
// new v0.26.1
axiosInstance.defaults.headers.common["Authorization"] = `Bearer ${data.jwt}`;
```
- Updated dependencies [[`2ba2a96fd2`](https://github.com/refinedev/refine/commit/2ba2a96fd24aa733c355ac9ef4c99b7d48115746)]:
- @pankod/refine-core@3.26.0
## 3.22.2
### Patch Changes
- Updated dependencies [[`2deb19babf`](https://github.com/refinedev/refine/commit/2deb19babfc6db5b00b111ec29aa5ece4c371bbc)]:
- @pankod/refine-core@3.23.2

21
packages/airtable/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Refine Development Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,70 @@
<div align="center" style="margin: 30px;">
<a href="https://refine.dev">
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
</a>
</div>
<br/>
<div align="center">
<a href="https://refine.dev">Home Page</a> |
<a href="https://discord.gg/refine">Discord</a> |
<a href="https://refine.dev/examples/">Examples</a> |
<a href="https://refine.dev/blog/">Blog</a> |
<a href="https://refine.dev/docs/">Documentation</a>
<br/>
<br/>
[![Discord](https://img.shields.io/discord/837692625737613362.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/refine)
[![Twitter Follow](https://img.shields.io/twitter/follow/refine_dev?style=social)](https://twitter.com/refine_dev)
<a href="https://www.producthunt.com/posts/refine-3?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-refine&#0045;3" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=362220&theme=light&period=daily" alt="refine - 100&#0037;&#0032;open&#0032;source&#0032;React&#0032;framework&#0032;to&#0032;build&#0032;web&#0032;apps&#0032;3x&#0032;faster | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
<br/>
<div align="center">refine is an open-source, headless React framework for developers building enterprise internal tools, admin panels, dashboards, B2B applications.
<br/>
It eliminates repetitive tasks in CRUD operations and provides industry-standard solutions for critical project components like **authentication**, **access control**, **routing**, **networking**, **state management**, and **i18n**.
</div>
# Airtable integration for refine
[Airtable](https://www.airtable.com/) is a cloud-based platform for creating and sharing relational databases.
[refine](https://refine.dev/) is **headless by design**, offering unlimited styling and customization options. Moreover, refine ships with ready-made integrations 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/) for convenience.
refine has connectors for 15+ backend services, including REST API, [GraphQL](https://graphql.org/), and popular services like [Airtable](https://www.airtable.com/), [Strapi](https://strapi.io/), [Supabase](https://supabase.com/), [Firebase](https://firebase.google.com/), and [NestJS](https://nestjs.com/).
## Installation & Usage
```
npm install @refinedev/airtable
```
```tsx
import dataProvider from "@refinedev/airtable";
const App = () => {
return (
<Refine
dataProvider={dataProvider("API_KEY", "BASE_ID")}
/* ... */
>
{/* ... */}
</Refine>
);
};
```
## Documentation
- For more detailed information and usage, refer to the [refine data provider documentation](https://refine.dev/docs/core/providers/data-provider).
- [Refer to refine Airtable example](https://refine.dev/docs/examples/data-provider/airtable/).
- [Refer to documentation for more info about refine](https://refine.dev/docs/).
- [Step up to refine tutorials](https://refine.dev/docs/tutorial/introduction/index/).

View file

@ -0,0 +1,7 @@
module.exports = {
preset: "ts-jest",
rootDir: "./",
displayName: "airtable",
setupFilesAfterEnv: ["<rootDir>/test/jest.setup.js"],
testEnvironment: "jsdom",
};

View file

@ -0,0 +1,54 @@
{
"name": "@refinedev/airtable",
"description": "refine Airtable data provider. refine is a React-based framework for building internal tools, rapidly. It ships with Ant Design System, an enterprise-level UI toolkit.",
"version": "4.4.6",
"license": "MIT",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"private": false,
"files": [
"dist",
"src",
"./refine.config.js"
],
"engines": {
"node": ">=10"
},
"scripts": {
"start": "tsup --watch --format esm,cjs,iife --legacy-output",
"build": "tsup --format esm,cjs,iife --minify --legacy-output",
"test": "jest --passWithNoTests --runInBand",
"prepare": "npm run build"
},
"author": "refine",
"module": "dist/esm/index.js",
"devDependencies": {
"@refinedev/core": "4.46.2",
"@esbuild-plugins/node-resolve": "^0.1.4",
"@types/jest": "^29.2.4",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"nock": "^13.4.0",
"ts-jest": "^29.0.3",
"tslib": "^2.3.1",
"tsup": "^6.7.0"
},
"dependencies": {
"@qualifyze/airtable-formulator": "^1.0.1",
"airtable": "^0.11.1",
"asyncairtable": "^2.1.0",
"query-string": "^7.1.1"
},
"peerDependencies": {
"@refinedev/core": "^4.46.1"
},
"repository": {
"type": "git",
"url": "https://github.com/refinedev/refine.git",
"directory": "packages/airtable"
},
"gitHead": "829f5a516f98c06f666d6be3e6e6099c75c07719",
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,80 @@
/** @type {import('@refinedev/cli').RefineConfig} */
module.exports = {
group: "Data Provider",
swizzle: {
items: [
{
label: "Data Provider",
requiredPackages: [
"@qualifyze/airtable-formulator@1.0.1",
"airtable@0.11.1",
"asyncairtable@2.1.0",
"query-string@7.1.1",
],
files: [
{
src: "./src/index.ts",
dest: "./providers/airtable/index.ts",
},
{
src: "./src/dataProvider.ts",
dest: "./providers/airtable/dataProvider.ts",
},
{
src: "./src/utils/index.ts",
dest: "./providers/airtable/utils/index.ts",
},
{
src: "./src/utils/generateFilter.ts",
dest: "./providers/airtable/utils/generateFilter.ts",
},
{
src: "./src/utils/generateFilterFormula.ts",
dest: "./providers/airtable/utils/generateFilterFormula.ts",
},
{
src: "./src/utils/generateLogicalFilterFormula.ts",
dest: "./providers/airtable/utils/generateLogicalFilterFormula.ts",
},
{
src: "./src/utils/generateLogicalFilterFormula.ts",
dest: "./providers/airtable/utils/generateLogicalFilterFormula.ts",
},
{
src: "./src/utils/generateSort.ts",
dest: "./providers/airtable/utils/generateSort.ts",
},
{
src: "./src/utils/isContainsOperator.ts",
dest: "./providers/airtable/utils/isContainsOperator.ts",
},
{
src: "./src/utils/isSimpleOperator.ts",
dest: "./providers/airtable/utils/isSimpleOperator.ts",
},
],
message: `
**\`Usage\`**
\`\`\`
// title: App.tsx
import { dataProvider } from "./providers/airtable";
const API_TOKEN = "dummy-api-token";
const BASE_ID = "dummy-base-id";
const App = () => {
return (
<Refine
dataProvider={dataProvider(API_TOKEN, BASE_ID)}
/* ... */
/>
);
}
\`\`\`
`,
},
],
},
};

View file

@ -0,0 +1,161 @@
import { DataProvider } from "@refinedev/core";
import Airtable from "airtable";
import { AirtableBase } from "airtable/lib/airtable_base";
import { generateSort, generateFilter } from "./utils";
export const dataProvider = (
apiKey: string,
baseId: string,
airtableClient?: AirtableBase,
): Required<DataProvider> => {
const base =
airtableClient || new Airtable({ apiKey: apiKey }).base(baseId);
return {
getList: async ({ resource, pagination, sorters, filters }) => {
const {
current = 1,
pageSize = 10,
mode = "server",
} = pagination ?? {};
const generatedSort = generateSort(sorters) || [];
const queryFilters = generateFilter(filters);
const { all } = base(resource).select({
pageSize: 100,
sort: generatedSort,
...(queryFilters ? { filterByFormula: queryFilters } : {}),
});
const data = await all();
const isServerPaginationEnabled = mode === "server";
return {
data: data
.slice(
isServerPaginationEnabled
? (current - 1) * pageSize
: undefined,
isServerPaginationEnabled
? current * pageSize
: undefined,
)
.map((p) => ({
id: p.id,
...p.fields,
})) as any,
total: data.length,
};
},
getMany: async ({ resource, ids }) => {
const { all } = base(resource).select({
pageSize: 100,
});
const data = await all();
return {
data: data
.filter((p) => ids.includes(p.id))
.map((p) => ({
id: p.id,
...p.fields,
})) as any,
};
},
create: async ({ resource, variables }) => {
const { id, fields } = await base(resource).create(variables);
return {
data: {
id: id,
...fields,
} as any,
};
},
createMany: async ({ resource, variables }) => {
const data = await base(resource).create(variables);
return {
data: data.map((p) => ({
id: p.id,
...p.fields,
})) as any,
};
},
update: async ({ resource, id, variables }) => {
const { fields } = await base(resource).update(
id.toString(),
variables,
);
return {
data: {
id,
...fields,
} as any,
};
},
updateMany: async ({ resource, ids, variables }) => {
const requestParams = ids.map((id) => ({
id: id.toString(),
fields: { ...variables },
}));
const data = await base(resource).update(requestParams);
return {
data: data.map((p) => ({
id: p.id,
...p.fields,
})) as any,
};
},
getOne: async ({ resource, id }) => {
const { fields } = await base(resource).find(id.toString());
return {
data: {
id,
...fields,
} as any,
};
},
deleteOne: async ({ resource, id }) => {
const { fields } = await base(resource).destroy(id.toString());
return {
data: {
id,
...fields,
} as any,
};
},
deleteMany: async ({ resource, ids }) => {
const data = await base(resource).destroy(ids.map(String));
return {
data: data.map((p) => ({
id: p.id,
...p.fields,
})) as any,
};
},
getApiUrl: () => {
throw Error("Not implemented on refine-airtable data provider.");
},
custom: async () => {
throw Error("Not implemented on refine-airtable data provider.");
},
};
};

View file

@ -0,0 +1,5 @@
import { dataProvider } from "./dataProvider";
export * from "./utils";
export * from "./dataProvider";
export default dataProvider;

View file

@ -0,0 +1,12 @@
import { CrudFilters } from "@refinedev/core";
import { compile } from "@qualifyze/airtable-formulator";
import { generateFilterFormula } from "./generateFilterFormula";
export const generateFilter = (filters?: CrudFilters): string | undefined => {
if (filters) {
// Top-level array has an implicit AND as per CRUDFilter design - https://refine.dev/docs/guides-and-concepts/data-provider/handling-filters/#logicalfilters
return compile(["AND", ...generateFilterFormula(filters)]);
}
return undefined;
};

View file

@ -0,0 +1,17 @@
import { CrudFilters, LogicalFilter } from "@refinedev/core";
import { Formula } from "@qualifyze/airtable-formulator";
import { generateLogicalFilterFormula } from "./generateLogicalFilterFormula";
export const generateFilterFormula = (filters: CrudFilters): Formula[] => {
const compound = filters.map((filter): Formula => {
const { operator, value } = filter;
if (operator === "or") {
return ["OR", ...generateFilterFormula(value)];
}
return generateLogicalFilterFormula(filter as LogicalFilter);
});
return compound;
};

View file

@ -0,0 +1,50 @@
import { LogicalFilter } from "@refinedev/core";
import { isContainsOperator, isContainssOperator } from "./isContainsOperator";
import { isSimpleOperator, simpleOperatorMapping } from "./isSimpleOperator";
import { Formula } from "@qualifyze/airtable-formulator";
export const generateLogicalFilterFormula = (
filter: LogicalFilter,
): Formula => {
const { field, operator, value } = filter;
if (isSimpleOperator(operator)) {
return [simpleOperatorMapping[operator], { field }, value];
}
if (isContainssOperator(operator)) {
const mappedOperator = {
containss: "!=",
ncontainss: "=",
} as const;
return [mappedOperator[operator], ["FIND", value, { field }], 0];
}
if (isContainsOperator(operator)) {
const mappedOperator = {
contains: "!=",
ncontains: "=",
} as const;
const find = [
"FIND",
["LOWER", value],
["LOWER", { field }],
] as Formula;
return [mappedOperator[operator], find, 0];
}
if (operator === "null") {
return ["=", { field }, ["BLANK"]];
}
if (operator === "nnull") {
return ["!=", { field }, ["BLANK"]];
}
throw Error(
`Operator ${operator} is not supported for the Airtable data provider`,
);
};

View file

@ -0,0 +1,8 @@
import { CrudSorting } from "@refinedev/core";
export const generateSort = (sorters?: CrudSorting) => {
return sorters?.map((item) => ({
field: item.field,
direction: item.order,
}));
};

View file

@ -0,0 +1,6 @@
export * from "./isSimpleOperator";
export * from "./isContainsOperator";
export * from "./generateLogicalFilterFormula";
export * from "./generateFilterFormula";
export * from "./generateFilter";
export * from "./generateSort";

View file

@ -0,0 +1,9 @@
export const isContainssOperator = (
operator: any,
): operator is "containss" | "ncontainss" =>
["containss", "ncontainss"].includes(operator);
export const isContainsOperator = (
operator: any,
): operator is "contains" | "ncontains" =>
["contains", "ncontains"].includes(operator);

View file

@ -0,0 +1,14 @@
export type SimpleOperators = "eq" | "ne" | "lt" | "lte" | "gt" | "gte";
import { OperatorSymbol } from "@qualifyze/airtable-formulator";
export const simpleOperatorMapping: Record<SimpleOperators, OperatorSymbol> = {
eq: "=",
ne: "!=",
lt: "<",
lte: "<=",
gt: ">",
gte: ">=",
} as const;
export const isSimpleOperator = (operator: any): operator is SimpleOperators =>
Object.keys(simpleOperatorMapping).includes(operator);

View file

@ -0,0 +1,48 @@
import nock from "nock";
nock("https://api.airtable.com:443", { encodedQueryParams: true })
.post("/v0/appKYl1H4k9g73sBT/posts/", {
fields: {
title: "foo",
content: "bar",
status: "published",
category: ["recDBRJljBDFH4rIh"],
},
})
.query({})
.reply(
200,
[
"1f8b0800000000000003158c3d0f82301400ffcb9bc5b4151d3aa2821227c3e4c750e8436a2a35e53118c27ff7b15eee6e02674143c4a63c16785126dbc622aff7b082d6a1b703e809c89147b6da10980f6468640edfb1f66ee8d0326c0ce12bc41fe8fb323b64d7d2bfb3437e4ae3b983270ba127ec89b3dac4470f33a3885cd9ca7d96b9124a266297a8b4921b2d85966a2d84b8c1fc0704ab8bf4a4000000",
],
[
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-origin",
"*",
"content-encoding",
"gzip",
"Content-Type",
"application/json; charset=utf-8",
"Date",
"Thu, 24 Jun 2021 13:10:12 GMT",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwhuRDEqN6Ub31j3; path=/; expires=Fri, 24 Jun 2022 13:10:12 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"Vary",
"Accept-Encoding",
"X-Content-Type-Options",
"nosniff",
"X-Frame-Options",
"DENY",
"Content-Length",
"160",
"Connection",
"Close",
],
);

View file

@ -0,0 +1,26 @@
import dataProvider from "../../src/index";
import "./index.mock";
describe("create", () => {
it("correct response", async () => {
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).create({
resource: "posts",
variables: {
title: "foo",
content: "bar",
status: "published",
category: ["recDBRJljBDFH4rIh"],
},
});
const { data } = response;
expect(data["title"]).toEqual("foo");
expect(data["status"]).toEqual("published");
expect(data["category"]).toEqual(["recDBRJljBDFH4rIh"]);
expect(data["content"]).toEqual("bar\n");
});
});

View file

@ -0,0 +1,14 @@
import dataProvider from "../../src/index";
describe("custom", () => {
it("correct get response", async () => {
try {
await dataProvider("keywoytODSr6xAqfg", "appKYl1H4k9g73sBT")
.custom!({ url: "users", method: "get" });
} catch (error) {
expect(error).toEqual(
Error("Not implemented on refine-airtable data provider."),
);
}
});
});

View file

@ -0,0 +1,41 @@
import nock from "nock";
nock("https://api.airtable.com:443", { encodedQueryParams: true })
.delete("/v0/appKYl1H4k9g73sBT/posts")
.query({ "records%5B%5D": "recdgFXue7JnGD90w" })
.reply(
200,
[
"1f8b0800000000000003ab562a4a4dce2f4a2956b28aae564a49cd492d494d51b22a292a4dd551ca04b240f229e96e11a5a9e65e79ee2e9606e54ab5b1b5002d0259eb37000000",
],
[
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-origin",
"*",
"content-encoding",
"gzip",
"Content-Type",
"application/json; charset=utf-8",
"Date",
"Thu, 24 Jun 2021 13:17:00 GMT",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwTjdQAZbqbk0xGk; path=/; expires=Fri, 24 Jun 2022 13:17:00 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"Vary",
"Accept-Encoding",
"X-Content-Type-Options",
"nosniff",
"X-Frame-Options",
"DENY",
"Content-Length",
"71",
"Connection",
"Close",
],
);

View file

@ -0,0 +1,15 @@
import dataProvider from "../../src/index";
import "./index.mock";
describe("deleteMany", () => {
it("correct response", async () => {
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).deleteMany!({ resource: "posts", ids: ["recdgFXue7JnGD90w"] });
const { data } = response;
expect(data).not.toBeNull();
});
});

View file

@ -0,0 +1,41 @@
import nock from "nock";
nock("https://api.airtable.com:443", { encodedQueryParams: true })
.delete("/v0/appKYl1H4k9g73sBT/posts/recJEGeL2aB5rGFbC")
.query({})
.reply(
200,
[
"1f8b0800000000000003ab564a49cd492d494d51b22a292a4dd551ca04b2948a5293bd5cdd537d8c129d4c8bdcdd929c956a016e8d991b29000000",
],
[
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-origin",
"*",
"content-encoding",
"gzip",
"Content-Type",
"application/json; charset=utf-8",
"Date",
"Thu, 24 Jun 2021 13:19:14 GMT",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwInLaPzl4WP7sXu; path=/; expires=Fri, 24 Jun 2022 13:19:13 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"Vary",
"Accept-Encoding",
"X-Content-Type-Options",
"nosniff",
"X-Frame-Options",
"DENY",
"Content-Length",
"59",
"Connection",
"Close",
],
);

View file

@ -0,0 +1,15 @@
import dataProvider from "../../src/index";
import "./index.mock";
describe("deleteOne", () => {
it("correct response", async () => {
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).deleteOne({ resource: "posts", id: "recJEGeL2aB5rGFbC" });
const { data } = response;
expect(data).not.toBeNull();
});
});

View file

@ -0,0 +1,150 @@
import nock from "nock";
import url from "url";
const commonHeaders = [
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-origin",
"*",
"airtable-uncompressed-content-length",
"380",
"Content-Type",
"application/json; charset=utf-8",
"Date",
"Thu, 24 Jun 2021 12:24:32 GMT",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwHislGvzT3Ws3Yf; path=/; expires=Fri, 24 Jun 2022 12:24:32 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"Vary",
"Accept-Encoding",
"X-Content-Type-Options",
"nosniff",
"X-Frame-Options",
"DENY",
"Content-Length",
"233",
"Connection",
"Close",
];
nock("https://api.airtable.com:443", { encodedQueryParams: true })
.persist()
.get("/v0/appKYl1H4k9g73sBT/posts")
.query((query) => {
if (query.pageSize !== "100") return false;
if (query.filterByFormula === undefined) return false;
return true;
})
.reply(
200,
function () {
const parsed = new url.URL(this.req.path, "http://example.com");
const query = parsed.searchParams.get("filterByFormula");
return JSON.stringify({
offset: 0,
records: [
{
fields: {
query,
},
},
],
});
},
commonHeaders,
);
nock("https://api.airtable.com:443", { encodedQueryParams: true })
.get("/v0/appKYl1H4k9g73sBT/posts")
.query({ pageSize: "100" })
.reply(
200,
[
"1f8b0800000000000003954fc18ac23014fc157dc7a292562bdaa3c86a570f2215c5ad87da3cd7484cd634825aecb7fb82a0c74598c3639899375382c15c1b5e40f45382e01039a23fdaaea637dee517d58925346027503a4d09565889a41aa394bab6d446f25abb4e923cb3f8abcd95825cc47030ff9687c1f06bdc31f11e360d286c66cf94017fe7ad14c51eb9736965515962b38213525555cf8b5055a94a95e7bd88ccf3520577b219a4773c1147d7256081df64dda61f26be1f056114f65a8cb13529df9ba693b9d027b14bcc6c11dffedbf4d9228307cca9cf27d536f7073da8e6fb7c010000",
],
[
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-origin",
"*",
"airtable-uncompressed-content-length",
"380",
"content-encoding",
"gzip",
"Content-Type",
"application/json; charset=utf-8",
"Date",
"Thu, 24 Jun 2021 12:24:32 GMT",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwHislGvzT3Ws3Yf; path=/; expires=Fri, 24 Jun 2022 12:24:32 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"Vary",
"Accept-Encoding",
"X-Content-Type-Options",
"nosniff",
"X-Frame-Options",
"DENY",
"Content-Length",
"233",
"Connection",
"Close",
],
);
nock("https://api.airtable.com:443", { encodedQueryParams: true })
.get("/v0/appKYl1H4k9g73sBT/posts")
.query({
pageSize: "100",
"sort%5B0%5D%5Bfield%5D": "title",
"sort%5B0%5D%5Bdirection%5D": "desc",
})
.reply(
200,
[
"1f8b0800000000000003958f416bc2401085ff8ace31a86ca211cd518235d64391144b1b0f4976ac2beb6ebbbb426b30bfdd490bf59243857718debc6f785381c1521b6e217aab4070881a63f5b816fa53ec52f3f49c9ca1073b81b2c954605dee4ef62776c0d221a76d993b7cd7e69b6e34743c5b2fe56116cf172393ec61db03279c44621628a5ee6cb491bc0b17020d12ca53716cb6010bfc3e1bf7fd30f5fd2808a37032608cbd52f2566dfa50bcacce7cccbfd42891edd53e4e851476ffbf6ea5560e95232cb79c94a9bafe9d48759da94c79de9f917b5ea6a0f5a3cef0ae9fb6972ba8a00e5b7c010000",
],
[
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-origin",
"*",
"airtable-uncompressed-content-length",
"380",
"content-encoding",
"gzip",
"Content-Type",
"application/json; charset=utf-8",
"Date",
"Thu, 24 Jun 2021 13:07:26 GMT",
"Server",
"Tengine",
"Set-Cookie",
"brw=brw0sykMWa9glzNWF; path=/; expires=Fri, 24 Jun 2022 13:07:25 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"Vary",
"Accept-Encoding",
"X-Content-Type-Options",
"nosniff",
"X-Frame-Options",
"DENY",
"Content-Length",
"237",
"Connection",
"Close",
],
);

View file

@ -0,0 +1,444 @@
import { ConditionalFilter } from "@refinedev/core";
import dataProvider from "../../src/index";
import "./index.mock";
describe("getList", () => {
it("correct response", async () => {
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
});
expect(response.data[0]["id"]).toBe("rec9GbXLzd6dxn4Il");
expect(response.data[0]["title"]).toBe("Hello World 3!");
expect(response.total).toBe(2);
});
it("correct sorting response", async () => {
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
sorters: [
{
field: "title",
order: "desc",
},
],
});
expect(response.data[0]["id"]).toBe("recLKRioqifTrPUIz");
expect(response.data[0]["title"]).toBe("Hello World!");
expect(response.total).toBe(2);
});
it("correct equals filter for strings", async () => {
const filter = {
operator: "eq",
field: "title",
value: "Hello World!",
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must equal exactly string
expect(response.data[0]["query"]).toBe('AND({title}="Hello World!")');
});
it("correct equals filter for numbers", async () => {
const filter = {
operator: "eq",
field: "age",
value: 100,
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must equal exactly number
expect(response.data[0]["query"]).toBe("AND({age}=100)");
});
it("correct not equals filter for strings", async () => {
const filter = {
operator: "ne",
field: "title",
value: "Hello World!",
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must not equal exactly string
expect(response.data[0]["query"]).toBe('AND({title}!="Hello World!")');
});
it("correct not equals filter for numbers", async () => {
const filter = {
operator: "ne",
field: "age",
value: 100,
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must not equal exactly number
expect(response.data[0]["query"]).toBe("AND({age}!=100)");
});
it("correct less than filter", async () => {
const filter = {
operator: "lt",
field: "age",
value: 10,
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must be less than value (as number)
expect(response.data[0]["query"]).toBe("AND({age}<10)");
});
it("correct less than or equal filter", async () => {
const filter = {
operator: "lte",
field: "age",
value: 10,
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must be less than or equal value (as number)
expect(response.data[0]["query"]).toBe("AND({age}<=10)");
});
it("correct greater than filter", async () => {
const filter = {
operator: "gt",
field: "age",
value: 10,
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must be greater than value (as number)
expect(response.data[0]["query"]).toBe("AND({age}>10)");
});
it("correct greater than or equal filter", async () => {
const filter = {
operator: "gte",
field: "age",
value: 10,
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must be greater than or equal value (as number)
expect(response.data[0]["query"]).toBe("AND({age}>=10)");
});
it("correct contains filter", async () => {
const filter = {
operator: "containss",
field: "title",
value: "Hello",
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// must find string in {field} - FIND returns non-zero value
expect(response.data[0]["query"]).toBe('AND(FIND("Hello",{title})!=0)');
});
it("correct not contains filter", async () => {
const filter = {
operator: "ncontainss",
field: "title",
value: "Hello",
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// must not find string in {field} - FIND returns zero
expect(response.data[0]["query"]).toBe('AND(FIND("Hello",{title})=0)');
});
it("correct case-insensitive contains filter", async () => {
const filter = {
operator: "contains",
field: "title",
value: "Hello",
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// must find lower-cased string in lower-cased {field} - lower-casing both values makes it case-insensitive
expect(response.data[0]["query"]).toBe(
'AND(FIND(LOWER("Hello"),LOWER({title}))!=0)',
);
});
it("correct case-insensitive not contains filter", async () => {
const filter = {
operator: "ncontains",
field: "title",
value: "Hello",
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// must not find lower-cased string in lower-cased {field} - lower-casing both values makes it case-insensitive
expect(response.data[0]["query"]).toBe(
'AND(FIND(LOWER("Hello"),LOWER({title}))=0)',
);
});
it("correct truthy null filter", async () => {
const filter = {
operator: "null",
field: "title",
value: undefined,
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must be null (blank)
expect(response.data[0]["query"]).toBe("AND({title}=BLANK())");
});
it("correct falsy null filter", async () => {
const filter = {
operator: "nnull",
field: "title",
value: undefined,
} as const;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must not be null (blank)
expect(response.data[0]["query"]).toBe("AND({title}!=BLANK())");
});
it.each(["between", "nbetween"] as const)(
"fails for %s filter",
async (operator) => {
const filter = {
operator,
field: "age",
value: [10, 15],
} as const;
await expect(() => {
return dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
}).rejects.toThrow(
`Operator ${operator} is not supported for the Airtable data provider`,
);
},
);
it.each(["in", "nin"] as const)("fails for %s filter", async (operator) => {
const filter = {
operator,
field: "posts",
value: ["uuid-1", "uuid-2"],
} as const;
await expect(() => {
return dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
}).rejects.toThrow(
`Operator ${operator} is not supported for the Airtable data provider`,
);
});
it("correct 'or' conditional filter", async () => {
const filter = {
operator: "or",
value: [
{
field: "title",
operator: "eq",
value: "Silver Bullet",
},
{
field: "title",
operator: "ne",
value: "The Mythical Man Month",
},
],
} as ConditionalFilter;
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: [filter],
});
expect(response.total).toBe(1);
// {field} must either be Silver Bullet or must not be Mythical Man Month
expect(response.data[0]["query"]).toBe(
'AND(OR({title}="Silver Bullet",{title}!="The Mythical Man Month"))',
);
});
it("correct compound 'or' conditional filter", async () => {
const filters = [
{
operator: "or",
value: [
{
field: "title",
operator: "eq",
value: "Silver Bullet",
},
{
field: "title",
operator: "ne",
value: "The Mythical Man Month",
},
],
},
{
operator: "or",
value: [
{
field: "age",
operator: "gt",
value: 15,
},
{
field: "age",
operator: "lt",
value: 25,
},
],
},
] as ConditionalFilter[];
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getList({
resource: "posts",
filters: filters,
});
expect(response.total).toBe(1);
expect(response.data[0]["query"]).toBe(
'AND(OR({title}="Silver Bullet",{title}!="The Mythical Man Month"),OR({age}>15,{age}<25))',
);
});
});

View file

@ -0,0 +1,43 @@
import nock from "nock";
nock("https://api.airtable.com:443", { encodedQueryParams: true })
.get("/v0/appKYl1H4k9g73sBT/posts")
.query({ pageSize: "100" })
.reply(
200,
[
"1f8b080000000000000395905d6b83301486ff4a974b69875a959acb526aed1c88d875747a114d5c53b2a48be9585bf4b72f6eeca3e0608373111e5e4e9ef79c8124a590b806f0e10c2806b0039302c55e54851b278d571118828a12d665ce4051c5884e5542685e2ba40e9a83fda160b4de12ac6129b8225c695a2099f18e20451e853cea4fbaf577c151be24cbc05fdfeed51ce48d4e48a23338a54fdd72dbb4ad91e98d6c27b5c6d0f4a133b9364d73039ae1b7a41f14f7d1097bf8953b21bb90ecd7ba94984d9325db4d67f38523c32dc87f7aa31aebc978db7ebcf4b46dc6336e185f0019c67bb7cf832c086362b01692e1c1f80afcdac97253cb82b60bdd9e4ed14d42c533ad5219afc2537f274976a4547fabd423f72fb5bc7903b0079a9c21020000",
],
[
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-origin",
"*",
"airtable-uncompressed-content-length",
"545",
"content-encoding",
"gzip",
"Content-Type",
"application/json; charset=utf-8",
"Date",
"Thu, 24 Jun 2021 13:20:40 GMT",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwNd7NWRhWftbS0Q; path=/; expires=Fri, 24 Jun 2022 13:20:40 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"Vary",
"Accept-Encoding",
"X-Content-Type-Options",
"nosniff",
"X-Frame-Options",
"DENY",
"Content-Length",
"294",
"Connection",
"Close",
],
);

View file

@ -0,0 +1,20 @@
import dataProvider from "../../src/index";
import "./index.mock";
describe("getMany", () => {
it("correct response", async () => {
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getMany!({
resource: "posts",
ids: ["recLKRioqifTrPUIz", "rec9GbXLzd6dxn4Il"],
});
const { data } = response;
expect(data[0]["id"]).toBe("rec9GbXLzd6dxn4Il");
expect(data[1]["id"]).toBe("recLKRioqifTrPUIz");
expect(response.data.length).toBe(2);
});
});

View file

@ -0,0 +1,43 @@
import nock from "nock";
nock("https://api.airtable.com:443", { encodedQueryParams: true })
.get("/v0/appKYl1H4k9g73sBT/posts/recLKRioqifTrPUIz")
.query({})
.reply(
200,
[
"1f8b08000000000000031dcb410bc2201880e1bf52df790b958cf038c6d8aa430c23283a0cfd560e43523bd4d87fcf757edf6704a341804775d8b7c6bd4c2ffdf1d47c2183dea0d501c4082176f11dfedb802aa24e557511efce7f405c675d16edce0e4559d56bdf3ce0964134d16232355aeb1667e7ad5ec294a0c744b534cfb932c2684e3639e59252c1b8e0db1521e402d30fb10f929e9a000000",
],
[
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-origin",
"*",
"airtable-uncompressed-content-length",
"154",
"content-encoding",
"gzip",
"Content-Type",
"application/json; charset=utf-8",
"Date",
"Thu, 24 Jun 2021 13:21:53 GMT",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwQBAba4kMeu0MpF; path=/; expires=Fri, 24 Jun 2022 13:21:53 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"Vary",
"Accept-Encoding",
"X-Content-Type-Options",
"nosniff",
"X-Frame-Options",
"DENY",
"Content-Length",
"156",
"Connection",
"Close",
],
);

View file

@ -0,0 +1,17 @@
import dataProvider from "../../src/index";
import "./index.mock";
describe("getOne", () => {
it("correct response", async () => {
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).getOne({ resource: "posts", id: "recLKRioqifTrPUIz" });
const { data } = response;
expect(data.title).toBe("Hello World!");
expect(data.status).toBe("rejected");
expect(data.category).toEqual(["recDBRJljBDFH4rIh"]);
});
});

View file

@ -0,0 +1,11 @@
const fetch = require("node-fetch");
const nock = require("nock");
global.fetch = window.fetch = fetch;
global.Request = window.Request = fetch.Request;
global.Response = window.Response = fetch.Response;
afterAll(() => {
nock.cleanAll();
nock.restore();
});

View file

@ -0,0 +1,43 @@
import nock from "nock";
nock("https://api.airtable.com:443", { encodedQueryParams: true })
.patch("/v0/appKYl1H4k9g73sBT/posts/recLKRioqifTrPUIz", {
fields: { title: "Hello World!!" },
})
.query({})
.reply(
200,
[
"1f8b08000000000000031dcb410bc2201880e1bfd2bef3162a19e1718cd8aa430c23283a0cfd560e43523bd4d87fcf757edf6704a341804775d8b7c6bd4c2ffdf1d47c2187dea0d501c4082176f11dfedb802aa24e557511efce7f405c675d95edce0e65b5ad57be79c02d8768a2c5646ab4d62dcece5b9d653025e931592dcd73ce8c305a907541b9a454302ef8664908b9c0f4030447c06c9b000000",
],
[
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-origin",
"*",
"content-encoding",
"gzip",
"Content-Type",
"application/json; charset=utf-8",
"Date",
"Thu, 24 Jun 2021 13:24:42 GMT",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwQrvJXnX0I6wkCt; path=/; expires=Fri, 24 Jun 2022 13:24:42 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"Vary",
"Accept-Encoding",
"X-Content-Type-Options",
"nosniff",
"X-Frame-Options",
"DENY",
"Content-Length",
"157",
"Connection",
"Close",
],
);

View file

@ -0,0 +1,21 @@
import dataProvider from "../../src/index";
import "./index.mock";
describe("update", () => {
it("correct response", async () => {
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).update({
resource: "posts",
id: "recLKRioqifTrPUIz",
variables: {
title: "Hello World!!",
},
});
const { data } = response;
expect(data["title"]).toBe("Hello World!!");
});
});

View file

@ -0,0 +1,46 @@
import nock from "nock";
nock("https://api.airtable.com:443", { encodedQueryParams: true })
.patch("/v0/appKYl1H4k9g73sBT/posts/", {
records: [
{ id: "recLKRioqifTrPUIz", fields: { title: "Hello World!!!" } },
{ id: "rec9GbXLzd6dxn4Il", fields: { title: "Hello World!!!" } },
],
})
.query({})
.reply(
200,
[
"1f8b0800000000000003ad8f416bc2401085ff8a996350d9042336471135ad872229169b1c6276d49575577757680de6b73b69412f3d7810e630bcf7bee14d05064b6db885f8ab02c1216e84d9db5ce8a358a7e6fd2339431bd6026593a9c0bac29dec6f6c87a5434e6e5938dc68f343371a7a349cbfcadd70349ef64cb285bc0d4e3889c44c514add5a6823b9e7797021d420c13c15fbc60f59187458bf13446910c4611447832e636c49c97bb997c9ea7376e67dfead7a89fcbfdce1b492c26e1f6b576ae55039c20acb693255d77f1b4d5d672a53be7f130adfcf143ce3a7fc720554f78d147e010000",
],
[
"access-control-allow-headers",
"authorization,content-length,content-type,user-agent,x-airtable-application-id,x-airtable-user-agent,x-api-version,x-requested-with",
"access-control-allow-methods",
"DELETE,GET,OPTIONS,PATCH,POST,PUT",
"access-control-allow-origin",
"*",
"content-encoding",
"gzip",
"Content-Type",
"application/json; charset=utf-8",
"Date",
"Thu, 24 Jun 2021 13:26:53 GMT",
"Server",
"Tengine",
"Set-Cookie",
"brw=brwPAe5xxAm5wIYu2; path=/; expires=Fri, 24 Jun 2022 13:26:53 GMT; domain=.airtable.com; samesite=none; secure",
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
"Vary",
"Accept-Encoding",
"X-Content-Type-Options",
"nosniff",
"X-Frame-Options",
"DENY",
"Content-Length",
"235",
"Connection",
"Close",
],
);

View file

@ -0,0 +1,22 @@
import dataProvider from "../../src/index";
import "./index.mock";
describe("updateMany", () => {
it("correct response", async () => {
const response = await dataProvider(
"keywoytODSr6xAqfg",
"appKYl1H4k9g73sBT",
).updateMany!({
resource: "posts",
ids: ["recLKRioqifTrPUIz", "rec9GbXLzd6dxn4Il"],
variables: {
title: "Hello World!!!",
},
});
const { data } = response;
expect(data[0]["title"]).toBe("Hello World!!!");
expect(data[1]["title"]).toBe("Hello World!!!");
});
});

View file

@ -0,0 +1,33 @@
import { CrudFilters } from "@refinedev/core";
import { generateFilter } from "../../src/utils";
describe("generateFilter", () => {
it("should return undefined when no filters are provided", () => {
expect(generateFilter()).toBeUndefined();
});
it("should return a filter formula when filters are provided", () => {
const filters: CrudFilters = [
{ field: "name", operator: "eq", value: "John" },
{ field: "age", operator: "gte", value: 30 },
];
const expected = 'AND({name}="John",{age}>=30)';
expect(generateFilter(filters)).toBe(expected);
});
it("should return a complex filter formula with nested filters", () => {
const filters: CrudFilters = [
{
operator: "or",
value: [
{ field: "name", operator: "eq", value: "John" },
{ field: "name", operator: "eq", value: "Jane" },
],
},
{ field: "age", operator: "gte", value: 30 },
];
const expected = 'AND(OR({name}="John",{name}="Jane"),{age}>=30)';
expect(generateFilter(filters)).toBe(expected);
});
});

View file

@ -0,0 +1,44 @@
import { CrudFilters } from "@refinedev/core";
import { generateFilterFormula } from "../../src/utils";
describe("generateFilterFormula", () => {
it("should return an empty array when no filters are provided", () => {
expect(generateFilterFormula([])).toEqual([]);
});
it("should return a formula array when filters are provided", () => {
const filters: CrudFilters = [
{ field: "name", operator: "eq", value: "John" },
{ field: "age", operator: "gte", value: 30 },
];
const expected = [
["=", { field: "name" }, "John"],
[">=", { field: "age" }, 30],
];
expect(generateFilterFormula(filters)).toEqual(expected);
});
it("should return a complex formula array with nested filters", () => {
const filters: CrudFilters = [
{
operator: "or",
value: [
{ field: "name", operator: "eq", value: "John" },
{ field: "name", operator: "eq", value: "Jane" },
],
},
{ field: "age", operator: "gte", value: 30 },
];
const expected = [
[
"OR",
["=", { field: "name" }, "John"],
["=", { field: "name" }, "Jane"],
],
[">=", { field: "age" }, 30],
];
expect(generateFilterFormula(filters)).toEqual(expected);
});
});

View file

@ -0,0 +1,58 @@
import { CrudFilter } from "@refinedev/core";
import { generateLogicalFilterFormula } from "../../src/utils";
import { LogicalFilter } from "@refinedev/core";
describe("generateLogicalFilterFormula", () => {
it("should generate a formula for simple operators", () => {
const filter: CrudFilter = {
field: "age",
operator: "gte",
value: 30,
};
const expected = [">=", { field: "age" }, 30];
expect(generateLogicalFilterFormula(filter)).toEqual(expected);
});
it("should generate a formula for contains operators", () => {
const filter: CrudFilter = {
field: "name",
operator: "contains",
value: "John",
};
const expected = [
"!=",
["FIND", ["LOWER", "John"], ["LOWER", { field: "name" }]],
0,
];
expect(generateLogicalFilterFormula(filter)).toEqual(expected);
});
it("should generate a formula for null operators", () => {
const filter = { field: "email", operator: "null" } as LogicalFilter;
const expected = ["=", { field: "email" }, ["BLANK"]];
expect(generateLogicalFilterFormula(filter)).toEqual(expected);
});
it("should generate a formula for nnull operators", () => {
const filter = { field: "email", operator: "nnull" } as LogicalFilter;
const expected = ["!=", { field: "email" }, ["BLANK"]];
expect(generateLogicalFilterFormula(filter)).toEqual(expected);
});
it("should throw an error for unsupported operators", () => {
const filter = {
field: "age",
operator: "unsupported",
value: 30,
} as unknown as LogicalFilter;
expect(() => generateLogicalFilterFormula(filter)).toThrowError(
"Operator unsupported is not supported for the Airtable data provider",
);
});
});

View file

@ -0,0 +1,22 @@
import { CrudSorting } from "@refinedev/core";
import { generateSort } from "../../src/utils";
describe("generateSort", () => {
it("should return undefined if no sorters are provided", () => {
expect(generateSort(undefined)).toBeUndefined();
});
it("should generate an array of sorting objects", () => {
const sorters: CrudSorting = [
{ field: "name", order: "asc" },
{ field: "age", order: "desc" },
];
const expected = [
{ field: "name", direction: "asc" },
{ field: "age", direction: "desc" },
];
expect(generateSort(sorters)).toEqual(expected);
});
});

View file

@ -0,0 +1,31 @@
import { isContainssOperator, isContainsOperator } from "../../src/utils";
describe("Operators", () => {
describe("isContainssOperator", () => {
it("should return true if operator is containss", () => {
expect(isContainssOperator("containss")).toBe(true);
});
it("should return true if operator is ncontainss", () => {
expect(isContainssOperator("ncontainss")).toBe(true);
});
it("should return false if operator is not containss or ncontainss", () => {
expect(isContainssOperator("contains")).toBe(false);
});
});
describe("isContainsOperator", () => {
it("should return true if operator is contains", () => {
expect(isContainsOperator("contains")).toBe(true);
});
it("should return true if operator is ncontains", () => {
expect(isContainsOperator("ncontains")).toBe(true);
});
it("should return false if operator is not contains or ncontains", () => {
expect(isContainsOperator("containss")).toBe(false);
});
});
});

View file

@ -0,0 +1,30 @@
import { isSimpleOperator, simpleOperatorMapping } from "../../src/utils";
describe("SimpleOperators", () => {
describe("isSimpleOperator", () => {
it("should return true if operator is a simple operator", () => {
expect(isSimpleOperator("eq")).toBe(true);
expect(isSimpleOperator("ne")).toBe(true);
expect(isSimpleOperator("lt")).toBe(true);
expect(isSimpleOperator("lte")).toBe(true);
expect(isSimpleOperator("gt")).toBe(true);
expect(isSimpleOperator("gte")).toBe(true);
});
it("should return false if operator is not a simple operator", () => {
expect(isSimpleOperator("contains")).toBe(false);
expect(isSimpleOperator("containss")).toBe(false);
});
});
describe("simpleOperatorMapping", () => {
it("should map simple operators to their corresponding Airtable symbols", () => {
expect(simpleOperatorMapping["eq"]).toBe("=");
expect(simpleOperatorMapping["ne"]).toBe("!=");
expect(simpleOperatorMapping["lt"]).toBe("<");
expect(simpleOperatorMapping["lte"]).toBe("<=");
expect(simpleOperatorMapping["gt"]).toBe(">");
expect(simpleOperatorMapping["gte"]).toBe(">=");
});
});
});

View file

@ -0,0 +1,21 @@
{
"extends": "./tsconfig.json",
"exclude": [
"node_modules",
"dist",
"test",
"../test/**/*",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx"
],
"compilerOptions": {
"outDir": "dist",
"declarationDir": "dist",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"declarationMap": true
}
}

View file

@ -0,0 +1,8 @@
{
"include": ["src", "types", "refine.config.js"],
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"rootDir": "./src",
"baseUrl": "."
}
}

View file

@ -0,0 +1,24 @@
import { defineConfig } from "tsup";
import { NodeResolvePlugin } from "@esbuild-plugins/node-resolve";
export default defineConfig({
entry: ["src/index.ts"],
splitting: false,
sourcemap: true,
clean: false,
platform: "browser",
esbuildPlugins: [
NodeResolvePlugin({
extensions: [".js", "ts", "tsx", "jsx"],
onResolved: (resolved) => {
if (resolved.includes("node_modules")) {
return {
external: true,
};
}
return resolved;
},
}),
],
onSuccess: "tsc --project tsconfig.declarations.json",
});

11
packages/antd/.npmignore Normal file
View file

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

1
packages/antd/.npmrc Normal file
View file

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

3380
packages/antd/CHANGELOG.md Normal file

File diff suppressed because it is too large Load diff

71
packages/antd/README.md Normal file
View file

@ -0,0 +1,71 @@
<div align="center" style="margin: 30px;">
<a href="https://refine.dev">
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
</a>
</div>
<br/>
<div align="center">
<a href="https://refine.dev">Home Page</a> |
<a href="https://discord.gg/refine">Discord</a> |
<a href="https://refine.dev/examples/">Examples</a> |
<a href="https://refine.dev/blog/">Blog</a> |
<a href="https://refine.dev/docs/">Documentation</a>
<br/>
<br/>
[![Discord](https://img.shields.io/discord/837692625737613362.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/refine)
[![Twitter Follow](https://img.shields.io/twitter/follow/refine_dev?style=social)](https://twitter.com/refine_dev)
<a href="https://www.producthunt.com/posts/refine-3?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-refine&#0045;3" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=362220&theme=light&period=daily" alt="refine - 100&#0037;&#0032;open&#0032;source&#0032;React&#0032;framework&#0032;to&#0032;build&#0032;web&#0032;apps&#0032;3x&#0032;faster | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
<br/>
<div align="center">refine is an open-source, headless React framework for developers building enterprise internal tools, admin panels, dashboards, B2B applications.
<br/>
It eliminates repetitive tasks in CRUD operations and provides industry-standard solutions for critical project components like **authentication**, **access control**, **routing**, **networking**, **state management**, and **i18n**.
</div>
# Ant Design integration for refine
[Ant Design](https://ant.design/) is a React.js UI library that contains easy-to-use components that are useful for building interactive user interfaces.
[refine](https://refine.dev/) is **headless by design**, offering unlimited styling and customization options. Moreover, refine ships with ready-made integrations 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/) for convenience.
refine has connectors for 15+ backend services, including REST API, [GraphQL](https://graphql.org/), and popular services like [Airtable](https://www.airtable.com/), [Strapi](https://strapi.io/), [Supabase](https://supabase.com/), [Firebase](https://firebase.google.com/), and [NestJS](https://nestjs.com/).
## Installation & Usage
```
npm install @refinedev/antd antd
```
```tsx
import { ThemedLayoutV2 } from "@refinedev/antd";
import "@refinedev/antd/dist/reset.css";
const App = () => {
return (
<Refine
/* ... */
>
<ThemedLayoutV2>{/* ... */}</ThemedLayoutV2>
</Refine>
);
};
```
## Documentation
- For more detailed information and usage, refer to the [refine Ant Design documentation](https://refine.dev/docs/api-reference/antd/).
- [Refer to complete refine tutorial with Ant Design](https://refine.dev/docs/tutorial/introduction/select-framework/)
- [Refer to documentation for more info about refine](https://refine.dev/docs/).
- [Step up to refine tutorials](https://refine.dev/docs/tutorial/introduction/index/).

View file

@ -0,0 +1,31 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("./tsconfig.json");
const paths = compilerOptions.paths ? compilerOptions.paths : {};
module.exports = {
preset: "ts-jest",
rootDir: "./",
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/test/jest.setup.ts"],
testPathIgnorePatterns: ["<rootDir>/node_modules/"],
moduleNameMapper: {
...pathsToModuleNameMapper(paths, { prefix: "<rootDir>/" }),
"\\.css$": "identity-obj-proxy",
"^antd/es/": "antd/lib/",
"^.+\\.tsx?$": [
"ts-jest",
{
tsconfig: "<rootDir>/tsconfig.test.json",
},
],
},
displayName: "antd",
transform: {
"^.+\\.svg$": "<rootDir>/test/svgTransform.ts",
},
coveragePathIgnorePatterns: [
"<rootDir>/src/index.ts",
"<rootDir>/src/interfaces/",
],
};

View file

@ -0,0 +1,75 @@
{
"name": "@refinedev/antd",
"version": "5.37.2",
"description": "refine is a React-based framework for building internal tools, rapidly. It ships with Ant Design System, an enterprise-level UI toolkit.",
"private": false,
"sideEffects": [
"*.css"
],
"main": "dist/index.js",
"module": "dist/esm/index.js",
"typings": "dist/index.d.ts",
"scripts": {
"start": "tsup --watch --format esm,cjs,iife --legacy-output",
"build": "tsup --format esm,cjs,iife --minify --legacy-output",
"test": "jest --passWithNoTests --runInBand",
"prepare": "npm run build",
"generate-theme": "npx @emeks/antd-custom-theme-generator -w --antd ../../node_modules/antd ./src/assets/styles/custom-theme.less ./src/assets/styles/styles.min.css"
},
"peerDependencies": {
"@refinedev/core": "^4.46.1",
"@types/react": "^17.0.0 || ^18.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0",
"antd": "^5.0.5",
"dayjs": "^1.10.7",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"devDependencies": {
"@refinedev/cli": "^2.16.22",
"@refinedev/ui-tests": "^1.14.1",
"@refinedev/core": "^4.46.2",
"@esbuild-plugins/node-resolve": "^0.1.4",
"@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/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/testing-library__jest-dom": "^5.14.3",
"esbuild-copy-static-files": "^0.1.0",
"esbuild-plugin-inline-image": "^0.0.8",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"postcss": "^8.1.4",
"react-router-dom": "^6.8.1",
"ts-jest": "^29.0.3",
"tsup": "^6.7.0",
"typescript": "^4.7.4"
},
"dependencies": {
"@ant-design/icons": "5.0.1",
"@ant-design/pro-layout": "7.17.12",
"@refinedev/ui-types": "^1.22.4",
"@tanstack/react-query": "^4.10.1",
"antd": "^5.0.5",
"dayjs": "^1.10.7",
"react-markdown": "^6.0.1",
"remark-gfm": "^1.0.0",
"sunflower-antd": "1.0.0-beta.3",
"tslib": "^2.3.1"
},
"author": "refine",
"license": "MIT",
"gitHead": "829f5a516f98c06f666d6be3e6e6099c75c07719",
"repository": {
"type": "git",
"url": "https://github.com/refinedev/refine.git",
"directory": "packages/antd"
},
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,629 @@
const { dirname, join } = require("path");
const {
getImports,
appendAfterImports,
getFileContent,
} = require("@refinedev/cli");
/** @type {import('@refinedev/cli').RefineConfig} */
module.exports = {
group: "UI Framework",
swizzle: {
items: [
{
group: "Buttons",
label: "ShowButton",
files: [
{
src: "./src/components/buttons/show/index.tsx",
dest: "./components/buttons/show.tsx",
},
],
},
{
group: "Buttons",
label: "CreateButton",
message: `
**\`Warning:\`**
This component is used in the below component. If you want to change it, you can run the **swizzle** command for the below component or you can use props to override the default buttons.
- <List/>
`,
files: [
{
src: "./src/components/buttons/create/index.tsx",
dest: "./components/buttons/create.tsx",
},
],
},
{
group: "Buttons",
label: "CloneButton",
files: [
{
src: "./src/components/buttons/clone/index.tsx",
dest: "./components/buttons/clone.tsx",
},
],
},
{
group: "Buttons",
label: "DeleteButton",
message: `
**\`Warning:\`**
This component is used in the below components. If you want to change it, you can run the **swizzle** command for the below components or you can use props to override the default buttons.
- <Edit/>
- <List/>
`,
files: [
{
src: "./src/components/buttons/delete/index.tsx",
dest: "./components/buttons/delete.tsx",
},
],
},
{
group: "Buttons",
label: "EditButton",
message: `
**\`Warning:\`**
This component is used in the below component. If you want to change it, you can run the **swizzle** command for the below component or you can use props to override the default buttons.
- <Show/>
`,
files: [
{
src: "./src/components/buttons/edit/index.tsx",
dest: "./components/buttons/edit.tsx",
},
],
},
{
group: "Buttons",
label: "ExportButton",
files: [
{
src: "./src/components/buttons/export/index.tsx",
dest: "./components/buttons/export.tsx",
},
],
},
{
group: "Buttons",
label: "ImportButton",
files: [
{
src: "./src/components/buttons/import/index.tsx",
dest: "./components/buttons/import.tsx",
},
],
},
{
group: "Buttons",
label: "ListButton",
message: `
**\`Warning:\`**
This component is used in the below components. If you want to change it, you can run the **swizzle** command for the below components or you can use props to override the default buttons.
- <Edit/>
- <Show/>
`,
files: [
{
src: "./src/components/buttons/list/index.tsx",
dest: "./components/buttons/list.tsx",
},
],
},
{
group: "Buttons",
label: "RefreshButton",
message: `
**\`Warning:\`**
This component is used in the below components. If you want to change it, you can run the **swizzle** command for the below components or you can use props to override the default buttons.
- <Edit/>
- <Show/>
`,
files: [
{
src: "./src/components/buttons/refresh/index.tsx",
dest: "./components/buttons/refresh.tsx",
},
],
},
{
group: "Buttons",
label: "SaveButton",
message: `
**\`Warning:\`**
This component is used in the below components. If you want to change it, you can run the **swizzle** command for the below components or you can use props to override the default buttons.
- <Create/>
- <Edit/>
`,
files: [
{
src: "./src/components/buttons/save/index.tsx",
dest: "./components/buttons/save.tsx",
},
],
},
{
group: "Basic Views",
label: "Create",
files: [
{
src: "./src/components/crud/create/index.tsx",
dest: "./components/crud/create.tsx",
},
],
},
{
group: "Basic Views",
label: "List",
files: [
{
src: "./src/components/crud/list/index.tsx",
dest: "./components/crud/list.tsx",
},
],
},
{
group: "Basic Views",
label: "Show",
files: [
{
src: "./src/components/crud/show/index.tsx",
dest: "./components/crud/show.tsx",
},
],
},
{
group: "Basic Views",
label: "Edit",
files: [
{
src: "./src/components/crud/edit/index.tsx",
dest: "./components/crud/edit.tsx",
},
],
},
{
group: "Fields",
label: "BooleanField",
files: [
{
src: "./src/components/fields/boolean/index.tsx",
dest: "./components/fields/boolean.tsx",
},
],
},
{
group: "Fields",
label: "DateField",
files: [
{
src: "./src/components/fields/date/index.tsx",
dest: "./components/fields/date.tsx",
},
],
},
{
group: "Fields",
label: "EmailField",
files: [
{
src: "./src/components/fields/email/index.tsx",
dest: "./components/fields/email.tsx",
},
],
},
{
group: "Fields",
label: "FileField",
files: [
{
src: "./src/components/fields/file/index.tsx",
dest: "./components/fields/file.tsx",
},
],
},
{
group: "Fields",
label: "ImageField",
files: [
{
src: "./src/components/fields/image/index.tsx",
dest: "./components/fields/image.tsx",
},
],
},
{
group: "Fields",
label: "MarkdownField",
files: [
{
src: "./src/components/fields/markdown/index.tsx",
dest: "./components/fields/markdown.tsx",
},
],
},
{
group: "Fields",
label: "NumberField",
files: [
{
src: "./src/components/fields/number/index.tsx",
dest: "./components/fields/number.tsx",
},
],
},
{
group: "Fields",
label: "TagField",
files: [
{
src: "./src/components/fields/tag/index.tsx",
dest: "./components/fields/tag.tsx",
},
],
},
{
group: "Fields",
label: "TextField",
files: [
{
src: "./src/components/fields/text/index.tsx",
dest: "./components/fields/text.tsx",
},
],
},
{
group: "Fields",
label: "UrlField",
files: [
{
src: "./src/components/fields/url/index.tsx",
dest: "./components/fields/url.tsx",
},
],
},
{
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",
transform: (content) => {
let newContent = content;
// for remove RefineErorrPageProps
const refineErrorPagePropsRegex =
/React\.FC<RefineErrorPageProps>/g;
newContent = newContent.replace(
refineErrorPagePropsRegex,
"React.FC",
);
return newContent;
},
},
],
},
{
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",
transform: (content) => {
let newContent = content;
// for change style import path
const styleImportRegex = /"\.\.\/styles";/g;
newContent = newContent.replace(
styleImportRegex,
`"./styles";`,
);
return newContent;
},
},
{
src: "./src/components/pages/auth/components/login/index.tsx",
dest: "./components/pages/auth/components/login.tsx",
transform: (content) => {
let newContent = content;
// for change style import path
const styleImportRegex = /"\.\.\/styles";/g;
newContent = newContent.replace(
styleImportRegex,
`"./styles";`,
);
return newContent;
},
},
{
src: "./src/components/pages/auth/components/register/index.tsx",
dest: "./components/pages/auth/components/register.tsx",
transform: (content) => {
let newContent = content;
// for change style import path
const styleImportRegex = /"\.\.\/styles";/g;
newContent = newContent.replace(
styleImportRegex,
`"./styles";`,
);
return newContent;
},
},
{
src: "./src/components/pages/auth/components/updatePassword/index.tsx",
dest: "./components/pages/auth/components/updatePassword.tsx",
transform: (content) => {
let newContent = content;
// for change style import path
const styleImportRegex = /"\.\.\/styles";/g;
newContent = newContent.replace(
styleImportRegex,
`"./styles";`,
);
return newContent;
},
},
{
src: "./src/components/pages/auth/components/index.tsx",
dest: "./components/pages/auth/components/index.tsx",
},
{
src: "./src/components/pages/auth/components/styles.ts",
dest: "./components/pages/auth/components/styles.ts",
},
],
},
{
group: "Other",
label: "Breadcrumb",
message: `
**\`Warning:\`**
This component is used in the below components. If you want to change it, you can use props to override the default breadcrumb or you can manage globally with the **options** prop to the **<Refine/>** component.
- <Edit/>
- <List/>
- <Show/>
- <Create/>
**\`Passing Breadcrumb Globally:\`**
\`\`\`
// title: App.tsx
import { Breadcrumb } from "components/breadcrumb";
const App = () => {
return (
<Refine
options={{
breadcrumb: <Breadcrumb />
/* ... */
}}
/* ... */
/>
);
}
\`\`\`
`,
files: [
{
src: "./src/components/breadcrumb/index.tsx",
dest: "./components/breadcrumb.tsx",
transform: (content) => {
let newContent = content;
// for remove type export
const breadcrumbPropsExportRegex =
/export type BreadcrumbProps = RefineBreadcrumbProps<AntdBreadcrumbProps>;?/g;
newContent = newContent.replace(
breadcrumbPropsExportRegex,
`import { BreadcrumbProps } from "@refinedev/antd";`,
);
// change the breadcrumb import path
const breadcrumbImportRegex =
/BreadcrumbProps as AntdBreadcrumbProps,/g;
newContent = newContent.replace(
breadcrumbImportRegex,
"",
);
return newContent;
},
},
],
},
{
group: "Other",
label: "ThemedLayoutV2",
message: `
**\`Warning:\`**
If you want to change the default layout;
You should pass layout related components to the **<ThemedLayoutV2 />** component's props.
\`\`\`
// title: App.tsx
import { ThemedLayoutV2 } from "components/layout";
import { ThemedHeaderV2 } from "components/layout/header";
import { ThemedSiderV2 } from "components/layout/sider";
import { ThemedTitleV2 } from "components/layout/title";
const App = () => {
return (
<Refine
/* ... */
>
<ThemedLayoutV2 Header={ThemedHeaderV2} Sider={ThemedSiderV2} Title={ThemedTitleV2}>
/* ... */
</ThemedLayoutV2>
</Refine>
);
}
\`\`\`
`,
files: [
{
src: "./src/components/themedLayoutV2/sider/index.tsx",
dest: "./components/layout/sider.tsx",
transform: (content) => {
let newContent = content;
const imports = getImports(content);
imports.map((importItem) => {
// handle @components import replacement
if (
importItem.importPath === "@components" ||
importItem.importPath === "@hooks"
) {
const newStatement = `import ${importItem.namedImports} from "@refinedev/antd";`;
newContent = newContent.replace(
importItem.statement,
newStatement,
);
}
// add content of ./styles.ts and remove import
if (importItem.importPath === "./styles") {
newContent = newContent.replace(
importItem.statement,
"",
);
let appending = "";
try {
const stylesContent = getFileContent(
join(
dirname(
"./src/components/themedLayoutV2/sider/index.tsx",
),
"/styles.ts",
),
"utf-8",
).replace("export const", "const");
appending = stylesContent;
} catch (err) {
// console.log(err);
}
newContent = appendAfterImports(
newContent,
appending,
);
}
});
return newContent;
},
},
{
src: "./src/components/themedLayoutV2/header/index.tsx",
dest: "./components/layout/header.tsx",
},
{
src: "./src/components/themedLayoutV2/title/index.tsx",
dest: "./components/layout/title.tsx",
},
{
src: "./src/components/themedLayoutV2/index.tsx",
dest: "./components/layout/index.tsx",
transform: (content) => {
let newContent = content;
const imports = getImports(content);
imports.map((importItem) => {
// handle @components import replacement
if (
importItem.importPath === "@components" ||
importItem.importPath === "@contexts" ||
importItem.importPath === "@hooks"
) {
const newStatement = `import ${importItem.namedImports} from "@refinedev/antd";`;
newContent = newContent.replace(
importItem.statement,
newStatement,
);
}
});
return newContent;
},
},
],
},
],
transform: (content) => {
let newContent = content;
const imports = getImports(content);
imports.map((importItem) => {
if (importItem.importPath === "@components") {
const newStatement = `import ${importItem.namedImports} from "@refinedev/antd";`;
newContent = newContent.replace(
importItem.statement,
newStatement,
);
}
// for ui-types
if (importItem.importPath === "@refinedev/ui-types") {
newContent = newContent.replace(importItem.statement, "");
// prop is data-testid
// remove data-testid={*} from props
const testIdPropRegex = /data-testid={.*?}/g;
newContent = newContent.replace(testIdPropRegex, "");
}
// for prop types
if (
importItem.importPath === "../types" ||
importItem.importPath === "./types"
) {
const newStatement = `import type ${importItem.namedImports} from "@refinedev/antd";`;
newContent = newContent.replace(
importItem.statement,
newStatement,
);
}
});
return newContent;
},
},
};

View file

@ -0,0 +1,254 @@
/* stylelint-disable */
html,
body {
width: 100%;
height: 100%;
}
input::-ms-clear,
input::-ms-reveal {
display: none;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: sans-serif;
line-height: 1.15;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
-ms-overflow-style: scrollbar;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
@-ms-viewport {
width: device-width;
}
body {
margin: 0;
}
[tabindex="-1"]:focus {
outline: none;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
margin-bottom: 0.5em;
font-weight: 500;
}
p {
margin-top: 0;
margin-bottom: 1em;
}
abbr[title],
abbr[data-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline;
text-decoration: underline dotted;
border-bottom: 0;
cursor: help;
}
address {
margin-bottom: 1em;
font-style: normal;
line-height: inherit;
}
input[type="text"],
input[type="password"],
input[type="number"],
textarea {
-webkit-appearance: none;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1em;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 500;
}
dd {
margin-bottom: 0.5em;
margin-left: 0;
}
blockquote {
margin: 0 0 1em;
}
dfn {
font-style: italic;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
pre,
code,
kbd,
samp {
font-size: 1em;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
monospace;
}
pre {
margin-top: 0;
margin-bottom: 1em;
overflow: auto;
}
figure {
margin: 0 0 1em;
}
img {
vertical-align: middle;
border-style: none;
}
a,
area,
button,
[role="button"],
input:not([type="range"]),
label,
select,
summary,
textarea {
touch-action: manipulation;
}
table {
border-collapse: collapse;
}
caption {
padding-top: 0.75em;
padding-bottom: 0.3em;
text-align: left;
caption-side: bottom;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
color: inherit;
font-size: inherit;
font-family: inherit;
line-height: inherit;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
html [type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
padding: 0;
border-style: none;
}
input[type="radio"],
input[type="checkbox"] {
box-sizing: border-box;
padding: 0;
}
input[type="date"],
input[type="time"],
input[type="datetime-local"],
input[type="month"] {
-webkit-appearance: listbox;
}
textarea {
overflow: auto;
resize: vertical;
}
fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
legend {
display: block;
width: 100%;
max-width: 100%;
margin-bottom: 0.5em;
padding: 0;
color: inherit;
font-size: 1.5em;
line-height: inherit;
white-space: normal;
}
progress {
vertical-align: baseline;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
outline-offset: -2px;
-webkit-appearance: none;
}
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
summary {
display: list-item;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
mark {
padding: 0.2em;
background-color: #feffe6;
}

View file

@ -0,0 +1,7 @@
import { autoSaveIndicatorTests } from "@refinedev/ui-tests";
import { AutoSaveIndicator } from "./";
describe("AutoSaveIndicator", () => {
autoSaveIndicatorTests.bind(this)(AutoSaveIndicator);
});

View file

@ -0,0 +1,86 @@
import React from "react";
import {
AutoSaveIndicatorProps,
useTranslate,
AutoSaveIndicator as AutoSaveIndicatorCore,
} from "@refinedev/core";
import { Typography, theme } from "antd";
import {
EllipsisOutlined,
SyncOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
} from "@ant-design/icons";
export const AutoSaveIndicator: React.FC<AutoSaveIndicatorProps> = ({
status,
elements: {
success = (
<Message
key="autoSave.success"
defaultMessage="saved"
icon={<CheckCircleOutlined />}
/>
),
error = (
<Message
key="autoSave.error"
defaultMessage="auto save failure"
icon={<ExclamationCircleOutlined />}
/>
),
loading = (
<Message
key="autoSave.loading"
defaultMessage="saving..."
icon={<SyncOutlined />}
/>
),
idle = (
<Message
key="autoSave.idle"
defaultMessage="waiting for changes"
icon={<EllipsisOutlined />}
/>
),
} = {},
}) => {
return (
<AutoSaveIndicatorCore
status={status}
elements={{
success,
error,
loading,
idle,
}}
/>
);
};
const Message = ({
key,
defaultMessage,
icon,
}: {
key: string;
defaultMessage: string;
icon: React.ReactNode;
}) => {
const translate = useTranslate();
const { useToken } = theme;
const { token } = useToken();
return (
<Typography.Text
style={{
marginRight: 5,
color: token.colorTextTertiary,
fontSize: ".8rem",
}}
>
{translate(key, defaultMessage)}
<span style={{ marginLeft: ".2rem" }}>{icon}</span>
</Typography.Text>
);
};

View file

@ -0,0 +1,53 @@
import React, { ReactNode } from "react";
import { Route, Routes } from "react-router-dom";
import { breadcrumbTests } from "@refinedev/ui-tests";
import { render, TestWrapper, ITestWrapperProps, act } from "@test";
import { Breadcrumb } from "./";
const renderBreadcrumb = (
children: ReactNode,
wrapperProps: ITestWrapperProps = {},
) => {
return render(
<Routes>
<Route path="/:resource/:action" element={children} />
</Routes>,
{
wrapper: TestWrapper(wrapperProps),
},
);
};
const DummyDashboard = () => <div>Dashboard</div>;
describe("Breadcrumb", () => {
beforeAll(() => {
jest.spyOn(console, "warn").mockImplementation(jest.fn());
});
breadcrumbTests.bind(this)(Breadcrumb);
it("should render home icon", async () => {
const { container } = renderBreadcrumb(<Breadcrumb />, {
resources: [{ name: "posts" }],
routerInitialEntries: ["/posts/create"],
DashboardPage: DummyDashboard,
});
expect(container.querySelector("svg")).toBeTruthy();
});
it("should not render home icon with 'showhHome' props", async () => {
const { container } = renderBreadcrumb(
<Breadcrumb showHome={false} />,
{
resources: [{ name: "posts" }],
routerInitialEntries: ["/posts/create"],
DashboardPage: DummyDashboard,
},
);
expect(container.querySelector("svg")).toBeFalsy();
});
});

View file

@ -0,0 +1,87 @@
import React from "react";
import {
useBreadcrumb,
useLink,
useRefineContext,
useRouterContext,
useRouterType,
useResource,
matchResourceFromRoute,
} from "@refinedev/core";
import { RefineBreadcrumbProps } from "@refinedev/ui-types";
import {
Breadcrumb as AntdBreadcrumb,
BreadcrumbProps as AntdBreadcrumbProps,
} from "antd";
import { HomeOutlined } from "@ant-design/icons";
export type BreadcrumbProps = RefineBreadcrumbProps<AntdBreadcrumbProps>;
export const Breadcrumb: React.FC<BreadcrumbProps> = ({
breadcrumbProps,
showHome = true,
hideIcons = false,
meta,
}) => {
const routerType = useRouterType();
const { breadcrumbs } = useBreadcrumb({
meta,
});
const Link = useLink();
const { Link: LegacyLink } = useRouterContext();
const { hasDashboard } = useRefineContext();
const { resources } = useResource();
const rootRouteResource = matchResourceFromRoute("/", resources);
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
if (breadcrumbs.length === 1) {
return null;
}
const breadCrumbItems = breadcrumbs.map(({ label, icon, href }) => ({
key: `breadcrumb-item-${label}`,
title: (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 4,
}}
>
{!hideIcons && icon}
{href ? (
<ActiveLink to={href}>{label}</ActiveLink>
) : (
<span>{label}</span>
)}
</div>
),
}));
const getBreadcrumbItems = () => {
if (showHome && (hasDashboard || rootRouteResource.found)) {
return [
{
key: "breadcrumb-item-home",
title: (
<ActiveLink to="/">
{rootRouteResource?.resource?.meta?.icon ?? (
<HomeOutlined />
)}
</ActiveLink>
),
},
...breadCrumbItems,
];
}
return breadCrumbItems;
};
return <AntdBreadcrumb items={getBreadcrumbItems()} {...breadcrumbProps} />;
};

View file

@ -0,0 +1,7 @@
import { buttonCloneTests } from "@refinedev/ui-tests";
import { CloneButton } from "./";
describe("Clone Button", () => {
buttonCloneTests.bind(this)(CloneButton);
});

View file

@ -0,0 +1,117 @@
import React, { useContext } from "react";
import { Button } from "antd";
import { PlusSquareOutlined } from "@ant-design/icons";
import {
useCan,
useNavigation,
useTranslate,
useResource,
useRouterContext,
useRouterType,
useLink,
AccessControlContext,
} from "@refinedev/core";
import {
RefineButtonTestIds,
RefineButtonClassNames,
} from "@refinedev/ui-types";
import { CloneButtonProps } from "../types";
/**
* `<CloneButton>` uses Ant Design's {@link https://ant.design/components/button/ `<Button> component`}.
* It uses the {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation#clone `clone`} method from {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation useNavigation} under the hood.
* It can be useful when redirecting the app to the create page with the record id route of resource.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/buttons/clone-button} for more details.
*/
export const CloneButton: React.FC<CloneButtonProps> = ({
resourceNameOrRouteName: propResourceNameOrRouteName,
resource: resourceNameFromProps,
recordItemId,
hideText = false,
accessControl,
meta,
children,
onClick,
...rest
}) => {
const accessControlContext = useContext(AccessControlContext);
const accessControlEnabled =
accessControl?.enabled ??
accessControlContext.options.buttons.enableAccessControl;
const hideIfUnauthorized =
accessControl?.hideIfUnauthorized ??
accessControlContext.options.buttons.hideIfUnauthorized;
const { cloneUrl: generateCloneUrl } = useNavigation();
const routerType = useRouterType();
const Link = useLink();
const { Link: LegacyLink } = useRouterContext();
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
const translate = useTranslate();
const { id, resource } = useResource(
resourceNameFromProps ?? propResourceNameOrRouteName,
);
const { data } = useCan({
resource: resource?.name,
action: "create",
params: { id: recordItemId ?? id, resource },
queryOptions: {
enabled: accessControlEnabled,
},
});
const createButtonDisabledTitle = () => {
if (data?.can) return "";
else if (data?.reason) return data.reason;
else
return translate(
"buttons.notAccessTitle",
"You don't have permission to access",
);
};
const cloneUrl =
resource && (recordItemId || id)
? generateCloneUrl(resource, recordItemId! ?? id!, meta)
: "";
if (accessControlEnabled && hideIfUnauthorized && !data?.can) {
return null;
}
return (
<ActiveLink
to={cloneUrl}
replace={false}
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
if (data?.can === false) {
e.preventDefault();
return;
}
if (onClick) {
e.preventDefault();
onClick(e);
}
}}
>
<Button
icon={<PlusSquareOutlined />}
disabled={data?.can === false}
title={createButtonDisabledTitle()}
data-testid={RefineButtonTestIds.CloneButton}
className={RefineButtonClassNames.CloneButton}
{...rest}
>
{!hideText && (children ?? translate("buttons.clone", "Clone"))}
</Button>
</ActiveLink>
);
};

View file

@ -0,0 +1,7 @@
import { buttonCreateTests } from "@refinedev/ui-tests";
import { CreateButton } from "./";
describe("Create Button", () => {
buttonCreateTests.bind(this)(CreateButton);
});

View file

@ -0,0 +1,117 @@
import React, { useContext } from "react";
import { Button } from "antd";
import { PlusSquareOutlined } from "@ant-design/icons";
import {
useNavigation,
useTranslate,
useCan,
useResource,
useRouterContext,
useRouterType,
useLink,
AccessControlContext,
} from "@refinedev/core";
import {
RefineButtonClassNames,
RefineButtonTestIds,
} from "@refinedev/ui-types";
import { CreateButtonProps } from "../types";
/**
* <CreateButton> uses Ant Design's {@link https://ant.design/components/button/ `<Button> component`}.
* It uses the {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation#create `create`} method from {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation `useNavigation`} under the hood.
* It can be useful to redirect the app to the create page route of resource}.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/buttons/create-button} for more details.
*/
export const CreateButton: React.FC<CreateButtonProps> = ({
resource: resourceNameFromProps,
resourceNameOrRouteName: propResourceNameOrRouteName,
hideText = false,
accessControl,
meta,
children,
onClick,
...rest
}) => {
const accessControlContext = useContext(AccessControlContext);
const accessControlEnabled =
accessControl?.enabled ??
accessControlContext.options.buttons.enableAccessControl;
const hideIfUnauthorized =
accessControl?.hideIfUnauthorized ??
accessControlContext.options.buttons.hideIfUnauthorized;
const translate = useTranslate();
const routerType = useRouterType();
const Link = useLink();
const { Link: LegacyLink } = useRouterContext();
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
const { createUrl: generateCreateUrl } = useNavigation();
const { resource } = useResource(
resourceNameFromProps ?? propResourceNameOrRouteName,
);
const { data } = useCan({
resource: resource?.name,
action: "create",
queryOptions: {
enabled: accessControlEnabled,
},
params: {
resource,
},
});
const createButtonDisabledTitle = () => {
if (data?.can) return "";
else if (data?.reason) return data.reason;
else
return translate(
"buttons.notAccessTitle",
"You don't have permission to access",
);
};
const createUrl = resource ? generateCreateUrl(resource, meta) : "";
if (accessControlEnabled && hideIfUnauthorized && !data?.can) {
return null;
}
return (
<ActiveLink
to={createUrl}
replace={false}
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
if (data?.can === false) {
e.preventDefault();
return;
}
if (onClick) {
e.preventDefault();
onClick(e);
}
}}
>
<Button
icon={<PlusSquareOutlined />}
disabled={data?.can === false}
title={createButtonDisabledTitle()}
data-testid={RefineButtonTestIds.CreateButton}
className={RefineButtonClassNames.CreateButton}
type="primary"
{...rest}
>
{!hideText &&
(children ?? translate("buttons.create", "Create"))}
</Button>
</ActiveLink>
);
};

View file

@ -0,0 +1,6 @@
import { buttonDeleteTests } from "@refinedev/ui-tests";
import { DeleteButton } from "./";
describe("Delete Button", () => {
buttonDeleteTests.bind(this)(DeleteButton);
});

View file

@ -0,0 +1,150 @@
import React, { useContext } from "react";
import { Button, Popconfirm } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import {
useDelete,
useTranslate,
useMutationMode,
useCan,
useResource,
pickNotDeprecated,
useWarnAboutChange,
AccessControlContext,
} from "@refinedev/core";
import {
RefineButtonClassNames,
RefineButtonTestIds,
} from "@refinedev/ui-types";
import { DeleteButtonProps } from "../types";
/**
* `<DeleteButton>` uses Ant Design's {@link https://ant.design/components/button/ `<Button>`} and {@link https://ant.design/components/button/ `<Popconfirm>`} components.
* When you try to delete something, a pop-up shows up and asks for confirmation. When confirmed it executes the `useDelete` method provided by your `dataProvider`.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/buttons/delete-button} for more details.
*/
export const DeleteButton: React.FC<DeleteButtonProps> = ({
resource: resourceNameFromProps,
resourceNameOrRouteName: propResourceNameOrRouteName,
recordItemId,
onSuccess,
mutationMode: mutationModeProp,
children,
successNotification,
errorNotification,
hideText = false,
accessControl,
metaData,
meta,
dataProviderName,
confirmTitle,
confirmOkText,
confirmCancelText,
invalidates,
...rest
}) => {
const accessControlContext = useContext(AccessControlContext);
const accessControlEnabled =
accessControl?.enabled ??
accessControlContext.options.buttons.enableAccessControl;
const hideIfUnauthorized =
accessControl?.hideIfUnauthorized ??
accessControlContext.options.buttons.hideIfUnauthorized;
const translate = useTranslate();
const { id, resource, identifier } = useResource(
resourceNameFromProps ?? propResourceNameOrRouteName,
);
const { mutationMode: mutationModeContext } = useMutationMode();
const mutationMode = mutationModeProp ?? mutationModeContext;
const { mutate, isLoading, variables } = useDelete();
const { data } = useCan({
resource: resource?.name,
action: "delete",
params: { id: recordItemId ?? id, resource },
queryOptions: {
enabled: accessControlEnabled,
},
});
const disabledTitle = () => {
if (data?.can) return "";
else if (data?.reason) return data.reason;
else
return translate(
"buttons.notAccessTitle",
"You don't have permission to access",
);
};
const { setWarnWhen } = useWarnAboutChange();
if (accessControlEnabled && hideIfUnauthorized && !data?.can) {
return null;
}
return (
<Popconfirm
key="delete"
okText={confirmOkText ?? translate("buttons.delete", "Delete")}
cancelText={
confirmCancelText ?? translate("buttons.cancel", "Cancel")
}
okType="danger"
title={
confirmTitle ?? translate("buttons.confirm", "Are you sure?")
}
okButtonProps={{ disabled: isLoading }}
onConfirm={(): void => {
if ((recordItemId ?? id) && identifier) {
setWarnWhen(false);
mutate(
{
id: recordItemId ?? id ?? "",
resource: identifier,
mutationMode,
successNotification,
errorNotification,
meta: pickNotDeprecated(meta, metaData),
metaData: pickNotDeprecated(meta, metaData),
dataProviderName,
invalidates,
},
{
onSuccess: (value) => {
onSuccess && onSuccess(value);
},
},
);
}
}}
disabled={
typeof rest?.disabled !== "undefined"
? rest.disabled
: data?.can === false
}
>
<Button
danger
loading={(recordItemId ?? id) === variables?.id && isLoading}
icon={<DeleteOutlined />}
title={disabledTitle()}
disabled={data?.can === false}
data-testid={RefineButtonTestIds.DeleteButton}
className={RefineButtonClassNames.DeleteButton}
{...rest}
>
{!hideText &&
(children ?? translate("buttons.delete", "Delete"))}
</Button>
</Popconfirm>
);
};

View file

@ -0,0 +1,6 @@
import { buttonEditTests } from "@refinedev/ui-tests";
import { EditButton } from "./";
describe("Edit Button", () => {
buttonEditTests.bind(this)(EditButton);
});

View file

@ -0,0 +1,118 @@
import React, { useContext } from "react";
import { Button } from "antd";
import { EditOutlined } from "@ant-design/icons";
import {
useCan,
useNavigation,
useTranslate,
useResource,
useRouterContext,
useRouterType,
useLink,
AccessControlContext,
} from "@refinedev/core";
import {
RefineButtonClassNames,
RefineButtonTestIds,
} from "@refinedev/ui-types";
import { EditButtonProps } from "../types";
/**
* `<EditButton>` uses Ant Design's {@link https://ant.design/components/button/ `<Button>`} component.
* It uses the {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation#edit `edit`} method from {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation `useNavigation`} under the hood.
* It can be useful when redirecting the app to the edit page with the record id route of resource}.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/buttons/edit-button} for more details.
*/
export const EditButton: React.FC<EditButtonProps> = ({
resource: resourceNameFromProps,
resourceNameOrRouteName: propResourceNameOrRouteName,
recordItemId,
hideText = false,
accessControl,
meta,
children,
onClick,
...rest
}) => {
const accessControlContext = useContext(AccessControlContext);
const accessControlEnabled =
accessControl?.enabled ??
accessControlContext.options.buttons.enableAccessControl;
const hideIfUnauthorized =
accessControl?.hideIfUnauthorized ??
accessControlContext.options.buttons.hideIfUnauthorized;
const translate = useTranslate();
const routerType = useRouterType();
const Link = useLink();
const { Link: LegacyLink } = useRouterContext();
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
const { editUrl: generateEditUrl } = useNavigation();
const { id, resource } = useResource(
resourceNameFromProps ?? propResourceNameOrRouteName,
);
const { data } = useCan({
resource: resource?.name,
action: "edit",
params: { id: recordItemId ?? id, resource },
queryOptions: {
enabled: accessControlEnabled,
},
});
const createButtonDisabledTitle = () => {
if (data?.can) return "";
else if (data?.reason) return data.reason;
else
return translate(
"buttons.notAccessTitle",
"You don't have permission to access",
);
};
const editUrl =
resource && (recordItemId ?? id)
? generateEditUrl(resource, recordItemId! ?? id!, meta)
: "";
if (accessControlEnabled && hideIfUnauthorized && !data?.can) {
return null;
}
return (
<ActiveLink
to={editUrl}
replace={false}
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
if (data?.can === false) {
e.preventDefault();
return;
}
if (onClick) {
e.preventDefault();
onClick(e);
}
}}
>
<Button
icon={<EditOutlined />}
disabled={data?.can === false}
title={createButtonDisabledTitle()}
data-testid={RefineButtonTestIds.EditButton}
className={RefineButtonClassNames.EditButton}
{...rest}
>
{!hideText && (children ?? translate("buttons.edit", "Edit"))}
</Button>
</ActiveLink>
);
};

View file

@ -0,0 +1,6 @@
import { buttonExportTests } from "@refinedev/ui-tests";
import { ExportButton } from "./index";
describe("<ExportButton/>", () => {
buttonExportTests.bind(this)(ExportButton);
});

View file

@ -0,0 +1,36 @@
import React from "react";
import { Button } from "antd";
import { ExportOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import {
RefineButtonClassNames,
RefineButtonTestIds,
} from "@refinedev/ui-types";
import { ExportButtonProps } from "../types";
/**
* `<ExportButton>` is an Ant Design {@link https://ant.design/components/button/ `<Button>`} with a default export icon and a default text with "Export".
* It only has presentational value.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/buttons/export-button} for more details.
*/
export const ExportButton: React.FC<ExportButtonProps> = ({
hideText = false,
children,
...rest
}) => {
const translate = useTranslate();
return (
<Button
type="default"
icon={<ExportOutlined />}
data-testid={RefineButtonTestIds.ExportButton}
className={RefineButtonClassNames.ExportButton}
{...rest}
>
{!hideText && (children ?? translate("buttons.export", "Export"))}
</Button>
);
};

View file

@ -0,0 +1,7 @@
import { buttonImportTests } from "@refinedev/ui-tests";
import { ImportButton } from "./index";
describe("<ImportButton /> usage with useImport", () => {
buttonImportTests.bind(this)(ImportButton);
});

View file

@ -0,0 +1,40 @@
import React from "react";
import { Button, Upload } from "antd";
import { ImportOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import {
RefineButtonClassNames,
RefineButtonTestIds,
} from "@refinedev/ui-types";
import { ImportButtonProps } from "../types";
/**
* `<ImportButton>` is compatible with the {@link https://refine.dev/docs/api-reference/antd/hooks/import/useImport `useImport`} hook and is meant to be used as it's upload button.
* It uses Ant Design's {@link https://ant.design/components/button/ `<Button>`} and {@link https://ant.design/components/upload/ `<Upload>`} components.
* It wraps a `<Button>` component with an `<Upload>` component and accepts properties for `<Button>` and `<Upload>` components separately.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/buttons/import-button} for more details.
*/
export const ImportButton: React.FC<ImportButtonProps> = ({
uploadProps,
buttonProps,
hideText = false,
children,
}) => {
const translate = useTranslate();
return (
<Upload {...uploadProps}>
<Button
icon={<ImportOutlined />}
data-testid={RefineButtonTestIds.ImportButton}
className={RefineButtonClassNames.ImportButton}
{...buttonProps}
>
{!hideText &&
(children ?? translate("buttons.import", "Import"))}
</Button>
</Upload>
);
};

View file

@ -0,0 +1,11 @@
export { CreateButton } from "./create";
export { EditButton } from "./edit";
export { DeleteButton } from "./delete";
export { RefreshButton } from "./refresh";
export { ShowButton } from "./show";
export { ListButton } from "./list";
export { ExportButton } from "./export";
export { SaveButton } from "./save";
export { CloneButton } from "./clone";
export { ImportButton } from "./import";
export * from "./types";

View file

@ -0,0 +1,6 @@
import { buttonListTests } from "@refinedev/ui-tests";
import { ListButton } from "./";
describe("List Button", () => {
buttonListTests.bind(this)(ListButton);
});

View file

@ -0,0 +1,136 @@
import React, { useContext } from "react";
import { Button } from "antd";
import { BarsOutlined } from "@ant-design/icons";
import {
useCan,
useNavigation,
useTranslate,
useUserFriendlyName,
useResource,
useRouterContext,
useRouterType,
useLink,
pickNotDeprecated,
AccessControlContext,
} from "@refinedev/core";
import {
RefineButtonClassNames,
RefineButtonTestIds,
} from "@refinedev/ui-types";
import { ListButtonProps } from "../types";
/**
* `<ListButton>` is using Ant Design's {@link https://ant.design/components/button/ `<Button>`} component.
* It uses the {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation#list `list`} method from {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation `useNavigation`} under the hood.
* It can be useful when redirecting the app to the list page route of resource}.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/buttons/list-button} for more details.
*/
export const ListButton: React.FC<ListButtonProps> = ({
resource: resourceNameFromProps,
resourceNameOrRouteName: propResourceNameOrRouteName,
hideText = false,
accessControl,
meta,
children,
onClick,
...rest
}) => {
const accessControlContext = useContext(AccessControlContext);
const accessControlEnabled =
accessControl?.enabled ??
accessControlContext.options.buttons.enableAccessControl;
const hideIfUnauthorized =
accessControl?.hideIfUnauthorized ??
accessControlContext.options.buttons.hideIfUnauthorized;
const { listUrl: generateListUrl } = useNavigation();
const routerType = useRouterType();
const Link = useLink();
const { Link: LegacyLink } = useRouterContext();
const getUserFriendlyName = useUserFriendlyName();
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
const translate = useTranslate();
const { resource, identifier } = useResource(
resourceNameFromProps ?? propResourceNameOrRouteName,
);
const { data } = useCan({
resource: resource?.name,
action: "list",
queryOptions: {
enabled: accessControlEnabled,
},
params: {
resource,
},
});
const createButtonDisabledTitle = () => {
if (data?.can) return "";
else if (data?.reason) return data.reason;
else
return translate(
"buttons.notAccessTitle",
"You don't have permission to access",
);
};
const listUrl = resource ? generateListUrl(resource, meta) : "";
if (accessControlEnabled && hideIfUnauthorized && !data?.can) {
return null;
}
return (
<ActiveLink
to={listUrl}
replace={false}
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
if (data?.can === false) {
e.preventDefault();
return;
}
if (onClick) {
e.preventDefault();
onClick(e);
}
}}
>
<Button
icon={<BarsOutlined />}
disabled={data?.can === false}
title={createButtonDisabledTitle()}
data-testid={RefineButtonTestIds.ListButton}
className={RefineButtonClassNames.ListButton}
{...rest}
>
{!hideText &&
(children ??
translate(
`${
identifier ??
resourceNameFromProps ??
propResourceNameOrRouteName
}.titles.list`,
getUserFriendlyName(
resource?.meta?.label ??
resource?.label ??
identifier ??
pickNotDeprecated(
resourceNameFromProps,
propResourceNameOrRouteName,
),
"plural",
),
))}
</Button>
</ActiveLink>
);
};

View file

@ -0,0 +1,6 @@
import { buttonRefreshTests } from "@refinedev/ui-tests";
import { RefreshButton } from "./";
describe("Refresh Button", () => {
buttonRefreshTests.bind(this)(RefreshButton);
});

View file

@ -0,0 +1,76 @@
import React from "react";
import { Button } from "antd";
import { RedoOutlined } from "@ant-design/icons";
import {
useTranslate,
useResource,
useInvalidate,
queryKeys,
pickDataProvider,
} from "@refinedev/core";
import {
RefineButtonClassNames,
RefineButtonTestIds,
} from "@refinedev/ui-types";
import { RefreshButtonProps } from "../types";
import { useQueryClient } from "@tanstack/react-query";
/**
* `<RefreshButton>` uses Ant Design's {@link https://ant.design/components/button/ `<Button>`} component
* to update the data shown on the page via the {@link https://refine.dev/docs/api-reference/core/hooks/invalidate/useInvalidate `useInvalidate`} hook.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/buttons/refresh-button} for more details.
*/
export const RefreshButton: React.FC<RefreshButtonProps> = ({
resource: resourceNameFromProps,
resourceNameOrRouteName: propResourceNameOrRouteName,
recordItemId,
hideText = false,
dataProviderName,
children,
onClick,
meta: _meta,
metaData: _metaData,
...rest
}) => {
const translate = useTranslate();
const queryClient = useQueryClient();
const invalidates = useInvalidate();
const { resources, identifier, id } = useResource(
resourceNameFromProps ?? propResourceNameOrRouteName,
);
const isInvalidating = !!queryClient.isFetching({
queryKey: queryKeys(
identifier,
pickDataProvider(identifier, dataProviderName, resources),
).detail(recordItemId ?? id),
});
const handleInvalidate = () => {
invalidates({
id: recordItemId ?? id,
invalidates: ["detail"],
dataProviderName,
resource: identifier,
});
};
return (
<Button
onClick={(e) => {
onClick ? onClick(e) : handleInvalidate();
}}
icon={<RedoOutlined spin={isInvalidating} />}
data-testid={RefineButtonTestIds.RefreshButton}
className={RefineButtonClassNames.RefreshButton}
{...rest}
>
{!hideText && (children ?? translate("buttons.refresh", "Refresh"))}
</Button>
);
};

View file

@ -0,0 +1,6 @@
import { buttonSaveTests } from "@refinedev/ui-tests";
import { SaveButton } from "./";
describe("Save Button", () => {
buttonSaveTests.bind(this)(SaveButton);
});

View file

@ -0,0 +1,36 @@
import React from "react";
import { Button } from "antd";
import { SaveOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import {
RefineButtonClassNames,
RefineButtonTestIds,
} from "@refinedev/ui-types";
import { SaveButtonProps } from "../types";
/**
* `<SaveButton>` uses Ant Design's {@link https://ant.design/components/button/ `<Button>`} component.
* It uses it for presantation purposes only. Some of the hooks that refine has adds features to this button.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/buttons/save-button} for more details.
*/
export const SaveButton: React.FC<SaveButtonProps> = ({
hideText = false,
children,
...rest
}) => {
const translate = useTranslate();
return (
<Button
type="primary"
icon={<SaveOutlined />}
data-testid={RefineButtonTestIds.SaveButton}
className={RefineButtonClassNames.SaveButton}
{...rest}
>
{!hideText && (children ?? translate("buttons.save", "Save"))}
</Button>
);
};

View file

@ -0,0 +1,6 @@
import { buttonShowTests } from "@refinedev/ui-tests";
import { ShowButton } from "./";
describe("Show Button", () => {
buttonShowTests.bind(this)(ShowButton);
});

View file

@ -0,0 +1,117 @@
import React, { useContext } from "react";
import { Button } from "antd";
import { EyeOutlined } from "@ant-design/icons";
import {
useCan,
useNavigation,
useTranslate,
useResource,
useRouterContext,
useRouterType,
useLink,
AccessControlContext,
} from "@refinedev/core";
import {
RefineButtonClassNames,
RefineButtonTestIds,
} from "@refinedev/ui-types";
import { ShowButtonProps } from "../types";
/**
* `<ShowButton>` uses Ant Design's {@link https://ant.design/components/button/ `<Button>`} component.
* It uses the {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation#show `show`} method from {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation `useNavigation`} under the hood.
* It can be useful when redirecting the app to the show page with the record id route of resource.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/buttons/show-button} for more details.
*/
export const ShowButton: React.FC<ShowButtonProps> = ({
resource: resourceNameFromProps,
resourceNameOrRouteName: propResourceNameOrRouteName,
recordItemId,
hideText = false,
accessControl,
meta,
children,
onClick,
...rest
}) => {
const accessControlContext = useContext(AccessControlContext);
const accessControlEnabled =
accessControl?.enabled ??
accessControlContext.options.buttons.enableAccessControl;
const hideIfUnauthorized =
accessControl?.hideIfUnauthorized ??
accessControlContext.options.buttons.hideIfUnauthorized;
const { showUrl: generateShowUrl } = useNavigation();
const routerType = useRouterType();
const Link = useLink();
const { Link: LegacyLink } = useRouterContext();
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
const translate = useTranslate();
const { id, resource } = useResource(
resourceNameFromProps ?? propResourceNameOrRouteName,
);
const { data } = useCan({
resource: resource?.name,
action: "show",
params: { id: recordItemId ?? id, resource },
queryOptions: {
enabled: accessControlEnabled,
},
});
const createButtonDisabledTitle = () => {
if (data?.can) return "";
else if (data?.reason) return data.reason;
else
return translate(
"buttons.notAccessTitle",
"You don't have permission to access",
);
};
const showUrl =
resource && (recordItemId || id)
? generateShowUrl(resource, recordItemId! ?? id!, meta)
: "";
if (accessControlEnabled && hideIfUnauthorized && !data?.can) {
return null;
}
return (
<ActiveLink
to={showUrl}
replace={false}
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
if (data?.can === false) {
e.preventDefault();
return;
}
if (onClick) {
e.preventDefault();
onClick(e);
}
}}
>
<Button
icon={<EyeOutlined />}
disabled={data?.can === false}
title={createButtonDisabledTitle()}
data-testid={RefineButtonTestIds.ShowButton}
className={RefineButtonClassNames.ShowButton}
{...rest}
>
{!hideText && (children ?? translate("buttons.show", "Show"))}
</Button>
</ActiveLink>
);
};

View file

@ -0,0 +1,44 @@
import { ButtonProps, UploadProps } from "antd";
import {
RefineCloneButtonProps,
RefineCreateButtonProps,
RefineDeleteButtonProps,
RefineEditButtonProps,
RefineExportButtonProps,
RefineImportButtonProps,
RefineListButtonProps,
RefineRefreshButtonProps,
RefineSaveButtonProps,
RefineShowButtonProps,
} from "@refinedev/ui-types";
export type ShowButtonProps = RefineShowButtonProps<ButtonProps>;
export type CloneButtonProps = RefineCloneButtonProps<ButtonProps>;
export type CreateButtonProps = RefineCreateButtonProps<ButtonProps>;
export type DeleteButtonProps = RefineDeleteButtonProps<ButtonProps>;
export type EditButtonProps = RefineEditButtonProps<ButtonProps>;
export type ExportButtonProps = RefineExportButtonProps<ButtonProps>;
export type ImportButtonProps = RefineImportButtonProps & {
/**
* Sets the button type
* @type [UploadProps](https://ant.design/components/upload/#API)
*/
uploadProps: UploadProps;
/**
* Sets props of the button
* @type [ButtonProps](https://ant.design/components/button/#API)
*/
buttonProps: ButtonProps;
};
export type ListButtonProps = RefineListButtonProps<ButtonProps>;
export type RefreshButtonProps = RefineRefreshButtonProps<ButtonProps>;
export type SaveButtonProps = RefineSaveButtonProps<ButtonProps>;

View file

@ -0,0 +1,45 @@
import React, { ReactNode } from "react";
import { Route, Routes } from "react-router-dom";
import { crudCreateTests } from "@refinedev/ui-tests";
import { render, TestWrapper } from "@test";
import { Create } from "./";
import { SaveButton } from "@components/buttons";
import { RefineButtonTestIds } from "@refinedev/ui-types";
const renderCreate = (create: ReactNode) => {
return render(
<Routes>
<Route path="/:resource/create" element={create} />
</Routes>,
{
wrapper: TestWrapper({
routerInitialEntries: ["/posts/create"],
}),
},
);
};
describe("Create", () => {
crudCreateTests.bind(this)(Create);
it("should customize default buttons with default props", async () => {
const { queryByTestId } = renderCreate(
<Create
saveButtonProps={{ className: "customize-test" }}
footerButtons={({ saveButtonProps }) => {
expect(saveButtonProps).toBeDefined();
return (
<>
<SaveButton {...saveButtonProps} />
</>
);
}}
/>,
);
expect(queryByTestId(RefineButtonTestIds.SaveButton)).toHaveClass(
"customize-test",
);
});
});

View file

@ -0,0 +1,144 @@
import React from "react";
import { Card, Space, Spin } from "antd";
import {
useNavigation,
useTranslate,
useUserFriendlyName,
useRefineContext,
useRouterType,
useResource,
useBack,
} from "@refinedev/core";
import {
Breadcrumb,
SaveButton,
PageHeader,
SaveButtonProps,
} from "@components";
import { CreateProps } from "../types";
/**
* `<Create>` provides us a layout to display the page.
* It does not contain any logic but adds extra functionalities like action buttons and giving titles to the page.
*
* @see {@link https://refine.dev/docs/ui-frameworks/antd/components/basic-views/create} for more details.
*/
export const Create: React.FC<CreateProps> = ({
title,
saveButtonProps: saveButtonPropsFromProps,
children,
resource: resourceFromProps,
isLoading = false,
breadcrumb: breadcrumbFromProps,
wrapperProps,
headerProps,
contentProps,
headerButtonProps,
headerButtons,
footerButtonProps,
footerButtons,
goBack: goBackFromProps,
}) => {
const translate = useTranslate();
const { options: { breadcrumb: globalBreadcrumb } = {} } =
useRefineContext();
const routerType = useRouterType();
const back = useBack();
const { goBack } = useNavigation();
const getUserFriendlyName = useUserFriendlyName();
const { resource, action, identifier } = useResource(resourceFromProps);
const breadcrumb =
typeof breadcrumbFromProps === "undefined"
? globalBreadcrumb
: breadcrumbFromProps;
const saveButtonProps: SaveButtonProps = {
...(isLoading ? { disabled: true } : {}),
...saveButtonPropsFromProps,
htmlType: "submit",
};
const defaultFooterButtons = (
<>
<SaveButton {...saveButtonProps} />
</>
);
return (
<div {...(wrapperProps ?? {})}>
<PageHeader
ghost={false}
backIcon={goBackFromProps}
onBack={
action !== "list" || typeof action !== "undefined"
? routerType === "legacy"
? goBack
: back
: undefined
}
title={
title ??
translate(
`${identifier}.titles.create`,
`Create ${getUserFriendlyName(
resource?.meta?.label ??
resource?.options?.label ??
resource?.label ??
identifier,
"singular",
)}`,
)
}
breadcrumb={
typeof breadcrumb !== "undefined" ? (
<>{breadcrumb}</> ?? undefined
) : (
<Breadcrumb />
)
}
extra={
<Space wrap {...(headerButtonProps ?? {})}>
{headerButtons
? typeof headerButtons === "function"
? headerButtons({
defaultButtons: null,
})
: headerButtons
: null}
</Space>
}
{...(headerProps ?? {})}
>
<Spin spinning={isLoading}>
<Card
bordered={false}
actions={[
<Space
key="action-buttons"
style={{ float: "right", marginRight: 24 }}
{...(footerButtonProps ?? {})}
>
{footerButtons
? typeof footerButtons === "function"
? footerButtons({
defaultButtons:
defaultFooterButtons,
saveButtonProps: saveButtonProps,
})
: footerButtons
: defaultFooterButtons}
</Space>,
]}
{...(contentProps ?? {})}
>
{children}
</Card>
</Spin>
</PageHeader>
</div>
);
};

View file

@ -0,0 +1,521 @@
import React, { ReactNode } from "react";
import { Route, Routes } from "react-router-dom";
import { AccessControlProvider } from "@refinedev/core";
import { Form, Input } from "antd";
import {
act,
fireEvent,
ITestWrapperProps,
render,
TestWrapper,
waitFor,
MockJSONServer,
} from "@test";
import { Edit } from "./";
import { crudEditTests } from "@refinedev/ui-tests";
import { RefineButtonTestIds } from "@refinedev/ui-types";
import {
DeleteButton,
ListButton,
RefreshButton,
SaveButton,
} from "@components/buttons";
import { useForm } from "@hooks/form";
const renderEdit = (
edit: ReactNode,
accessControlProvider?: AccessControlProvider,
wrapperOptions?: ITestWrapperProps,
) => {
return render(
<Routes>
<Route path="/:resource/edit/:id" element={edit} />
</Routes>,
{
wrapper: TestWrapper({
routerInitialEntries: ["/posts/edit/1"],
accessControlProvider,
...wrapperOptions,
}),
},
);
};
describe("Edit", () => {
crudEditTests.bind(this)(Edit);
it("should render optional mutationMode with mutationModeProp prop", async () => {
const container = renderEdit(<Edit mutationMode="undoable" />);
expect(container).toBeTruthy();
});
describe("render delete button", () => {
it("should render delete button ", async () => {
const { getByText, queryByTestId } = render(
<Routes>
<Route
path="/:resource/edit/:id"
element={
<Edit
footerButtons={({
defaultButtons,
deleteButtonProps,
}) => {
expect(deleteButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: true }],
routerInitialEntries: ["/posts/edit/1"],
}),
},
);
expect(
queryByTestId(RefineButtonTestIds.DeleteButton),
).not.toBeNull();
getByText("Edit Post");
});
it("should not render delete button on resource canDelete false", async () => {
const { getByText, queryByTestId } = render(
<Routes>
<Route
path="/:resource/edit/:id"
element={
<Edit
footerButtons={({
defaultButtons,
deleteButtonProps,
}) => {
expect(deleteButtonProps).toBeUndefined();
return <>{defaultButtons}</>;
}}
/>
}
></Route>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: false }],
routerInitialEntries: ["/posts/edit/1"],
}),
},
);
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeNull();
getByText("Edit Post");
});
it("should not render delete button on resource canDelete true & canDelete props false on component", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/edit/:id"
element={
<Edit
canDelete={false}
footerButtons={({
defaultButtons,
deleteButtonProps,
}) => {
expect(deleteButtonProps).toBeUndefined();
return <>{defaultButtons}</>;
}}
/>
}
></Route>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: true }],
routerInitialEntries: ["/posts/edit/1"],
}),
},
);
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeNull();
});
it("should render delete button on resource canDelete false & canDelete props true on component", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/edit/:id"
element={
<Edit
canDelete={true}
footerButtons={({
defaultButtons,
deleteButtonProps,
}) => {
expect(deleteButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
></Route>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: false }],
routerInitialEntries: ["/posts/edit/1"],
}),
},
);
expect(
queryByTestId(RefineButtonTestIds.DeleteButton),
).not.toBeNull();
});
it("should render delete button on resource canDelete false & deleteButtonProps props not null on component", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/edit/:id"
element={
<Edit
deleteButtonProps={{ size: "large" }}
footerButtons={({
defaultButtons,
deleteButtonProps,
}) => {
expect(deleteButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
></Route>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: false }],
routerInitialEntries: ["/posts/edit/1"],
}),
},
);
expect(
queryByTestId(RefineButtonTestIds.DeleteButton),
).not.toBeNull();
});
});
describe("accessibility of buttons by accessControlProvider", () => {
it("should render disabled list button and not disabled delete button", async () => {
const { queryByTestId } = renderEdit(
<Edit
canDelete
footerButtons={({ defaultButtons, deleteButtonProps }) => {
expect(deleteButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>,
{
can: ({ action }) => {
switch (action) {
case "list":
return Promise.resolve({ can: true });
case "delete":
default:
return Promise.resolve({ can: false });
}
},
},
);
await waitFor(() =>
expect(
queryByTestId(RefineButtonTestIds.ListButton),
).not.toBeDisabled(),
);
await waitFor(() =>
expect(
queryByTestId(RefineButtonTestIds.DeleteButton),
).toBeDisabled(),
);
});
it("should render disabled list button and delete button", async () => {
const { queryByTestId } = renderEdit(
<Edit
canDelete
headerButtons={({ defaultButtons, listButtonProps }) => {
expect(listButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
footerButtons={({ defaultButtons, deleteButtonProps }) => {
expect(deleteButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>,
{
can: ({ action }) => {
switch (action) {
case "list":
case "delete":
return Promise.resolve({ can: false });
default:
return Promise.resolve({ can: false });
}
},
},
);
await waitFor(() =>
expect(
queryByTestId(RefineButtonTestIds.ListButton),
).toBeDisabled(),
);
await waitFor(() =>
expect(
queryByTestId(RefineButtonTestIds.DeleteButton),
).toBeDisabled(),
);
});
it("should customize default buttons with default props", async () => {
const { queryByTestId } = renderEdit(
<Edit
canDelete
saveButtonProps={{ className: "customize-test" }}
headerButtons={({
listButtonProps,
refreshButtonProps,
}) => {
return (
<>
<RefreshButton {...refreshButtonProps} />
<ListButton {...listButtonProps} />
</>
);
}}
footerButtons={({ deleteButtonProps, saveButtonProps }) => {
return (
<>
<DeleteButton {...deleteButtonProps} />
<SaveButton {...saveButtonProps} />
</>
);
}}
/>,
{
can: ({ action }) => {
switch (action) {
case "list":
case "delete":
return Promise.resolve({ can: false });
default:
return Promise.resolve({ can: false });
}
},
},
);
await waitFor(() =>
expect(
queryByTestId(RefineButtonTestIds.DeleteButton),
).toBeDisabled(),
);
await waitFor(() =>
expect(
queryByTestId(RefineButtonTestIds.ListButton),
).toBeDisabled(),
);
expect(queryByTestId(RefineButtonTestIds.SaveButton)).toHaveClass(
"customize-test",
);
expect(
queryByTestId(RefineButtonTestIds.RefreshButton),
).toBeTruthy();
});
});
describe("list button", () => {
it("should render list button", async () => {
const { queryByTestId } = renderEdit(<Edit />);
await waitFor(() =>
expect(
queryByTestId(RefineButtonTestIds.ListButton),
).not.toBeNull(),
);
});
it("should not render list button when list resource is undefined", async () => {
const { queryByTestId } = renderEdit(
<Edit
headerButtons={({ defaultButtons, listButtonProps }) => {
expect(listButtonProps).toBeUndefined();
return <>{defaultButtons}</>;
}}
/>,
undefined,
{
resources: [{ name: "posts", list: undefined }],
},
);
await waitFor(() =>
expect(
queryByTestId(RefineButtonTestIds.ListButton),
).toBeNull(),
);
});
it("should not render list button when has recordItemId", async () => {
const { queryByTestId } = renderEdit(
<Edit
recordItemId="1"
headerButtons={({ defaultButtons, listButtonProps }) => {
expect(listButtonProps).toBeUndefined();
return <>{defaultButtons}</>;
}}
/>,
);
await waitFor(() =>
expect(
queryByTestId(RefineButtonTestIds.ListButton),
).toBeNull(),
);
});
});
describe("auto save", () => {
const EditPageWithAutoSave = () => {
const { formProps, formLoading, autoSaveProps } = useForm({
action: "edit",
autoSave: {
enabled: true,
},
});
return (
<Edit autoSaveProps={autoSaveProps}>
{formLoading && <div>loading...</div>}
<Form {...formProps} layout="vertical">
<Form.Item label="Title" name="title">
<Input data-testid="title" />
</Form.Item>
</Form>
</Edit>
);
};
it("check idle,loading,success statuses", async () => {
jest.useFakeTimers();
const { getByText, getByTestId } = render(
<Routes>
<Route
path="/:resource/edit/:id"
element={<EditPageWithAutoSave />}
></Route>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: false }],
routerInitialEntries: ["/posts/edit/1"],
dataProvider: {
...MockJSONServer,
update: () => {
return new Promise((res) => {
setTimeout(
() =>
res({
data: {
id: "1",
title: "ok",
} as any,
}),
1000,
);
});
},
},
}),
},
);
getByText("Edit Post");
getByText("waiting for changes");
// update title and wait
await act(async () => {
fireEvent.change(getByTestId("title"), {
target: { value: "test" },
});
jest.advanceTimersByTime(1100);
});
// check saving message
expect(getByText("saving...")).toBeTruthy();
await act(async () => {
jest.advanceTimersByTime(1000);
});
// check saved message
expect(getByText("saved")).toBeTruthy();
});
it("check error status", async () => {
jest.useFakeTimers();
const { getByText, getByTestId } = render(
<Routes>
<Route
path="/:resource/edit/:id"
element={<EditPageWithAutoSave />}
></Route>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: false }],
routerInitialEntries: ["/posts/edit/1"],
dataProvider: {
...MockJSONServer,
update: () => {
return new Promise((res, rej) => {
setTimeout(() => rej("error"), 1000);
});
},
},
}),
},
);
getByText("Edit Post");
getByText("waiting for changes");
// update title and wait
await act(async () => {
fireEvent.change(getByTestId("title"), {
target: { value: "test" },
});
jest.advanceTimersByTime(1100);
});
// check saving message
expect(getByText("saving...")).toBeTruthy();
await act(async () => {
jest.advanceTimersByTime(1000);
});
// check saved message
expect(getByText("auto save failure")).toBeTruthy();
});
});
});

View file

@ -0,0 +1,231 @@
import React from "react";
import { Card, Space, Spin } from "antd";
import {
useMutationMode,
useNavigation,
useTranslate,
useUserFriendlyName,
useRefineContext,
useRouterType,
useBack,
useResource,
useGo,
useToPath,
} from "@refinedev/core";
import {
DeleteButton,
RefreshButton,
ListButton,
SaveButton,
Breadcrumb,
PageHeader,
ListButtonProps,
RefreshButtonProps,
DeleteButtonProps,
SaveButtonProps,
AutoSaveIndicator,
} from "@components";
import { EditProps } from "../types";
/**
* `<Edit>` provides us a layout for displaying the page.
* It does not contain any logic but adds extra functionalities like a refresh button.
*
* @see {@link https://refine.dev/docs/ui-frameworks/antd/components/basic-views/edit} for more details.
*/
export const Edit: React.FC<EditProps> = ({
title,
saveButtonProps: saveButtonPropsFromProps,
mutationMode: mutationModeProp,
recordItemId,
children,
deleteButtonProps: deleteButtonPropsFromProps,
canDelete,
resource: resourceFromProps,
isLoading = false,
dataProviderName,
breadcrumb: breadcrumbFromProps,
wrapperProps,
headerProps,
contentProps,
headerButtonProps,
headerButtons,
footerButtonProps,
footerButtons,
goBack: goBackFromProps,
autoSaveProps,
}) => {
const translate = useTranslate();
const { options: { breadcrumb: globalBreadcrumb } = {} } =
useRefineContext();
const { mutationMode: mutationModeContext } = useMutationMode();
const mutationMode = mutationModeProp ?? mutationModeContext;
const routerType = useRouterType();
const back = useBack();
const go = useGo();
const { goBack, list: legacyGoList } = useNavigation();
const getUserFriendlyName = useUserFriendlyName();
const {
resource,
action,
id: idFromParams,
identifier,
} = useResource(resourceFromProps);
const goListPath = useToPath({
resource,
action: "list",
});
const id = recordItemId ?? idFromParams;
const breadcrumb =
typeof breadcrumbFromProps === "undefined"
? globalBreadcrumb
: breadcrumbFromProps;
const hasList = resource?.list && !recordItemId;
const isDeleteButtonVisible =
canDelete ??
((resource?.meta?.canDelete ?? resource?.canDelete) ||
deleteButtonPropsFromProps);
const listButtonProps: ListButtonProps | undefined = hasList
? {
...(isLoading ? { disabled: true } : {}),
resource: routerType === "legacy" ? resource?.route : identifier,
}
: undefined;
const refreshButtonProps: RefreshButtonProps = {
...(isLoading ? { disabled: true } : {}),
resource: routerType === "legacy" ? resource?.route : identifier,
recordItemId: id,
dataProviderName,
};
const deleteButtonProps: DeleteButtonProps | undefined =
isDeleteButtonVisible
? {
...(isLoading ? { disabled: true } : {}),
resource:
routerType === "legacy" ? resource?.route : identifier,
mutationMode,
onSuccess: () => {
if (routerType === "legacy") {
legacyGoList(resource?.route ?? resource?.name ?? "");
} else {
go({ to: goListPath });
}
},
recordItemId: id,
dataProviderName,
...deleteButtonPropsFromProps,
}
: undefined;
const saveButtonProps: SaveButtonProps = {
...(isLoading ? { disabled: true } : {}),
...saveButtonPropsFromProps,
};
const defaultHeaderButtons = (
<>
{autoSaveProps && <AutoSaveIndicator {...autoSaveProps} />}
{hasList && <ListButton {...listButtonProps} />}
<RefreshButton {...refreshButtonProps} />
</>
);
const defaultFooterButtons = (
<>
{isDeleteButtonVisible && <DeleteButton {...deleteButtonProps} />}
<SaveButton {...saveButtonProps} />
</>
);
return (
<div {...(wrapperProps ?? {})}>
<PageHeader
ghost={false}
backIcon={goBackFromProps}
onBack={
action !== "list" && typeof action !== "undefined"
? routerType === "legacy"
? goBack
: back
: undefined
}
title={
title ??
translate(
`${identifier}.titles.edit`,
`Edit ${getUserFriendlyName(
resource?.meta?.label ??
resource?.options?.label ??
resource?.label ??
identifier,
"singular",
)}`,
)
}
extra={
<Space wrap {...(headerButtonProps ?? {})}>
{headerButtons
? typeof headerButtons === "function"
? headerButtons({
defaultButtons: defaultHeaderButtons,
listButtonProps,
refreshButtonProps,
})
: headerButtons
: defaultHeaderButtons}
</Space>
}
breadcrumb={
typeof breadcrumb !== "undefined" ? (
<>{breadcrumb}</> ?? undefined
) : (
<Breadcrumb />
)
}
{...(headerProps ?? {})}
>
<Spin spinning={isLoading}>
<Card
bordered={false}
actions={[
<Space
key="footer-buttons"
wrap
style={{
float: "right",
marginRight: 24,
}}
{...(footerButtonProps ?? {})}
>
{footerButtons
? typeof footerButtons === "function"
? footerButtons({
defaultButtons:
defaultFooterButtons,
deleteButtonProps,
saveButtonProps,
})
: footerButtons
: defaultFooterButtons}
</Space>,
]}
{...(contentProps ?? {})}
>
{children}
</Card>
</Spin>
</PageHeader>
</div>
);
};

View file

@ -0,0 +1,6 @@
export { List } from "./list";
export { Create } from "./create";
export { Edit } from "./edit";
export { Show } from "./show";
export * from "./types";

View file

@ -0,0 +1,45 @@
import React, { ReactNode } from "react";
import { crudListTests } from "@refinedev/ui-tests";
import { RefineButtonTestIds } from "@refinedev/ui-types";
import { Route, Routes } from "react-router-dom";
import { CreateButton } from "@components/buttons";
import { render, TestWrapper } from "@test";
import { List } from "./index";
const renderList = (list: ReactNode) => {
return render(
<Routes>
<Route path="/:resource" element={list} />
</Routes>,
{
wrapper: TestWrapper({
routerInitialEntries: ["/posts"],
}),
},
);
};
describe("<List/>", () => {
crudListTests.bind(this)(List);
it("should customize default buttons with default props", async () => {
const { queryByTestId } = renderList(
<List
createButtonProps={{ className: "customize-test" }}
headerButtons={({ createButtonProps }) => {
expect(createButtonProps).toBeDefined();
return (
<>
<CreateButton {...createButtonProps} />
</>
);
}}
/>,
);
expect(queryByTestId(RefineButtonTestIds.CreateButton)).toHaveClass(
"customize-test",
);
});
});

View file

@ -0,0 +1,115 @@
import React from "react";
import { Space } from "antd";
import {
useTranslate,
useUserFriendlyName,
useRefineContext,
useRouterType,
useResource,
} from "@refinedev/core";
import {
Breadcrumb,
CreateButton,
CreateButtonProps,
PageHeader,
} from "@components";
import { ListProps } from "../types";
/**
* `<List>` provides us a layout for displaying the page.
* It does not contain any logic but adds extra functionalities like a refresh button.
*
* @see {@link https://refine.dev/docs/ui-frameworks/antd/components/basic-views/list} for more details.
*/
export const List: React.FC<ListProps> = ({
canCreate,
title,
children,
createButtonProps: createButtonPropsFromProps,
resource: resourceFromProps,
wrapperProps,
contentProps,
headerProps,
breadcrumb: breadcrumbFromProps,
headerButtonProps,
headerButtons,
}) => {
const translate = useTranslate();
const { options: { breadcrumb: globalBreadcrumb } = {} } =
useRefineContext();
const routerType = useRouterType();
const getUserFriendlyName = useUserFriendlyName();
const { resource, identifier } = useResource(resourceFromProps);
const isCreateButtonVisible =
canCreate ??
((resource?.canCreate ?? !!resource?.create) ||
createButtonPropsFromProps);
const breadcrumb =
typeof breadcrumbFromProps === "undefined"
? globalBreadcrumb
: breadcrumbFromProps;
const createButtonProps: CreateButtonProps | undefined =
isCreateButtonVisible
? {
size: "middle",
resource:
routerType === "legacy" ? resource?.route : identifier,
...createButtonPropsFromProps,
}
: undefined;
const defaultExtra = isCreateButtonVisible ? (
<CreateButton {...createButtonProps} />
) : null;
return (
<div {...(wrapperProps ?? {})}>
<PageHeader
ghost={false}
title={
title ??
translate(
`${identifier}.titles.list`,
getUserFriendlyName(
resource?.meta?.label ??
resource?.options?.label ??
resource?.label ??
identifier,
"plural",
),
)
}
extra={
headerButtons ? (
<Space wrap {...headerButtonProps}>
{typeof headerButtons === "function"
? headerButtons({
defaultButtons: defaultExtra,
createButtonProps,
})
: headerButtons}
</Space>
) : (
defaultExtra
)
}
breadcrumb={
typeof breadcrumb !== "undefined" ? (
<>{breadcrumb}</> ?? undefined
) : (
<Breadcrumb />
)
}
{...(headerProps ?? {})}
>
<div {...(contentProps ?? {})}>{children}</div>
</PageHeader>
</div>
);
};

View file

@ -0,0 +1,459 @@
import React, { ReactNode } from "react";
import { Route, Routes } from "react-router-dom";
import { RefineButtonTestIds } from "@refinedev/ui-types";
import { AccessControlProvider } from "@refinedev/core";
import { render, TestWrapper, waitFor } from "@test";
import { Show } from "./index";
import { crudShowTests } from "@refinedev/ui-tests";
import {
DeleteButton,
EditButton,
ListButton,
RefreshButton,
} from "@components/buttons";
const renderShow = (
show: ReactNode,
accessControlProvider?: AccessControlProvider,
) => {
return render(
<Routes>
<Route path="/:resource/:action/:id" element={show} />
</Routes>,
{
wrapper: TestWrapper({
routerInitialEntries: ["/posts/show/1"],
accessControlProvider,
}),
},
);
};
describe("Show", () => {
crudShowTests.bind(this)(Show as any);
it("depending on the accessControlProvider it should get the buttons successfully", async () => {
const { getByText, getAllByText, queryByTestId } = renderShow(
<Show
canEdit
canDelete
headerButtons={({
defaultButtons,
deleteButtonProps,
editButtonProps,
}) => {
expect(deleteButtonProps).toBeDefined();
expect(editButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>,
{
can: ({ action }) => {
switch (action) {
case "edit":
case "list":
return Promise.resolve({ can: true });
case "delete":
default:
return Promise.resolve({ can: false });
}
},
},
);
await waitFor(() =>
expect(getByText("Edit").closest("button")).not.toBeDisabled(),
);
await waitFor(() =>
expect(
getAllByText("Posts")[1].closest("button"),
).not.toBeDisabled(),
);
await waitFor(() =>
expect(
queryByTestId(RefineButtonTestIds.DeleteButton),
).toBeDisabled(),
);
});
it("should render optional recordItemId with resource prop, not render list button", async () => {
const { getByText, queryByTestId } = renderShow(
<Show
recordItemId="1"
headerButtons={({ defaultButtons, listButtonProps }) => {
expect(listButtonProps).not.toBeDefined();
return <>{defaultButtons}</>;
}}
/>,
);
getByText("Show Post");
expect(queryByTestId(RefineButtonTestIds.ListButton)).toBeNull();
});
describe("render edit button", () => {
it("should render edit button", async () => {
const { getByText, queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
headerButtons={({
defaultButtons,
editButtonProps,
}) => {
expect(editButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
></Route>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", edit: () => null }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(
queryByTestId(RefineButtonTestIds.EditButton),
).not.toBeNull();
getByText("Show Post");
});
it("should not render edit button on resource canEdit false", async () => {
const { getByText, queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
headerButtons={({
defaultButtons,
editButtonProps,
}) => {
expect(editButtonProps).not.toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts" }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(queryByTestId(RefineButtonTestIds.EditButton)).toBeNull();
getByText("Show Post");
});
it("should not render edit button on resource canEdit true & canEdit props false on component", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
canEdit={false}
headerButtons={({
defaultButtons,
editButtonProps,
}) => {
expect(editButtonProps).not.toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", edit: () => null }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(queryByTestId(RefineButtonTestIds.EditButton)).toBeNull();
});
it("should render edit button on resource canEdit false & canEdit props true on component", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
canEdit={true}
headerButtons={({
defaultButtons,
editButtonProps,
}) => {
expect(editButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts" }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(
queryByTestId(RefineButtonTestIds.EditButton),
).not.toBeNull();
});
it("should render edit button with recordItemId prop", async () => {
const { getByText, queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
recordItemId="1"
headerButtons={({
defaultButtons,
editButtonProps,
}) => {
expect(editButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", edit: () => null }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(
queryByTestId(RefineButtonTestIds.EditButton),
).not.toBeNull();
getByText("Show Post");
});
});
describe("render delete button", () => {
it("should render delete button", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
headerButtons={({
defaultButtons,
deleteButtonProps,
}) => {
expect(deleteButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: true }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(
queryByTestId(RefineButtonTestIds.DeleteButton),
).not.toBeNull();
});
it("should not render delete button on resource canDelete false", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
headerButtons={({
defaultButtons,
deleteButtonProps,
}) => {
expect(deleteButtonProps).not.toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: false }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeNull();
});
it("should not render delete button on resource canDelete true & canDelete props false on component", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
canDelete={false}
headerButtons={({
defaultButtons,
deleteButtonProps,
}) => {
expect(deleteButtonProps).not.toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: true }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeNull();
});
it("should render delete button on resource canDelete false & canDelete props true on component", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
canDelete={true}
headerButtons={({
defaultButtons,
deleteButtonProps,
}) => {
expect(deleteButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: false }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(
queryByTestId(RefineButtonTestIds.DeleteButton),
).not.toBeNull();
});
it("should render delete button with recordItemId prop", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
recordItemId="1"
headerButtons={({
defaultButtons,
deleteButtonProps,
}) => {
expect(deleteButtonProps).toBeDefined();
return <>{defaultButtons}</>;
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts", canDelete: true }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(
queryByTestId(RefineButtonTestIds.DeleteButton),
).not.toBeNull();
});
});
it("should customize default buttons with default props", async () => {
const { queryByTestId } = render(
<Routes>
<Route
path="/:resource/:action/:id"
element={
<Show
canEdit
canDelete
headerButtons={({
deleteButtonProps,
editButtonProps,
listButtonProps,
refreshButtonProps,
}) => {
return (
<>
<DeleteButton {...deleteButtonProps} />
<EditButton {...editButtonProps} />
<ListButton {...listButtonProps} />
<RefreshButton
{...refreshButtonProps}
/>
</>
);
}}
/>
}
/>
</Routes>,
{
wrapper: TestWrapper({
resources: [{ name: "posts" }],
routerInitialEntries: ["/posts/show/1"],
}),
},
);
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).not.toBeNull();
expect(queryByTestId(RefineButtonTestIds.EditButton)).not.toBeNull();
expect(queryByTestId(RefineButtonTestIds.ListButton)).not.toBeNull();
expect(queryByTestId(RefineButtonTestIds.RefreshButton)).not.toBeNull();
});
});

View file

@ -0,0 +1,216 @@
import React from "react";
import { Card, Space, Spin } from "antd";
import {
useNavigation,
useTranslate,
useUserFriendlyName,
useRefineContext,
useResource,
useToPath,
useRouterType,
useBack,
useGo,
} from "@refinedev/core";
import {
EditButton,
DeleteButton,
RefreshButton,
ListButton,
Breadcrumb,
PageHeader,
ListButtonProps,
EditButtonProps,
DeleteButtonProps,
RefreshButtonProps,
} from "@components";
import { ShowProps } from "../types";
/**
* `<Show>` provides us a layout for displaying the page.
* It does not contain any logic but adds extra functionalities like a refresh button.
*
* @see {@link https://refine.dev/docs/ui-frameworks/antd/components/basic-views/show} for more details.
*/
export const Show: React.FC<ShowProps> = ({
title,
canEdit,
canDelete,
isLoading = false,
children,
resource: resourceFromProps,
recordItemId,
dataProviderName,
breadcrumb: breadcrumbFromProps,
contentProps,
headerProps,
wrapperProps,
headerButtons,
footerButtons,
footerButtonProps,
headerButtonProps,
goBack: goBackFromProps,
}) => {
const translate = useTranslate();
const { options: { breadcrumb: globalBreadcrumb } = {} } =
useRefineContext();
const routerType = useRouterType();
const back = useBack();
const go = useGo();
const { goBack, list: legacyGoList } = useNavigation();
const getUserFriendlyName = useUserFriendlyName();
const {
resource,
action,
id: idFromParams,
identifier,
} = useResource(resourceFromProps);
const goListPath = useToPath({
resource,
action: "list",
});
const id = recordItemId ?? idFromParams;
const breadcrumb =
typeof breadcrumbFromProps === "undefined"
? globalBreadcrumb
: breadcrumbFromProps;
const hasList = resource?.list && !recordItemId;
const isDeleteButtonVisible =
canDelete ?? resource?.meta?.canDelete ?? resource?.canDelete;
const isEditButtonVisible =
canEdit ?? resource?.canEdit ?? !!resource?.edit;
const listButtonProps: ListButtonProps | undefined = hasList
? {
resource: routerType === "legacy" ? resource?.route : identifier,
}
: undefined;
const editButtonProps: EditButtonProps | undefined = isEditButtonVisible
? {
...(isLoading ? { disabled: true } : {}),
type: "primary",
resource: routerType === "legacy" ? resource?.route : identifier,
recordItemId: id,
}
: undefined;
const deleteButtonProps: DeleteButtonProps | undefined =
isDeleteButtonVisible
? {
...(isLoading ? { disabled: true } : {}),
resource:
routerType === "legacy" ? resource?.route : identifier,
recordItemId: id,
onSuccess: () => {
if (routerType === "legacy") {
legacyGoList(resource?.route ?? resource?.name ?? "");
} else {
go({ to: goListPath });
}
},
dataProviderName,
}
: undefined;
const refreshButtonProps: RefreshButtonProps = {
...(isLoading ? { disabled: true } : {}),
resource: routerType === "legacy" ? resource?.route : identifier,
recordItemId: id,
dataProviderName,
};
const defaultHeaderButtons = (
<>
{hasList && <ListButton {...listButtonProps} />}
{isEditButtonVisible && <EditButton {...editButtonProps} />}
{isDeleteButtonVisible && <DeleteButton {...deleteButtonProps} />}
<RefreshButton {...refreshButtonProps} />
</>
);
return (
<div {...(wrapperProps ?? {})}>
<PageHeader
ghost={false}
backIcon={goBackFromProps}
onBack={
action !== "list" && typeof action !== "undefined"
? routerType === "legacy"
? goBack
: back
: undefined
}
title={
title ??
translate(
`${identifier}.titles.show`,
`Show ${getUserFriendlyName(
resource?.meta?.label ??
resource?.options?.label ??
resource?.label ??
identifier,
"singular",
)}`,
)
}
extra={
<Space
key="extra-buttons"
wrap
{...(headerButtonProps ?? {})}
>
{headerButtons
? typeof headerButtons === "function"
? headerButtons({
defaultButtons: defaultHeaderButtons,
deleteButtonProps,
editButtonProps,
listButtonProps,
refreshButtonProps,
})
: headerButtons
: defaultHeaderButtons}
</Space>
}
breadcrumb={
typeof breadcrumb !== "undefined" ? (
<>{breadcrumb}</> ?? undefined
) : (
<Breadcrumb />
)
}
{...(headerProps ?? {})}
>
<Spin spinning={isLoading}>
<Card
bordered={false}
actions={
footerButtons
? [
<Space
key="footer-buttons"
wrap
{...footerButtonProps}
>
{typeof footerButtons === "function"
? footerButtons({
defaultButtons: null,
})
: footerButtons}
</Space>,
]
: undefined
}
{...(contentProps ?? {})}
>
{children}
</Card>
</Spin>
</PageHeader>
</div>
);
};

View file

@ -0,0 +1,74 @@
import { CardProps, SpaceProps } from "antd";
import {
CreateButtonProps,
DeleteButtonProps,
EditButtonProps,
ListButtonProps,
RefreshButtonProps,
SaveButtonProps,
} from "../buttons/types";
import {
RefineCrudCreateProps,
RefineCrudEditProps,
RefineCrudListProps,
RefineCrudShowProps,
} from "@refinedev/ui-types";
import { PageHeaderProps } from "../pageHeader";
export type CreateProps = RefineCrudCreateProps<
SaveButtonProps,
SpaceProps,
SpaceProps,
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>,
PageHeaderProps,
CardProps
>;
export type EditProps = RefineCrudEditProps<
SaveButtonProps,
DeleteButtonProps,
SpaceProps,
SpaceProps,
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>,
PageHeaderProps,
CardProps,
{},
RefreshButtonProps,
ListButtonProps
>;
export type ListProps = RefineCrudListProps<
CreateButtonProps,
SpaceProps,
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>,
PageHeaderProps,
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>
>;
export type ShowProps = RefineCrudShowProps<
SpaceProps,
SpaceProps,
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>,
PageHeaderProps,
CardProps,
{},
EditButtonProps,
DeleteButtonProps,
RefreshButtonProps,
ListButtonProps
>;

View file

@ -0,0 +1,54 @@
import React from "react";
import { fieldBooleanTests } from "@refinedev/ui-tests";
import { render, fireEvent } from "@test";
import { BooleanField } from "./";
describe("BooleanField", () => {
fieldBooleanTests.bind(this)(BooleanField);
describe("BooleanField with default props values", () => {
const initialValues = [true, false, "true", "false", "", undefined];
const results = ["true", "false", "true", "true", "false", "false"];
const iconClass = [
"anticon-check",
"anticon-close",
"anticon-check",
"anticon-check",
"anticon-close",
"anticon-close",
];
initialValues.forEach((element, index) => {
const testName =
index === 2 || index === 3 || index === 4
? `"${initialValues[index]}"`
: initialValues[index];
it(`renders boolean field value(${testName}) with correct tooltip text and icon`, async () => {
const baseDom = render(
<div data-testid="default-field">
<BooleanField value={element} />
</div>,
);
fireEvent.mouseOver(
baseDom.getByTestId("default-field").children[0],
);
expect(
await baseDom.findByText(results[index]),
).toBeInTheDocument();
expect(
baseDom
.getByTestId("default-field")
.children[0].children[0].classList.contains(
iconClass[index],
),
).toBe(true);
});
});
});
});

View file

@ -0,0 +1,26 @@
import React from "react";
import { Tooltip } from "antd";
import { CheckOutlined, CloseOutlined } from "@ant-design/icons";
import { BooleanFieldProps } from "../types";
/**
* This field is used to display boolean values. It uses the {@link https://ant.design/components/tooltip/#header `<Tooltip>`} values from Ant Design.
*
* @see {@link https://refine.dev/docs/api-reference/antd/components/fields/boolean} for more details.
*/
export const BooleanField: React.FC<BooleanFieldProps> = ({
value,
valueLabelTrue = "true",
valueLabelFalse = "false",
trueIcon = <CheckOutlined />,
falseIcon = <CloseOutlined />,
...rest
}) => {
return (
<Tooltip title={value ? valueLabelTrue : valueLabelFalse} {...rest}>
{value ? <span>{trueIcon}</span> : <span>{falseIcon}</span>}
</Tooltip>
);
};

View file

@ -0,0 +1,7 @@
import { fieldDateTests } from "@refinedev/ui-tests";
import { DateField } from "./";
describe("DateField", () => {
fieldDateTests.bind(this)(DateField);
});

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