feat: 列表选择可视化 & 状态容器支持 (#8408)

* feat: 列表选择组件支持自定义样式设计

* feat: 添加多状态容器
This commit is contained in:
张涛 2023-10-18 10:42:19 +08:00 committed by GitHub
parent 4125f16cf7
commit c96f9be821
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1597 additions and 13 deletions

View File

@ -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}"
}
}
]
}
}
}
```

View File

@ -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);

View File

@ -34,6 +34,11 @@
}
}
.cxd-DropDown,
.cxd-DropDown > .cxd-Button {
width: 100%;
}
.action-btn {
padding: 0;
}

View File

@ -1335,6 +1335,7 @@
}
}
[data-renderer='switch-container'],
[data-renderer='container'] {
min-height: 0;
}

View File

@ -304,6 +304,7 @@ export interface RendererInfo extends RendererScaffoldInfo {
sharedContext?: Record<string, any>;
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
};
}
}

View File

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

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="20" height="20">
<path d="M0 265.142857v713.142857h1024v-713.142857H0z m987.428571 676.571429H36.571429v-640h950.857142v640zM54.857143 228.571429h914.285714a18.285714 18.285714 0 0 0 0-36.571429H54.857143a18.285714 18.285714 0 0 0 0 36.571429zM109.714286 155.428571h804.571428a18.285714 18.285714 0 0 0 0-36.571428H109.714286a18.285714 18.285714 0 0 0 0 36.571428zM164.571429 82.285714h694.857142a18.285714 18.285714 0 0 0 0-36.571428H164.571429a18.285714 18.285714 0 0 0 0 36.571428z"></path>
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@ -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);

View File

@ -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';

View File

@ -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);

View File

@ -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<RegionConfig> = [
{
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 (
<VRenderer
type={info.type}
plugin={info.plugin}
renderer={info.renderer}
multifactor
key={id}
//$schema="/schemas/ListBodyField.json"
hostId={info.id}
memberIndex={index}
name={`${item.title || `状态${index + 1}`}`}
id={id}
draggable={false}
wrapperResolve={plugin.stateWrapperResolve}
schemaPath={`${info.schemaPath}/items/${index}`}
path={`${this.props.$path}/${index}`}
data={this.props.data}
>
{region ? (
<Region
key={region.key}
preferTag={region.preferTag}
name={region.key}
label={region.label}
regionConfig={region}
placeholder={region.placeholder}
editorStore={plugin.manager.store}
manager={plugin.manager}
children={dom}
wrapperResolve={region.wrapperResolve}
rendererName={info.renderer.name}
/>
) : (
dom
)}
</VRenderer>
);
}
};
/**
* toolbar
* @param context
* @param toolbars
*/
buildEditorToolbar(
context: BaseEventContext,
toolbars: Array<BasicToolbarItem>
) {
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);

View File

@ -9,6 +9,7 @@ export * from './Layout/Layout_fixed'; // 悬浮容器
export * from './CollapseGroup'; // 折叠面板
export * from './Panel'; // 面板
export * from './Tabs'; // 选项卡
export * from './SwitchContainer'; // 状态容器
// 数据容器
export * from './CRUD'; // 增删改查

View File

@ -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<PlainObject>;
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 (
<header className="ae-OptionControl-header">
<label className={cx(`${classPrefix}Form-label`)}>
{label || ''}
{labelRemark
? render('label-remark', {
type: 'remark',
icon: labelRemark.icon || 'warning-mark',
tooltip: labelRemark,
className: cx(`Form-lableRemark`, labelRemark?.className),
useMobileUI,
container: popOverContainer || env.getModalContainer
})
: null}
</label>
</header>
);
}
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 ? (
<div className="ae-OptionControlItem-extendMore">
{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)}
)}
</div>
) : 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 (
<li className="ae-OptionControlItem" key={index}>
<div className="ae-OptionControlItem-Main">
<a className="ae-OptionControlItem-dragBar">
<Icon icon="drag-bar" className="icon" />
</a>
{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
}
)}
</div>
{editDom}
</li>
);
}
render() {
const {items} = this.state;
const {className, addTip, placeholder} = this.props;
return (
<div className={cx('ae-OptionControl', className)}>
{this.renderHeader()}
<div className="ae-OptionControl-wrapper">
{Array.isArray(items) && items.length ? (
<ul className="ae-OptionControl-content" ref={this.dragRef}>
{items.map((item, index) => this.renderOption({...item, index}))}
</ul>
) : (
<div className="ae-OptionControl-placeholder">
{placeholder || '无数据'}
</div>
)}
<div className="ae-OptionControl-footer">
<Button
level="enhance"
onClick={this.handleAdd}
ref={this.targetRef}
className="w-full"
>
{addTip || '添加选项'}
</Button>
</div>
</div>
</div>
);
}
}
@FormItem({
type: 'ae-listItemControl',
renderLabel: false
})
export class ListItemControlRenderer extends ListItemControl {}

View File

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

View File

@ -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();
});

View File

@ -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

View File

@ -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';

View File

@ -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<ListProps, any> {
imageClassName,
submitOnDBClick,
itemSchema,
activeItemSchema,
data,
labelField,
listClassName,
@ -187,7 +193,8 @@ export default class ListControl extends React.Component<ListProps, any> {
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<ListProps, any> {
}
>
{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)
: [

View File

@ -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<BaseSchema, 'type'> {
/**
*
*/
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<StateSchema>;
/**
*
*/
style?: {
[propName: string]: any;
};
}
export interface SwitchContainerProps
extends RendererProps,
Omit<SwitchContainerSchema, 'type' | 'className' | 'style'> {
children?: (props: any) => React.ReactNode;
}
export interface SwtichContainerState {
activeIndex: number;
}
export default class SwitchContainer extends React.Component<
SwitchContainerProps,
SwtichContainerState
> {
static propsList: Array<string> = ['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<any>) {
const {dispatchEvent, data} = this.props;
dispatchEvent(e, data);
}
@autobind
handleMouseEnter(e: React.MouseEvent<any>) {
const {dispatchEvent, data} = this.props;
dispatchEvent(e, data);
}
@autobind
handleMouseLeave(e: React.MouseEvent<any>) {
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 <div style={{display: 'inline'}}>{containerBody}</div>;
}
@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 = (
<div
className={cx(
'SwitchContainer',
className,
setThemeClassName('baseControlClassName', id, themeCss),
setThemeClassName('wrapperCustomStyle', id, wrapperCustomStyle)
)}
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={buildStyle(style, data)}
>
{activeItem && this.renderBody(activeItem)}
<CustomStyle
config={{
wrapperCustomStyle,
id,
themeCss,
classNames: [
{
key: 'baseControlClassName'
}
]
}}
env={env}
/>
</div>
);
return contentDom;
}
}
@Renderer({
type: 'switch-container'
})
export class SwitchContainerRenderer extends SwitchContainer {}