From c8d8312308ef0e3233d0c217e9b412a70a1c55ad Mon Sep 17 00:00:00 2001 From: Nice Date: Wed, 22 Jan 2025 14:03:24 +0800 Subject: [PATCH] feat: slot --- packages/json-schema/src/schema.ts | 3 + packages/json-schema/src/types.ts | 5 + .../react/docs/api/components/SchemaField.md | 119 ++++++++++++++++++ .../docs/api/components/SchemaField.zh-CN.md | 119 ++++++++++++++++++ packages/react/docs/api/shared/Schema.md | 82 ++++++++++++ .../react/docs/api/shared/Schema.zh-CN.md | 82 ++++++++++++ .../react/src/__tests__/schema.json.spec.tsx | 103 ++++++++++++++- .../react/src/components/RecursionField.tsx | 40 +++++- 8 files changed, 548 insertions(+), 5 deletions(-) diff --git a/packages/json-schema/src/schema.ts b/packages/json-schema/src/schema.ts index 72487afdebe..88fd942e074 100644 --- a/packages/json-schema/src/schema.ts +++ b/packages/json-schema/src/schema.ts @@ -6,6 +6,7 @@ import { SchemaTypes, SchemaKey, ISchemaTransformerOptions, + Slot, } from './types' import { IFieldFactoryProps } from '@formily/core' import { map, each, isFn, instOf, FormPath, isStr } from '@formily/shared' @@ -180,6 +181,8 @@ export class Schema< ['x-compile-omitted']?: string[]; + ['x-slot-node']?: Slot; + [key: `x-${string | number}` | symbol]: any _isJSONSchemaObject = true diff --git a/packages/json-schema/src/types.ts b/packages/json-schema/src/types.ts index fab5bdd41f7..cc88660cd0b 100644 --- a/packages/json-schema/src/types.ts +++ b/packages/json-schema/src/types.ts @@ -150,6 +150,11 @@ export interface ISchemaTransformerOptions { scope?: IScopeContext } +export type Slot = { + target: string + isRenderProp?: boolean +} + export type Stringify

