From c96f9be8218085811ed847f1725a55578ac051fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B6=9B?= Date: Wed, 18 Oct 2023 10:42:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=97=E8=A1=A8=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=8F=AF=E8=A7=86=E5=8C=96=20&=20=E7=8A=B6=E6=80=81=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E6=94=AF=E6=8C=81=20(#8408)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 列表选择组件支持自定义样式设计 * feat: 添加多状态容器 --- docs/zh-CN/components/switch-container.md | 235 +++++++++ .../scss/control/_formItem-control.scss | 16 + .../scss/control/_switch-more-control.scss | 5 + packages/amis-editor-core/scss/editor.scss | 1 + packages/amis-editor-core/src/plugin.ts | 4 +- packages/amis-editor-core/src/util.ts | 8 +- .../src/icons/container/switch-container.svg | 4 + packages/amis-editor/src/icons/index.tsx | 2 + packages/amis-editor/src/index.tsx | 1 + .../src/plugin/Form/ListSelect.tsx | 159 +++++- .../src/plugin/SwitchContainer.tsx | 480 ++++++++++++++++++ packages/amis-editor/src/plugin/index.ts | 1 + .../src/renderer/ListItemControl.tsx | 393 ++++++++++++++ .../amis-ui/scss/components/form/_list.scss | 15 + .../renderers/Form/listSelect.test.tsx | 74 +++ packages/amis/src/Schema.ts | 3 + packages/amis/src/index.tsx | 1 + .../amis/src/renderers/Form/ListSelect.tsx | 21 +- .../amis/src/renderers/SwitchContainer.tsx | 187 +++++++ 19 files changed, 1597 insertions(+), 13 deletions(-) create mode 100644 docs/zh-CN/components/switch-container.md create mode 100644 packages/amis-editor/src/icons/container/switch-container.svg create mode 100644 packages/amis-editor/src/plugin/SwitchContainer.tsx create mode 100644 packages/amis-editor/src/renderer/ListItemControl.tsx create mode 100644 packages/amis/src/renderers/SwitchContainer.tsx diff --git a/docs/zh-CN/components/switch-container.md b/docs/zh-CN/components/switch-container.md new file mode 100644 index 000000000..59c5ce8cd --- /dev/null +++ b/docs/zh-CN/components/switch-container.md @@ -0,0 +1,235 @@ +--- +title: switch-container 状态容器 +description: +type: 0 +group: ⚙ 组件 +menuName: switch-container 容器 +icon: +order: 50 +--- + +switch-container 是一种特殊的容器组件,它可以根据动态数据显示条件渲染组件的某一状态。注意容器只会显示最多一种状态,只显示首个命中的状态。容器的不同状态是对应的展示配置,可通过组件搭配与嵌套设计任意展示样式。状态容器的外观与事件动作与容器组件类似,支持常见的外观设置与点击、移入、移出事件动作。 + +状态容器主要用于编辑器内统一管理复杂组件的多种状态,同时避免因为组件多状态显示而干扰设计。如果只使用 amis 引擎,也可以直接用容器加显示条件实现。 + +## 基本用法 + +```schema: scope="body" +{ + "type": "form", + "title": "", + "mode": "horizontal", + "dsType": "api", + "feat": "Insert", + "body": [ + { + "type": "button-group-select", + "name": "state", + "label": "切换状态", + "inline": false, + "options": [ + { + "label": "选项1", + "value": "a" + }, + { + "label": "选项2", + "value": "b" + } + ], + "multiple": false, + "value": "" + }, + { + "type": "switch-container", + "items": [ + { + "title": "状态1", + "body": [ + { + "type": "tpl", + "tpl": "状态内容1", + "wrapperComponent": "", + "inline": false + } + ], + "visibleOn": "${state == \"a\"}" + }, + { + "title": "状态2", + "body": [ + { + "type": "tpl", + "tpl": "状态内容2", + "wrapperComponent": "", + "inline": false + } + ], + "visibleOn": "${state == \"b\"}" + } + ], + "style": { + "position": "static", + "display": "block" + } + } + ], + "actions": [], + "resetAfterSubmit": true +} +``` + +### style + +container 可以通过 style 来设置样式,比如背景色或背景图,注意这里的属性是使用驼峰写法,是 `backgroundColor` 而不是 `background-color`。 + +```schema: scope="body" +{ + "type": "switch-container", + "style": { + "backgroundColor": "#C4C4C4" + }, + "items": [ + { + "title": "状态1", + "body": [ + { + "type": "tpl", + "tpl": "状态内容1", + "wrapperComponent": "", + "inline": false + } + ] + } + ], +} +``` + +## 属性表 + +| 属性名 | 类型 | 默认值 | 说明 | +| --------- | ----------------------------------------- | ------------- | ----------------------- | +| type | `string` | `"container"` | 指定为 container 渲染器 | +| className | `string` | | 外层 Dom 的类名 | +| style | `Object` | | 自定义样式 | +| items | [SchemaNode](../../docs/types/schemanode) | | 容器内容 | + +## 事件表 + +> 3.3.0 及以上版本 + +当前组件会对外派发以下事件,可以通过`onEvent`来监听这些事件,并通过`actions`来配置执行的动作,在`actions`中可以通过`${事件参数名}`或`${event.data.[事件参数名]}`来获取事件产生的数据,详细查看[事件动作](../../docs/concepts/event-action)。 + +| 事件名称 | 事件参数 | 说明 | +| ---------- | -------- | -------------- | +| click | - | 点击时触发 | +| mouseenter | - | 鼠标移入时触发 | +| mouseleave | - | 鼠标移出时触发 | + +### click + +鼠标点击。可以尝试通过`${event.context.nativeEvent}`获取鼠标事件对象。 + +```schema: scope="body" +{ + "type": "switch-container", + "items": [ + { + "title": "状态1", + "body": [ + { + "type": "tpl", + "tpl": "状态内容1", + "wrapperComponent": "", + "inline": false + } + ] + } + ], + "onEvent": { + "click": { + "actions": [ + { + "actionType": "toast", + "args": { + "msgType": "info", + "msg": "${event.context.nativeEvent.type}" + } + } + ] + } + } +} +``` + +### mouseenter + +鼠标移入。可以尝试通过`${event.context.nativeEvent}`获取鼠标事件对象。 + +```schema: scope="body" +{ + "type": "switch-container", + "items": [ + { + "title": "状态1", + "body": [ + { + "type": "tpl", + "tpl": "状态内容1", + "wrapperComponent": "", + "inline": false + } + ] + } + ], + "onEvent": { + "mouseenter": { + "actions": [ + { + "actionType": "toast", + "args": { + "msgType": "info", + "msg": "${event.context.nativeEvent.type}" + } + } + ] + } + } +} +``` + +### mouseleave + +鼠标移出。可以尝试通过`${event.context.nativeEvent}`获取鼠标事件对象。 + +```schema: scope="body" +{ + "type": "switch-container", + "items": [ + { + "title": "状态1", + "body": [ + { + "type": "tpl", + "tpl": "状态内容1", + "wrapperComponent": "", + "inline": false + } + ] + } + ], + "onEvent": { + "mouseleave": { + "actions": [ + { + "actionType": "toast", + "args": { + "msgType": "info", + "msg": "${event.context.nativeEvent.type}" + } + } + ] + } + } +} +``` diff --git a/packages/amis-editor-core/scss/control/_formItem-control.scss b/packages/amis-editor-core/scss/control/_formItem-control.scss index 1af6b3117..74a7a6c5f 100644 --- a/packages/amis-editor-core/scss/control/_formItem-control.scss +++ b/packages/amis-editor-core/scss/control/_formItem-control.scss @@ -145,6 +145,22 @@ } } } + +/* 设置面板 combo 多行模式样式调整 */ +.cxd-Combo--ver:not(.cxd-Combo--noBorder) > .ae-Combo-items { + margin: px2rem(8px) 0; + padding: 0; +} + +.cxd-Combo--ver:not(.cxd-Combo--noBorder) > .ae-Combo-items > .cxd-Combo-item { + margin: 0; + border: none; + + &:hover { + border: none !important; + } +} + .ae-Combo-items + div { padding-left: 0px !important; margin-bottom: px2rem(12px); diff --git a/packages/amis-editor-core/scss/control/_switch-more-control.scss b/packages/amis-editor-core/scss/control/_switch-more-control.scss index 59e8f18b7..a2f04feff 100644 --- a/packages/amis-editor-core/scss/control/_switch-more-control.scss +++ b/packages/amis-editor-core/scss/control/_switch-more-control.scss @@ -34,6 +34,11 @@ } } + .cxd-DropDown, + .cxd-DropDown > .cxd-Button { + width: 100%; + } + .action-btn { padding: 0; } diff --git a/packages/amis-editor-core/scss/editor.scss b/packages/amis-editor-core/scss/editor.scss index 0ee4c70eb..281a77984 100644 --- a/packages/amis-editor-core/scss/editor.scss +++ b/packages/amis-editor-core/scss/editor.scss @@ -1335,6 +1335,7 @@ } } +[data-renderer='switch-container'], [data-renderer='container'] { min-height: 0; } diff --git a/packages/amis-editor-core/src/plugin.ts b/packages/amis-editor-core/src/plugin.ts index 1a8b3dea9..30174d640 100644 --- a/packages/amis-editor-core/src/plugin.ts +++ b/packages/amis-editor-core/src/plugin.ts @@ -304,6 +304,7 @@ export interface RendererInfo extends RendererScaffoldInfo { sharedContext?: Record; dialogTitle?: string; //弹窗标题用于弹窗大纲的展示 dialogType?: string; //区分确认对话框类型 + subEditorVariable?: Array<{label: string; children: any}>; // 传递给子编辑器的组件自定义变量,如listSelect的选项名称和值 } export type BasicRendererInfo = Omit< @@ -1049,7 +1050,8 @@ export abstract class BasePlugin implements PluginInterface { isBaseComponent: plugin.isBaseComponent, isListComponent: plugin.isListComponent, rendererName: plugin.rendererName, - memberImmutable: plugin.memberImmutable + memberImmutable: plugin.memberImmutable, + subEditorVariable: plugin.subEditorVariable }; } } diff --git a/packages/amis-editor-core/src/util.ts b/packages/amis-editor-core/src/util.ts index 501d2f6f4..5b19dcd1a 100644 --- a/packages/amis-editor-core/src/util.ts +++ b/packages/amis-editor-core/src/util.ts @@ -1209,10 +1209,16 @@ export async function resolveVariablesFromScope(node: any, manager: any) { (await manager?.dataSchema?.getDataPropsAsOptions()) ?? [] ); + // 子编辑器内读取的host节点自定义变量,非数据域方式,如listSelect的选项值 + let hostNodeVaraibles = []; + if (manager?.store?.isSubEditor) { + hostNodeVaraibles = manager.config?.hostNode?.info?.subEditorVariable || []; + } + const variables: VariableItem[] = manager?.variableManager?.getVariableFormulaOptions() || []; - return [...dataPropsAsOptions, ...variables].filter( + return [...hostNodeVaraibles, ...dataPropsAsOptions, ...variables].filter( (item: any) => item.children?.length ); } diff --git a/packages/amis-editor/src/icons/container/switch-container.svg b/packages/amis-editor/src/icons/container/switch-container.svg new file mode 100644 index 000000000..548f56c95 --- /dev/null +++ b/packages/amis-editor/src/icons/container/switch-container.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/amis-editor/src/icons/index.tsx b/packages/amis-editor/src/icons/index.tsx index e4a340b97..4f9ad8854 100644 --- a/packages/amis-editor/src/icons/index.tsx +++ b/packages/amis-editor/src/icons/index.tsx @@ -29,6 +29,7 @@ import wizard from './feat/wizard.svg'; import anchorNav from './container/anchor-nav.svg'; import collapse from './container/collapse.svg'; import container from './container/container.svg'; +import swtichContainer from './container/switch-container.svg'; import flexContainer from './container/flex-container.svg'; import formGroup from './container/form-group.svg'; import grid from './container/grid.svg'; @@ -207,6 +208,7 @@ registerIcon('anchor-nav-plugin', anchorNav); registerIcon('collapse-plugin', collapse); registerIcon('flex-container-plugin', flexContainer); registerIcon('container-plugin', container); +registerIcon('switch-container-plugin', swtichContainer); registerIcon('form-group-plugin', formGroup); registerIcon('panel-plugin', panel); registerIcon('grid-plugin', grid); diff --git a/packages/amis-editor/src/index.tsx b/packages/amis-editor/src/index.tsx index 019c32c22..b254e3a7e 100644 --- a/packages/amis-editor/src/index.tsx +++ b/packages/amis-editor/src/index.tsx @@ -48,6 +48,7 @@ import './renderer/crud2-control/CRUDToolbarControl'; import './renderer/crud2-control/CRUDFiltersControl'; import './renderer/InputRangeValueControl'; import './renderer/FunctionEditorControl'; +import './renderer/ListItemControl'; import 'amis-theme-editor/lib/locale/zh-CN'; import 'amis-theme-editor/lib/locale/en-US'; diff --git a/packages/amis-editor/src/plugin/Form/ListSelect.tsx b/packages/amis-editor/src/plugin/Form/ListSelect.tsx index 8d0fa18d5..c6574400a 100644 --- a/packages/amis-editor/src/plugin/Form/ListSelect.tsx +++ b/packages/amis-editor/src/plugin/Form/ListSelect.tsx @@ -1,11 +1,15 @@ -import {EditorNodeType, getSchemaTpl} from 'amis-editor-core'; +import { + EditorNodeType, + JSONPipeIn, + JSONPipeOut, + getSchemaTpl +} from 'amis-editor-core'; import {registerEditorPlugin} from 'amis-editor-core'; -import {BasePlugin, BaseEventContext} from 'amis-editor-core'; - +import {BasePlugin, BaseEventContext, diff} from 'amis-editor-core'; import {formItemControl} from '../../component/BaseControl'; -import {RendererPluginAction, RendererPluginEvent} from 'amis-editor-core'; -import {resolveOptionType} from '../../util'; +import type {RendererPluginAction, RendererPluginEvent} from 'amis-editor-core'; import type {Schema} from 'amis'; +import {resolveOptionType, schemaArrayFormat, schemaToArray} from '../../util'; export class ListControlPlugin extends BasePlugin { static id = 'ListControlPlugin'; @@ -105,6 +109,22 @@ export class ListControlPlugin extends BasePlugin { } ]; + subEditorVariable: Array<{label: string; children: any}> = [ + { + label: '当前选项', + children: [ + { + label: '选项名称', + value: 'label' + }, + { + label: '选项值', + value: 'value' + } + ] + } + ]; + panelBodyCreator = (context: BaseEventContext) => { return formItemControl( { @@ -119,7 +139,11 @@ export class ListControlPlugin extends BasePlugin { getSchemaTpl('multiple'), getSchemaTpl('extractValue'), getSchemaTpl('valueFormula', { - rendererSchema: (schema: Schema) => schema, + // 边栏渲染不渲染自定义样式,会干扰css生成 + rendererSchema: (schema: Schema) => ({ + ...(schema || {}), + itemSchema: null + }), mode: 'vertical', useSelectMode: true, // 改用 Select 设置模式 visibleOn: 'this.options && this.options.length > 0' @@ -127,7 +151,67 @@ export class ListControlPlugin extends BasePlugin { ] }, option: { - body: [getSchemaTpl('optionControlV2')] + body: [ + getSchemaTpl('optionControlV2'), + { + type: 'ae-switch-more', + mode: 'normal', + label: '自定义显示模板', + bulk: false, + name: 'itemSchema', + formType: 'extend', + form: { + body: [ + { + type: 'dropdown-button', + label: '配置显示模板', + level: 'enhance', + buttons: [ + { + type: 'button', + block: true, + onClick: this.editDetail.bind( + this, + context.id, + 'itemSchema' + ), + label: '配置默认态模板' + }, + { + type: 'button', + block: true, + onClick: this.editDetail.bind( + this, + context.id, + 'activeItemSchema' + ), + label: '配置激活态模板' + } + ] + } + ] + }, + pipeIn: (value: any) => { + return value !== undefined; + }, + pipeOut: (value: any, originValue: any, data: any) => { + if (value === true) { + return { + type: 'container', + body: [ + { + type: 'tpl', + tpl: `\${${this.getDisplayField(value)}}`, + wrapperComponent: '', + inline: true + } + ] + }; + } + return value ? value : undefined; + } + } + ] }, status: {} }, @@ -184,6 +268,67 @@ export class ListControlPlugin extends BasePlugin { return dataSchema; } + + filterProps(props: any) { + // 禁止选中子节点 + return JSONPipeOut(props); + } + + getDisplayField(data: any) { + if ( + data.source || + (data.map && + Array.isArray(data.map) && + data.map[0] && + Object.keys(data.map[0]).length > 1) + ) { + return data.labelField ?? 'label'; + } + return 'label'; + } + + editDetail(id: string, field: string) { + const manager = this.manager; + const store = manager.store; + const node = store.getNodeById(id); + const value = store.getValueOf(id); + let defaultItemSchema = { + type: 'container', + body: [ + { + type: 'tpl', + tpl: `\${${this.getDisplayField(value)}}`, + inline: true, + wrapperComponent: '' + } + ] + }; + + // 首次编辑激活态样式时自动复制默认态 + if (field !== 'itemSchema' && value?.itemSchema) { + defaultItemSchema = JSONPipeIn(value.itemSchema, true); + } + + node && + value && + this.manager.openSubEditor({ + title: '配置显示模板', + value: value[field] ?? defaultItemSchema, + slot: { + type: 'container', + body: '$$' + }, + onChange: (newValue: any) => { + newValue = {...value, [field]: schemaArrayFormat(newValue)}; + manager.panelChangeValue(newValue, diff(value, newValue)); + }, + data: { + [value.labelField || 'label']: '选项名', + [value.valueField || 'value']: '选项值', + item: '假数据' + } + }); + } } registerEditorPlugin(ListControlPlugin); diff --git a/packages/amis-editor/src/plugin/SwitchContainer.tsx b/packages/amis-editor/src/plugin/SwitchContainer.tsx new file mode 100644 index 000000000..05dd08adc --- /dev/null +++ b/packages/amis-editor/src/plugin/SwitchContainer.tsx @@ -0,0 +1,480 @@ +import { + BaseEventContext, + LayoutBasePlugin, + RegionConfig, + registerEditorPlugin, + getSchemaTpl, + RendererPluginEvent, + VRendererConfig, + VRenderer, + RendererInfo, + BasicToolbarItem +} from 'amis-editor-core'; +import {RegionWrapper as Region} from 'amis-editor-core'; +import {getEventControlConfig} from '../renderer/event-control'; +import React from 'react'; + +export class SwitchContainerPlugin extends LayoutBasePlugin { + static id = 'SwitchContainerPlugin'; + static scene = ['layout']; + // 关联渲染器名字 + rendererName = 'switch-container'; + $schema = '/schemas/SwitchContainerSchema.json'; + + // 组件名称 + name = '状态容器'; + isBaseComponent = true; + description = '根据状态进行组件条件渲染的容器,方便设计多状态组件'; + tags = ['布局容器']; + order = -2; + icon = 'fa fa-square-o'; + pluginIcon = 'switch-container-plugin'; + scaffold = { + type: 'switch-container', + items: [ + { + title: '状态一', + body: [ + { + type: 'tpl', + tpl: '状态一内容', + wrapperComponent: '' + } + ] + }, + { + title: '状态二', + body: [ + { + type: 'tpl', + tpl: '状态二内容', + wrapperComponent: '' + } + ] + } + ], + style: { + position: 'static', + display: 'block' + } + }; + previewSchema = { + ...this.scaffold + }; + + regions: Array = [ + { + key: 'body', + label: '内容区' + } + ]; + + panelTitle = '状态容器'; + + panelJustify = true; + + vRendererConfig: VRendererConfig = { + regions: { + body: { + key: 'body', + label: '内容区', + placeholder: '状态', + wrapperResolve: (dom: HTMLElement) => dom + } + }, + panelTitle: '状态', + panelJustify: true, + panelBodyCreator: (context: BaseEventContext) => { + return getSchemaTpl('tabs', [ + { + title: '属性', + body: getSchemaTpl('collapseGroup', [ + { + title: '基础', + body: [ + { + name: 'title', + label: '状态名称', + type: 'input-text', + required: true + }, + getSchemaTpl('expressionFormulaControl', { + evalMode: false, + label: '状态条件', + name: 'visibleOn', + placeholder: '\\${xxx}' + }) + ] + } + ]) + } + ]); + } + }; + + wrapperProps = { + unmountOnExit: true, + mountOnEnter: true + }; + + stateWrapperResolve = (dom: HTMLElement) => dom; + overrides = { + renderBody(this: any, item: any) { + const dom = this.super(item); + const info: RendererInfo = this.props.$$editor; + const items = this.props.items || []; + const index = items.findIndex((cur: any) => cur.$$id === item.$$id); + + if (!info || !info.plugin) { + return dom; + } + + const plugin: SwitchContainerPlugin = info.plugin as any; + const id = item.$$id; + const region = plugin.vRendererConfig?.regions?.body; + + return ( + + {region ? ( + + ) : ( + dom + )} + + ); + } + }; + + /** + * 补充切换的 toolbar + * @param context + * @param toolbars + */ + buildEditorToolbar( + context: BaseEventContext, + toolbars: Array + ) { + if ( + context.info.plugin === this && + context.info.renderer.name === 'switch-container' && + !context.info.hostId + ) { + const node = context.node; + toolbars.unshift({ + icon: 'fa fa-chevron-right', + tooltip: '下个状态', + onClick: () => { + const control = node.getComponent(); + if (control?.switchTo) { + let index = + control.state.activeIndex < 0 ? 0 : control.state.activeIndex; + control.switchTo(index + 1); + } + } + }); + + toolbars.unshift({ + icon: 'fa fa-chevron-left', + tooltip: '上个状态', + onClick: () => { + const control = node.getComponent(); + if (control?.switchTo) { + let index = control.state.activeIndex; + control.switchTo(index - 1); + } + } + }); + } + } + + // 事件定义 + events: RendererPluginEvent[] = [ + { + eventName: 'click', + eventLabel: '点击', + description: '点击时触发', + dataSchema: [ + { + type: 'object', + properties: { + context: { + type: 'object', + title: '上下文', + properties: { + nativeEvent: { + type: 'object', + title: '鼠标事件对象' + } + } + } + } + } + ] + }, + { + eventName: 'mouseenter', + eventLabel: '鼠标移入', + description: '鼠标移入时触发', + dataSchema: [ + { + type: 'object', + properties: { + context: { + type: 'object', + title: '上下文', + properties: { + nativeEvent: { + type: 'object', + title: '鼠标事件对象' + } + } + } + } + } + ] + }, + { + eventName: 'mouseleave', + eventLabel: '鼠标移出', + description: '鼠标移出时触发', + dataSchema: [ + { + type: 'object', + properties: { + context: { + type: 'object', + title: '上下文', + properties: { + nativeEvent: { + type: 'object', + title: '鼠标事件对象' + } + } + } + } + } + ] + } + ]; + + panelBodyCreator = (context: BaseEventContext) => { + const curRendererSchema = context?.schema; + const isFreeContainer = curRendererSchema?.isFreeContainer || false; + const isFlexItem = this.manager?.isFlexItem(context?.id); + const isFlexColumnItem = this.manager?.isFlexColumnItem(context?.id); + + const displayTpl = [ + getSchemaTpl('layout:display'), + + getSchemaTpl('layout:flex-setting', { + visibleOn: + 'data.style && (data.style.display === "flex" || data.style.display === "inline-flex")', + direction: curRendererSchema.direction, + justify: curRendererSchema.justify, + alignItems: curRendererSchema.alignItems + }), + + getSchemaTpl('layout:flex-wrap', { + visibleOn: + 'data.style && (data.style.display === "flex" || data.style.display === "inline-flex")' + }) + ]; + + return getSchemaTpl('tabs', [ + { + title: '属性', + body: getSchemaTpl('collapseGroup', [ + { + title: '基本', + body: [ + { + type: 'ae-listItemControl', + mode: 'normal', + name: 'items', + label: '状态列表', + addTip: '新增组件状态', + items: [ + { + type: 'input-text', + placeholder: '请输入显示文本', + label: '状态名称', + mode: 'horizontal', + name: 'title' + }, + getSchemaTpl('expressionFormulaControl', { + name: 'visibleOn', + mode: 'horizontal', + label: '显示条件' + }) + ], + scaffold: { + title: '状态', + body: [ + { + type: 'tpl', + tpl: '状态内容', + wrapperComponent: '', + inline: false + } + ] + } + } + ] + }, + getSchemaTpl('status') + ]) + }, + { + title: '外观', + className: 'p-none', + body: getSchemaTpl('collapseGroup', [ + { + title: '布局', + body: [ + getSchemaTpl('layout:originPosition'), + getSchemaTpl('layout:inset', { + mode: 'vertical' + }), + + // 自由容器不需要 display 相关配置项 + ...(!isFreeContainer ? displayTpl : []), + + ...(isFlexItem + ? [ + getSchemaTpl('layout:flex', { + isFlexColumnItem, + label: isFlexColumnItem ? '高度设置' : '宽度设置', + visibleOn: + 'data.style && (data.style.position === "static" || data.style.position === "relative")' + }), + getSchemaTpl('layout:flex-grow', { + visibleOn: + 'data.style && data.style.flex === "1 1 auto" && (data.style.position === "static" || data.style.position === "relative")' + }), + getSchemaTpl('layout:flex-basis', { + label: isFlexColumnItem ? '弹性高度' : '弹性宽度', + visibleOn: + 'data.style && (data.style.position === "static" || data.style.position === "relative") && data.style.flex === "1 1 auto"' + }), + getSchemaTpl('layout:flex-basis', { + label: isFlexColumnItem ? '固定高度' : '固定宽度', + visibleOn: + 'data.style && (data.style.position === "static" || data.style.position === "relative") && data.style.flex === "0 0 150px"' + }) + ] + : []), + + getSchemaTpl('layout:overflow-x', { + visibleOn: `${ + isFlexItem && !isFlexColumnItem + } && data.style.flex === '0 0 150px'` + }), + + getSchemaTpl('layout:isFixedHeight', { + visibleOn: `${!isFlexItem || !isFlexColumnItem}`, + onChange: (value: boolean) => { + context?.node.setHeightMutable(value); + } + }), + getSchemaTpl('layout:height', { + visibleOn: `${!isFlexItem || !isFlexColumnItem}` + }), + getSchemaTpl('layout:max-height', { + visibleOn: `${!isFlexItem || !isFlexColumnItem}` + }), + getSchemaTpl('layout:min-height', { + visibleOn: `${!isFlexItem || !isFlexColumnItem}` + }), + getSchemaTpl('layout:overflow-y', { + visibleOn: `${ + !isFlexItem || !isFlexColumnItem + } && (data.isFixedHeight || data.style && data.style.maxHeight) || (${ + isFlexItem && isFlexColumnItem + } && data.style.flex === '0 0 150px')` + }), + + getSchemaTpl('layout:isFixedWidth', { + visibleOn: `${!isFlexItem || isFlexColumnItem}`, + onChange: (value: boolean) => { + context?.node.setWidthMutable(value); + } + }), + getSchemaTpl('layout:width', { + visibleOn: `${!isFlexItem || isFlexColumnItem}` + }), + getSchemaTpl('layout:max-width', { + visibleOn: `${!isFlexItem || isFlexColumnItem}` + }), + getSchemaTpl('layout:min-width', { + visibleOn: `${!isFlexItem || isFlexColumnItem}` + }), + + getSchemaTpl('layout:overflow-x', { + visibleOn: `${ + !isFlexItem || isFlexColumnItem + } && (data.isFixedWidth || data.style && data.style.maxWidth)` + }), + + !isFlexItem ? getSchemaTpl('layout:margin-center') : null, + !isFlexItem && !isFreeContainer + ? getSchemaTpl('layout:textAlign', { + name: 'style.textAlign', + label: '内部对齐方式', + visibleOn: + 'data.style && data.style.display !== "flex" && data.style.display !== "inline-flex"' + }) + : null, + getSchemaTpl('layout:z-index'), + getSchemaTpl('layout:sticky', { + visibleOn: + 'data.style && (data.style.position !== "fixed" && data.style.position !== "absolute")' + }), + getSchemaTpl('layout:stickyPosition') + ] + }, + ...getSchemaTpl('theme:common', {exclude: ['layout']}) + ]) + }, + { + title: '事件', + className: 'p-none', + body: [ + getSchemaTpl('eventControl', { + name: 'onEvent', + ...getEventControlConfig(this.manager, context) + }) + ] + } + ]); + }; +} + +registerEditorPlugin(SwitchContainerPlugin); diff --git a/packages/amis-editor/src/plugin/index.ts b/packages/amis-editor/src/plugin/index.ts index e5b62cbbd..7001b5b6e 100644 --- a/packages/amis-editor/src/plugin/index.ts +++ b/packages/amis-editor/src/plugin/index.ts @@ -9,6 +9,7 @@ export * from './Layout/Layout_fixed'; // 悬浮容器 export * from './CollapseGroup'; // 折叠面板 export * from './Panel'; // 面板 export * from './Tabs'; // 选项卡 +export * from './SwitchContainer'; // 状态容器 // 数据容器 export * from './CRUD'; // 增删改查 diff --git a/packages/amis-editor/src/renderer/ListItemControl.tsx b/packages/amis-editor/src/renderer/ListItemControl.tsx new file mode 100644 index 000000000..4754bf48c --- /dev/null +++ b/packages/amis-editor/src/renderer/ListItemControl.tsx @@ -0,0 +1,393 @@ +/** + * @file 通用数组列表项的可视化编辑控件 + */ + +import React from 'react'; +import {findDOMNode} from 'react-dom'; +import cx from 'classnames'; +import get from 'lodash/get'; +import Sortable from 'sortablejs'; +import {FormItem, Button, Icon, render as amisRender} from 'amis'; +import {autobind} from 'amis-editor-core'; +import type {Option} from 'amis'; +import {createObject, FormControlProps} from 'amis-core'; +import type {SchemaApi} from 'amis'; +import type {PlainObject} from './style-control/types'; + +export type valueType = 'text' | 'boolean' | 'number'; + +export interface PopoverForm { + optionLabel: string; + optionValue: any; + optionValueType: valueType; +} + +export interface OptionControlProps extends FormControlProps { + className?: string; +} + +export type SourceType = 'custom' | 'api' | 'apicenter' | 'variable'; + +export interface OptionControlState { + items: Array; + api: SchemaApi; + labelField: string; + valueField: string; +} + +export default class ListItemControl extends React.Component< + OptionControlProps, + OptionControlState +> { + sortable?: Sortable; + drag?: HTMLElement | null; + target: HTMLElement | null; + + internalProps = ['checked', 'editing']; + + constructor(props: OptionControlProps) { + super(props); + + this.state = { + items: this.transformOptions(props), + api: props.data.source, + labelField: props.data.labelField || 'title', + valueField: props.data.valueField + }; + } + + /** + * 数据更新 + */ + componentWillReceiveProps(nextProps: OptionControlProps) { + const items = get(nextProps, 'items') + ? this.transformOptions(nextProps) + : []; + if ( + JSON.stringify( + this.state.items.map(item => ({ + ...item, + editing: undefined + })) + ) !== JSON.stringify(items) + ) { + this.setState({ + items + }); + } + } + + /** + * 处理填入输入框的值 + */ + transformOptionValue(value: any) { + return typeof value === 'undefined' || value === null + ? '' + : typeof value === 'string' + ? value + : JSON.stringify(value); + } + + transformOptions(props: OptionControlProps) { + const {data: ctx, value: options} = props; + + return Array.isArray(options) + ? options.map((item: Option) => ({ + ...item, + ...(item.hidden !== undefined ? {hidden: item.hidden} : {}), + ...(item.hiddenOn !== undefined ? {hiddenOn: item.hiddenOn} : {}) + })) + : []; + } + + /** + * 更新options字段的统一出口 + */ + onChange() { + const {onChange} = this.props; + onChange(this.state.items); + return; + } + + @autobind + targetRef(ref: any) { + this.target = ref ? (findDOMNode(ref) as HTMLElement) : null; + } + + @autobind + dragRef(ref: any) { + if (!this.drag && ref) { + this.initDragging(); + } else if (this.drag && !ref) { + this.destroyDragging(); + } + + this.drag = ref; + } + + initDragging() { + const dom = findDOMNode(this) as HTMLElement; + + this.sortable = new Sortable( + dom.querySelector('.ae-OptionControl-content') as HTMLElement, + { + group: 'OptionControlGroup', + animation: 150, + handle: '.ae-OptionControlItem-dragBar', + ghostClass: 'ae-OptionControlItem--dragging', + onEnd: (e: any) => { + // 没有移动 + if (e.newIndex === e.oldIndex) { + return; + } + + // 换回来 + const parent = e.to as HTMLElement; + if ( + e.newIndex < e.oldIndex && + e.oldIndex < parent.childNodes.length - 1 + ) { + parent.insertBefore(e.item, parent.childNodes[e.oldIndex + 1]); + } else if (e.oldIndex < parent.childNodes.length - 1) { + parent.insertBefore(e.item, parent.childNodes[e.oldIndex]); + } else { + parent.appendChild(e.item); + } + + const items = this.state.items.concat(); + + items[e.oldIndex] = items.splice(e.newIndex, 1, items[e.oldIndex])[0]; + + this.setState({items}, () => this.onChange()); + } + } + ); + } + + destroyDragging() { + this.sortable && this.sortable.destroy(); + } + + /** + * 删除选项 + */ + handleDelete(index: number) { + const items = this.state.items.concat(); + + items.splice(index, 1); + this.setState({items}, () => this.onChange()); + } + + /** + * 编辑选项 + */ + toggleEdit(index: number) { + const {items} = this.state; + items[index].editing = !items[index].editing; + this.setState({items}); + } + + editItem(item: PlainObject, index: number) { + const items = this.state.items.concat(); + if (items[index]) { + items[index] = item; + } + this.setState({items}, () => this.onChange()); + } + + @autobind + handleEditLabel(index: number, value: string) { + const items = this.state.items.concat(); + items.splice(index, 1, {...items[index], [this.state.labelField]: value}); + this.setState({items}, () => this.onChange()); + } + + @autobind + handleAdd() { + const scaffold = this.props.scaffold; + const {labelField} = this.state; + const items = this.state.items.slice(); + items.push( + scaffold + ? scaffold + : { + [labelField]: '新状态', + body: {} + } + ); + this.setState({items}, () => { + this.onChange(); + }); + } + + handleValueChange(index: number, value: string) { + const items = this.state.items.concat(); + items[index].value = value; + this.setState({items}, () => this.onChange()); + } + + renderHeader() { + const {render, label, labelRemark, useMobileUI, env, popOverContainer} = + this.props; + const classPrefix = env?.theme?.classPrefix; + + return ( +
+ +
+ ); + } + + renderOption(props: any) { + const {index, editing} = props; + const {render, data: ctx, items = []} = this.props; + const label = this.transformOptionValue(props[this.state.labelField]); + + const editDom = editing ? ( +
+ {render( + 'item', + { + type: 'form', + title: null, + className: 'ae-ExtendMore right mb-2 border-none', + wrapWithPanel: false, + labelAlign: 'left', + horizontal: { + left: 4, + right: 8 + }, + body: [ + { + type: 'button', + className: 'ae-OptionControlItem-closeBtn', + label: '×', + level: 'link', + onClick: () => this.toggleEdit(index) + }, + ...items + ], + onChange: (model: any) => { + this.editItem(model, index); + } + }, + {data: createObject(ctx, props)} + )} +
+ ) : null; + + const operationBtn = [ + { + type: 'button', + className: 'ae-OptionControlItem-action', + label: '编辑', + onClick: () => this.toggleEdit(index) + }, + { + type: 'button', + className: 'ae-OptionControlItem-action', + label: '删除', + onClick: () => this.handleDelete(index) + } + ]; + + const labelField = this.state.labelField; + + return ( +
  • +
    + + + + {amisRender( + { + type: 'input-text', + name: labelField, + className: 'ae-OptionControlItem-input', + value: label, + placeholder: '状态名称', + clearable: false, + onChange: (value: string) => { + this.handleEditLabel(index, value); + } + }, + { + data: { + [labelField]: label + } + } + )} + {render( + 'dropdown', + { + type: 'dropdown-button', + className: 'ae-OptionControlItem-dropdown', + btnClassName: 'px-2', + icon: 'fa fa-ellipsis-h', + hideCaret: true, + closeOnClick: true, + align: 'right', + menuClassName: 'ae-OptionControlItem-ulmenu', + buttons: operationBtn + }, + { + popOverContainer: null // amis 渲染挂载节点会使用 this.target + } + )} +
    + {editDom} +
  • + ); + } + + render() { + const {items} = this.state; + const {className, addTip, placeholder} = this.props; + + return ( +
    + {this.renderHeader()} + +
    + {Array.isArray(items) && items.length ? ( +
      + {items.map((item, index) => this.renderOption({...item, index}))} +
    + ) : ( +
    + {placeholder || '无数据'} +
    + )} +
    + +
    +
    +
    + ); + } +} + +@FormItem({ + type: 'ae-listItemControl', + renderLabel: false +}) +export class ListItemControlRenderer extends ListItemControl {} diff --git a/packages/amis-ui/scss/components/form/_list.scss b/packages/amis-ui/scss/components/form/_list.scss index 25c248c2b..3cc47a3d6 100644 --- a/packages/amis-ui/scss/components/form/_list.scss +++ b/packages/amis-ui/scss/components/form/_list.scss @@ -206,5 +206,20 @@ var(--listSelect-base-disabled-bottom-left-border-radius); background: var(--listSelect-base-disabled-bg-color); } + + &.is-custom { + border: none; + padding: 0; + + &:hover, + &.is-active { + border: none; + } + + &::before, + &::after { + display: none; + } + } } } diff --git a/packages/amis/__tests__/renderers/Form/listSelect.test.tsx b/packages/amis/__tests__/renderers/Form/listSelect.test.tsx index a57a581f4..d3cccb2da 100644 --- a/packages/amis/__tests__/renderers/Form/listSelect.test.tsx +++ b/packages/amis/__tests__/renderers/Form/listSelect.test.tsx @@ -136,3 +136,77 @@ test('Renderer:listSelect with image option & listClassName', async () => { 'items-wrapper' ); }); + +test('Renderer:listSelect with custom list style', async () => { + const {container, getByText} = render( + amisRender({ + type: 'form', + body: { + type: 'list-select', + name: 'select', + label: '单选', + listClassName: 'items-wrapper', + value: 'b', + options: [ + { + label: 'OptionA', + value: 'a', + image: + 'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3893101144,2877209892&fm=23&gp=0.jpg' + }, + { + label: 'OptionB', + value: 'b', + image: + 'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3893101144,2877209892&fm=23&gp=0.jpg' + } + ], + itemSchema: { + type: 'container', + className: 'item-default', + body: [ + { + type: 'tpl', + tpl: '${label}', + inline: true, + wrapperComponent: '' + } + ], + style: { + position: 'static', + display: 'block' + }, + themeCss: { + baseControlClassName: { + 'background:default': '#e06d6d' + } + } + }, + activeItemSchema: { + type: 'container', + className: 'item-active', + body: [ + { + type: 'tpl', + tpl: '${label}', + inline: true, + wrapperComponent: '' + } + ], + style: { + position: 'static', + display: 'block' + }, + themeCss: { + baseControlClassName: { + 'background:default': '#38f9d4' + } + } + } + } + }) + ); + + expect(container.querySelector('.item-default')).toBeInTheDocument(); + expect(container.querySelector('.item-active')).toBeInTheDocument(); +}); diff --git a/packages/amis/src/Schema.ts b/packages/amis/src/Schema.ts index cae582e0f..19624e811 100644 --- a/packages/amis/src/Schema.ts +++ b/packages/amis/src/Schema.ts @@ -16,6 +16,7 @@ import {CollapseSchema} from './renderers/Collapse'; import {CollapseGroupSchema} from './renderers/CollapseGroup'; import {ColorSchema} from './renderers/Color'; import {ContainerSchema} from './renderers/Container'; +import {SwitchContainerSchema} from './renderers/SwitchContainer'; import {CRUDSchema} from './renderers/CRUD'; import {CRUD2Schema} from './renderers/CRUD2'; import {DateSchema} from './renderers/Date'; @@ -238,6 +239,7 @@ export type SchemaType = | 'combo' | 'condition-builder' | 'container' + | 'switch-container' | 'input-date' | 'input-datetime' | 'input-time' @@ -380,6 +382,7 @@ export type SchemaObject = | CollapseGroupSchema | ColorSchema | ContainerSchema + | SwitchContainerSchema | CRUDSchema | CRUD2Schema | DateSchema diff --git a/packages/amis/src/index.tsx b/packages/amis/src/index.tsx index e43aefeb3..4b8d4900d 100644 --- a/packages/amis/src/index.tsx +++ b/packages/amis/src/index.tsx @@ -121,6 +121,7 @@ import './renderers/Link'; import './renderers/Wizard'; import './renderers/Chart'; import './renderers/Container'; +import './renderers/SwitchContainer'; import './renderers/SearchBox'; import './renderers/Service'; import './renderers/SparkLine'; diff --git a/packages/amis/src/renderers/Form/ListSelect.tsx b/packages/amis/src/renderers/Form/ListSelect.tsx index 391a6748a..fe3f1edb4 100644 --- a/packages/amis/src/renderers/Form/ListSelect.tsx +++ b/packages/amis/src/renderers/Form/ListSelect.tsx @@ -36,6 +36,11 @@ export interface ListControlSchema extends FormOptionsSchema { */ itemSchema?: SchemaCollection; + /** + * 激活态自定义展示模板。 + */ + activeItemSchema?: SchemaCollection; + /** * 支持配置 list div 的 css 类名。 * 比如:flex justify-between @@ -171,6 +176,7 @@ export default class ListControl extends React.Component { imageClassName, submitOnDBClick, itemSchema, + activeItemSchema, data, labelField, listClassName, @@ -187,7 +193,8 @@ export default class ListControl extends React.Component { key={key} className={cx(`ListControl-item`, itemClassName, { 'is-active': ~selectedOptions.indexOf(option), - 'is-disabled': option.disabled || disabled + 'is-disabled': option.disabled || disabled, + 'is-custom': !!itemSchema })} onClick={this.handleClick.bind(this, option)} onDoubleClick={ @@ -197,9 +204,15 @@ export default class ListControl extends React.Component { } > {itemSchema - ? render(`${key}/body`, itemSchema, { - data: createObject(data, option) - }) + ? render( + `${key}/body`, + ~selectedOptions.indexOf(option) + ? activeItemSchema ?? itemSchema + : itemSchema, + { + data: createObject(data, option) + } + ) : option.body ? render(`${key}/body`, option.body) : [ diff --git a/packages/amis/src/renderers/SwitchContainer.tsx b/packages/amis/src/renderers/SwitchContainer.tsx new file mode 100644 index 000000000..62d8f598a --- /dev/null +++ b/packages/amis/src/renderers/SwitchContainer.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { + Renderer, + RendererProps, + autobind, + buildStyle, + CustomStyle, + isVisible, + setThemeClassName +} from 'amis-core'; +import {DndContainer as DndWrapper} from 'amis-ui'; +import {BaseSchema, SchemaCollection} from '../Schema'; +import {JSONSchema} from '../types'; + +export interface StateSchema extends Omit { + /** + * 状态标题 + */ + title?: string; + + /** + * 内容 + */ + body?: SchemaCollection; + + /** + * 显示条件 + */ + visibleOn?: string; +} + +/** + * SwitchContainer 状态容器渲染器。 + * 文档:https://aisuda.bce.baidu.com/amis/zh-CN/components/state-container + */ +export interface SwitchContainerSchema extends BaseSchema { + /** + * 指定为 container 类型 + */ + type: 'switch-container'; + + /** + * 状态项列表 + */ + items: Array; + + /** + * 自定义样式 + */ + style?: { + [propName: string]: any; + }; +} + +export interface SwitchContainerProps + extends RendererProps, + Omit { + children?: (props: any) => React.ReactNode; +} + +export interface SwtichContainerState { + activeIndex: number; +} + +export default class SwitchContainer extends React.Component< + SwitchContainerProps, + SwtichContainerState +> { + static propsList: Array = ['body', 'className']; + static defaultProps = { + className: '' + }; + + constructor(props: SwitchContainerProps) { + super(props); + this.state = { + activeIndex: -1 + }; + } + + componentDidUpdate(preProps: SwitchContainerProps) { + const items = this.props.items || []; + if (this.state.activeIndex >= 0 && !items[this.state.activeIndex]) { + this.setState({ + activeIndex: 0 + }); + } + } + + @autobind + handleClick(e: React.MouseEvent) { + const {dispatchEvent, data} = this.props; + dispatchEvent(e, data); + } + + @autobind + handleMouseEnter(e: React.MouseEvent) { + const {dispatchEvent, data} = this.props; + dispatchEvent(e, data); + } + + @autobind + handleMouseLeave(e: React.MouseEvent) { + const {dispatchEvent, data} = this.props; + dispatchEvent(e, data); + } + + @autobind + renderBody(item: JSONSchema): JSX.Element | null { + const {children, render, disabled} = this.props; + const body = item?.body; + + const containerBody = children + ? typeof children === 'function' + ? ((children as any)(this.props) as JSX.Element) + : (children as any) + : body + ? (render('body', body as any, {disabled}) as JSX.Element) + : null; + + return
    {containerBody}
    ; + } + + @autobind + switchTo(index: number) { + const items = this.props.items || []; + if (index >= 0 && index < items.length) { + this.setState({activeIndex: index}); + } + } + + render() { + const { + className, + items = [], + classnames: cx, + style, + data, + id, + wrapperCustomStyle, + env, + themeCss + } = this.props; + + const activeItem = + items[this.state.activeIndex] ?? + items.find((item: JSONSchema) => isVisible(item, data)); + + const contentDom = ( +
    + {activeItem && this.renderBody(activeItem)} + + +
    + ); + + return contentDom; + } +} + +@Renderer({ + type: 'switch-container' +}) +export class SwitchContainerRenderer extends SwitchContainer {}