= { /** * Use `string & {}` instead of string to keep Literal Type for ISchema#component and ISchema#decorator diff --git a/packages/react/docs/api/components/SchemaField.md b/packages/react/docs/api/components/SchemaField.md index 93b544a1a71..2fb0c98195e 100644 --- a/packages/react/docs/api/components/SchemaField.md +++ b/packages/react/docs/api/components/SchemaField.md @@ -139,3 +139,122 @@ export default () => ( ) ``` + +## JSON Schema ReactNode Prop Use Case (x-slot-node) + +Reference [Slot](https://react.formilyjs.org/api/shared/schema#slot) + +```tsx +import React from 'react' +import { createForm } from '@formily/core' +import { FormProvider, createSchemaField } from '@formily/react' +import { Input, Tag } from 'antd' +import { CheckCircleTwoTone } from '@ant-design/icons' + +const form = createForm() + +const SchemaField = createSchemaField({ + components: { + Input, + CheckCircleTwoTone, + }, +}) + +export default () => ( + + + +) +``` + +## JSON Schema Render Prop Use Case (x-slot-node & isRenderProp) + +Reference [Slot](https://react.formilyjs.org/api/shared/schema#slot) + +```tsx +import React from 'react' +import { createForm } from '@formily/core' +import { FormProvider, createSchemaField } from '@formily/react' +import { Rate } from 'antd' +import { DollarOutlined } from '@ant-design/icons' + +const form = createForm() + +const SchemaField = createSchemaField({ + components: { + DollarOutlined, + }, +}) + +export default () => ( + + + +) +``` diff --git a/packages/react/docs/api/components/SchemaField.zh-CN.md b/packages/react/docs/api/components/SchemaField.zh-CN.md index ea317131acf..de1dbe2cc07 100644 --- a/packages/react/docs/api/components/SchemaField.zh-CN.md +++ b/packages/react/docs/api/components/SchemaField.zh-CN.md @@ -139,3 +139,122 @@ export default () => ( ) ``` + +## JSON Schema ReactNode Prop 用例 (x-slot-node) + +参考[Slot](https://react.formilyjs.org/zh-CN/api/shared/schema#slot) + +```tsx +import React from 'react' +import { createForm } from '@formily/core' +import { FormProvider, createSchemaField } from '@formily/react' +import { Input, Tag } from 'antd' +import { CheckCircleTwoTone } from '@ant-design/icons' + +const form = createForm() + +const SchemaField = createSchemaField({ + components: { + Input, + CheckCircleTwoTone, + }, +}) + +export default () => ( + + + +) +``` + +## JSON Schema Render Prop 用例 (x-slot-node & isRenderProp) + +参考[Slot](https://react.formilyjs.org/zh-CN/api/shared/schema#slot) + +```tsx +import React from 'react' +import { createForm } from '@formily/core' +import { FormProvider, createSchemaField } from '@formily/react' +import { Rate } from 'antd' +import { DollarOutlined } from '@ant-design/icons' + +const form = createForm() + +const SchemaField = createSchemaField({ + components: { + DollarOutlined, + }, +}) + +export default () => ( + + + +) +``` diff --git a/packages/react/docs/api/shared/Schema.md b/packages/react/docs/api/shared/Schema.md index 8968fef365e..cc5769d3b67 100644 --- a/packages/react/docs/api/shared/Schema.md +++ b/packages/react/docs/api/shared/Schema.md @@ -72,6 +72,7 @@ Create a Schema Tree based on a piece of json schema data to ensure that each sc | $ref | Read the Schema from the Schema predefined and merge it into the current Schema | String | - | | x-data | Extends Data | Object | `data` | | x-compile-omitted | list of attributes to ignore compiled expressions | string[] | `[]` | +| x-slot-node | Slot node mark | [Slot](#slot) | - | #### Detailed description @@ -957,6 +958,83 @@ Writing method two, operating the Schema protocol } ``` +### Slot + +#### Description + +Mark this node as a Slot node, which will be skipped in the normal rendering process. +Use `target` to specify the target property for rendering this node, which must be a sibling property at the same level. +You can use `isRenderProp` to specify that this node is passed in the form of the renderProp function. +When `isRenderProp` is `true`, the renderProp function’s argument list can be accessed within the Slot through `$slotArgs`. + +#### Signature + +```ts +type Slot = { + //Slot target: Specify the target property for rendering this node, which must be a sibling property at the same level. + target: string // 'some-sibling-node.x-component-props.xxx' or 'some-sibling-node.x-decorator-props.xxx' + //whether it is a renderProp Slot + isRenderProp?: boolean +} +``` + +#### Example + +**ReactNode Prop** +Reference [SchemaField](https://react.formilyjs.org/api/components/schema-field#json-schema-reactnode-prop-use-case-x-slot-node) + +```json +{ + "type": "object", + "properties": { + "search_icon": { + "x-slot-node": { + "target": "button.x-component-props.icon" //Specify to render the search_icon node as a slot into the icon prop of the Button component. + }, + "x-component": "SearchOutlined", + "x-component-props": { + "data-testid": "icon" + } + }, + "button": { + "type": "string", + "x-component": "Button", + "x-component-props": { + "data-testid": "button" + } + } + } +} +``` + +**RenderProp** +Reference [SchemaField](https://react.formilyjs.org/api/components/schema-field#json-schema-render-prop-use-case-x-slot-node--isrenderprop) + +```json +{ + "type": "object", + "properties": { + "dollar_icon": { + "x-slot-node": { + "target": "rate.x-component-props.character", //Specify to render the dollar_icon node as a slot into the character prop of the Rate component. + "isRenderProp": true //The character prop accepts a renderProp function. Specify the Slot as a renderProp to take control of the rendering of the rating icons. + }, + "x-component": "DollarOutlined", + "x-component-props": { + "data-testid": "icon", + "rotate": "{{$slotArgs[0].value * 45}}", //When isRenderProp is true, the renderProp function’s argument list can be accessed within the Slot through $slotArgs. + "style": { + "fontSize": "50px" + } + } + }, + "rate": { + "x-component": "Rate" + } + } +} +``` + ## Built-in expression scope Built-in expression scope is mainly used to realize various linkage relationships in expressions @@ -996,3 +1074,7 @@ It can only be consumed by expressions in x-reactions, corresponding to the depe ### $target Can only be consumed in expressions in x-reactions, representing the target field of active mode + +### $slotArgs + +Can only be consumed in slot node. When slot used as render prop, $slotArgs can access render function arguments array diff --git a/packages/react/docs/api/shared/Schema.zh-CN.md b/packages/react/docs/api/shared/Schema.zh-CN.md index cd423ba39ba..6abfc51cc6e 100644 --- a/packages/react/docs/api/shared/Schema.zh-CN.md +++ b/packages/react/docs/api/shared/Schema.zh-CN.md @@ -72,6 +72,7 @@ class Schema { | $ref | 从 Schema 预定义中读取 Schema 并合并至当前 Schema | String | - | | x-data | 扩展属性 | Object | `data` | | x-compile-omitted | 忽略编译表达式的属性列表 | string[] | `[]` | +| x-slot-node | Slot 节点标记 | [Slot](#slot) | - | #### 详细说明 @@ -957,6 +958,83 @@ type SchemaReactions = } ``` +### Slot + +#### 描述 + +标记该节点为 Slot 节点,会在常规渲染流程中被跳过 +通过 `target` 指定此节点渲染的目标属性,必须为同级兄弟节点属性 +可以通过 `isRenderProp` 来指定该节点以 renderProp 函数形式传入 +当`isRenderProp`为`true`时,在 Slot 中可以通过`$slotArgs`访问到 renderProp 函数入参参数列表 + +#### 签名 + +```ts +type Slot = { + //Slot目标,指定此节点渲染的目标属性,必须为同级兄弟节点属性 + target: string // 'some-sibling-node.x-component-props.xxx' 或 'some-sibling-node.x-decorator-props.xxx' + //Slot是否为render prop + isRenderProp?: boolean +} +``` + +#### 用例 + +**ReactNode Prop** +参考 [SchemaField](https://react.formilyjs.org/zh-CN/api/components/schema-field#json-schema-reactnode-prop-%E7%94%A8%E4%BE%8B-x-slot-node) + +```json +{ + "type": "object", + "properties": { + "search_icon": { + "x-slot-node": { + "target": "button.x-component-props.icon" //指定将search_icon节点作为slot渲染到Button组件的icon prop中 + }, + "x-component": "SearchOutlined", + "x-component-props": { + "data-testid": "icon" + } + }, + "button": { + "type": "string", + "x-component": "Button", + "x-component-props": { + "data-testid": "button" + } + } + } +} +``` + +**RenderProp** +参考 [SchemaField](https://react.formilyjs.org/zh-CN/api/components/schema-field#json-schema-render-prop-%E7%94%A8%E4%BE%8B-x-slot-node--isrenderprop) + +```json +{ + "type": "object", + "properties": { + "dollar_icon": { + "x-slot-node": { + "target": "rate.x-component-props.character", //指定将dollar_icon节点作为slot渲染到Rate组件的character prop中 + "isRenderProp": true //character prop接受一个renderProp函数,指定Slot作为renderProp传入,接管评分图标渲染 + }, + "x-component": "DollarOutlined", + "x-component-props": { + "data-testid": "icon", + "rotate": "{{$slotArgs[0].value * 45}}", //当isRenderProp为true时,在Slot中可以通过$slotArgs访问到renderProp函数入参参数列表 + "style": { + "fontSize": "50px" + } + } + }, + "rate": { + "x-component": "Rate" + } + } +} +``` + ## 内置表达式作用域 内置表达式作用域主要用于在表达式中实现各种联动关系 @@ -996,3 +1074,7 @@ type SchemaReactions = ### $target 只能在 x-reactions 中的表达式消费,代表主动模式的 target 字段 + +### $slotArgs + +只能在 Slot 节点中消费。当为 render prop slot 时,可以通过$slotArgs 获取渲染函数入参数组 diff --git a/packages/react/src/__tests__/schema.json.spec.tsx b/packages/react/src/__tests__/schema.json.spec.tsx index 9df058089f1..9a956cbd468 100644 --- a/packages/react/src/__tests__/schema.json.spec.tsx +++ b/packages/react/src/__tests__/schema.json.spec.tsx @@ -8,6 +8,9 @@ const Input = ({ value, onChange }) => { return } +import { Button, Rate } from 'antd' +import { SearchOutlined, DollarOutlined } from '@ant-design/icons' + describe('json schema field', () => { test('string field', () => { const form = createForm() @@ -31,7 +34,7 @@ describe('json schema field', () => { ) expect(queryByTestId('input')).toBeVisible() - expect(queryByTestId('input').getAttribute('value')).toEqual('123') + expect(queryByTestId('input')?.getAttribute('value')).toEqual('123') }) test('object field', () => { const form = createForm() @@ -88,7 +91,7 @@ describe('json schema field', () => { ) expect(queryByTestId('children-test')).toBeVisible() - expect(queryByTestId('children-test').innerHTML).toEqual('children') + expect(queryByTestId('children-test')?.innerHTML).toEqual('children') }) test('x-content', async () => { const form = createForm() @@ -118,6 +121,100 @@ describe('json schema field', () => { ) expect(queryByTestId('content-test')).toBeVisible() - expect(queryByTestId('content-test').innerHTML).toEqual('content') + expect(queryByTestId('content-test')?.innerHTML).toEqual('content') + }) + test('x-slot-node', () => { + const form = createForm() + const SchemaField = createSchemaField({ + components: { + SearchOutlined, + Button, + }, + }) + const { queryByTestId } = render( + + + + ) + const button = queryByTestId('button') + const icon = queryByTestId('icon') + + expect(button).toContainElement(icon) + }) + test('x-slot-node render prop', async () => { + const form = createForm() + const SchemaField = createSchemaField({ + components: { + Rate, + DollarOutlined, + }, + }) + const { queryByRole, queryAllByTestId } = render( + + + + ) + + const rate = queryByRole('radiogroup') + expect(rate).toBeVisible() + const icons = queryAllByTestId('icon') + expect(icons).toHaveLength(10) + icons.forEach((icon) => { + expect(rate).toContainElement(icon) + }) + + const style = window.getComputedStyle(icons[0]) + const fontSize = style.fontSize + expect(fontSize).toBe('20px') }) }) diff --git a/packages/react/src/components/RecursionField.tsx b/packages/react/src/components/RecursionField.tsx index 7ac7f2218e7..9792d7dc666 100644 --- a/packages/react/src/components/RecursionField.tsx +++ b/packages/react/src/components/RecursionField.tsx @@ -1,7 +1,7 @@ import React, { Fragment, useMemo } from 'react' -import { isBool, isFn, isValid } from '@formily/shared' +import { FormPath, isBool, isFn, isValid } from '@formily/shared' import { GeneralField } from '@formily/core' -import { Schema } from '@formily/json-schema' +import { Schema, SchemaKey } from '@formily/json-schema' import { SchemaContext } from '../shared' import { IRecursionFieldProps, ReactFC } from '../types' import { useField, useExpressionScope } from '../hooks' @@ -9,6 +9,8 @@ import { ObjectField } from './ObjectField' import { ArrayField } from './ArrayField' import { Field } from './Field' import { VoidField } from './VoidField' +import { ExpressionScope } from './ExpressionScope' +import { observable } from '@formily/reactive' const useFieldProps = (schema: Schema) => { const scope = useExpressionScope() @@ -29,15 +31,49 @@ export const RecursionField: ReactFC = (props) => { const basePath = useBasePath(props) const fieldSchema = useMemo(() => new Schema(props.schema), [props.schema]) const fieldProps = useFieldProps(fieldSchema) + + const renderSlots = () => { + fieldSchema.mapProperties((innerSchema, key) => { + const slot = innerSchema['x-slot-node'] + + if (!slot) { + return + } + + const { target, isRenderProp } = slot + if (isRenderProp) { + const args = observable({ $slotArgs: [] }) + FormPath.setIn(fieldSchema.properties, target, (..._args: any) => { + args.$slotArgs = _args + return ( + + + + ) + }) + } else { + FormPath.setIn( + fieldSchema.properties, + target, + + ) + } + }) + } + const renderProperties = (field?: GeneralField) => { if (props.onlyRenderSelf) return const properties = Schema.getOrderProperties(fieldSchema) if (!properties.length) return + renderSlots() return ( {properties.map(({ schema: item, key: name }, index) => { const base = field?.address || basePath let schema: Schema = item + if (schema['x-slot-node']) { + return null + } if (isFn(props.mapProperties)) { const mapped = props.mapProperties(item, name) if (mapped) {