Merge pull request #5585 from lurunze1226/feat-action-data

feat: 扩充setValue动作, 支持beforeSetData
This commit is contained in:
hsm-lv 2023-01-04 09:57:56 +08:00 committed by GitHub
commit 6ae95cce23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 331 additions and 16 deletions

View File

@ -12,7 +12,7 @@ import {
SearchBox,
InputBox
} from 'amis';
import {eachTree, mapTree} from 'amis-core';
import {eachTree} from 'amis-core';
import 'amis-ui/lib/locale/en-US';
import {withRouter} from 'react-router';
// @ts-ignore
@ -553,7 +553,7 @@ export class App extends React.PureComponent<{
renderContent() {
const locale = 'zh-CN'; // 暂时不支持切换,因为目前只有中文文档
const theme = this.state.theme;
const {theme} = this.state;
return (
<React.Suspense

View File

@ -0,0 +1,169 @@
/**
* @file 变量更新示例
*/
import update from 'lodash/update';
import isEqual from 'lodash/isEqual';
import {cloneObject, setVariable} from 'amis-core';
const namespace = 'appVariables';
const initData = JSON.parse(sessionStorage.getItem(namespace)) || {
ProductName: 'BCC',
Banlance: 1234.888,
ProductNum: 10,
isOnline: false,
ProductList: ['BCC', 'BOS', 'VPC'],
PROFILE: {
FirstName: 'Amis',
Age: 18,
Address: {
street: 'ShangDi',
postcode: 100001
}
}
};
export default {
/** schema配置 */
schema: {
type: 'page',
title: '更新变量数据',
body: [
{
type: 'tpl',
tpl: '变量的命名空间通过环境变量设置为了<code>appVariables</code>, 可以通过\\${appVariables.xxx}来取值'
},
{
type: 'container',
style: {
padding: '8px',
marginBottom: '8px',
backgroundColor: '#f5f5f5',
borderRadius: '4px'
},
body: [
{
type: 'tpl',
tpl: '<h2>数据域appVariables</h2>'
},
{
type: 'json',
id: 'u:44521540e64c',
source: '${appVariables}',
levelExpand: 10
},
{
type: 'tpl',
tpl: '<h3>接口中的<code>ProductName (\\${ProductName})</code>: <strong>${ProductName|default:-}</strong></h3>',
inline: false,
id: 'u:98ed5c5534ef'
},
{
type: 'tpl',
tpl: '<h3>变量中的<code>ProductName (\\${appVariables.ProductName})</code>: <strong>${appVariables.ProductName|default:-}</strong></h3>',
inline: false,
id: 'u:98ed5c5534ef'
}
]
},
{
type: 'form',
title: '表单',
debug: true,
body: [
{
label: '产品名称',
type: 'input-text',
name: 'product',
placeholder: '请输入内容, 观察引用变量组件的变化',
id: 'u:d9802fd83145',
onEvent: {
change: {
weight: 0,
actions: [
{
args: {
path: 'appVariables.ProductName',
value: '${event.data.value}'
},
actionType: 'setValue'
}
]
}
}
},
{
type: 'static',
label: '产品名称描述',
id: 'u:7bd4e2a4f95e',
value: '${appVariables.ProductName}',
name: 'staticName'
}
],
id: 'u:dc2580fa447a'
}
],
initApi: '/api/mock2/page/initData2',
onEvent: {
inited: {
weight: 0,
actions: [
{
args: {
path: 'appVariables.ProductName',
value: '${event.data.ProductName}'
},
actionType: 'setValue'
}
]
}
}
},
props: {
data: {[namespace]: JSON.parse(sessionStorage.getItem(namespace))}
},
/** 环境变量 */
env: {
beforeSetData: (renderer, action, event) => {
const value = event?.data?.value ?? action?.args?.value;
const path = action?.args?.path;
const {session = 'global'} = renderer.props?.env ?? {};
const comptList = event?.context?.scoped?.getComponentsByRefPath(
session,
path
);
for (let component of comptList) {
const {$path: targetPath, $schema: targetSchema} = component?.props;
const {$path: triggerPath, $schema: triggerSchema} = renderer?.props;
if (
!component.setData &&
(targetPath === triggerPath || isEqual(targetSchema, triggerSchema))
) {
continue;
}
if (component?.props?.onChange) {
const submitOnChange = !!component.props?.$schema?.submitOnChange;
component.props.onChange(value, submitOnChange, true);
} else if (component?.setData) {
const currentData = JSON.parse(
sessionStorage.getItem(namespace) || JSON.stringify(initData)
);
const varPath = path.replace(/^appVariables\./, '');
update(currentData, varPath, origin => {
return typeof value === typeof origin ? value : origin;
});
sessionStorage.setItem(namespace, JSON.stringify(currentData));
const newCtx = cloneObject(component?.props?.data ?? {});
setVariable(newCtx, path, value, true);
component.setData(newCtx, false);
}
}
}
}
};

View File

@ -102,6 +102,7 @@ import UpdateButtonGroupSelectActionSchema from './EventAction/update-data/Updat
import UpdateComboActionSchema from './EventAction/update-data/UpdateCombo';
import SyncUpdateActionSchema from './EventAction/update-data/SyncUpdate';
import DataAutoFillActionSchema from './EventAction/update-data/DataAutoFill';
import SetVariable from './EventAction/update-data/SetVariable';
import PreventFormActionSchema from './EventAction/prevent-defalut/PreventForm';
import WizardSchema from './Wizard';
import ChartSchema from './Chart';
@ -634,6 +635,16 @@ export const examples = [
label: '数据回填',
path: '/examples/action/setdata/autofill',
component: makeSchemaRenderer(DataAutoFillActionSchema)
},
{
label: '更新全局变量数据',
path: '/examples/action/setdata/variable',
component: makeSchemaRenderer(
SetVariable.schema,
SetVariable.props ?? {},
true,
SetVariable.env
)
}
]
},

View File

@ -6,6 +6,7 @@ import {normalizeLink} from 'amis-core';
import {withRouter} from 'react-router';
import copy from 'copy-to-clipboard';
import {qsparse, parseQuery} from 'amis-core';
import isPlainObject from 'lodash/isPlainObject';
function loadEditor() {
return new Promise(resolve =>
@ -15,7 +16,15 @@ function loadEditor() {
const viewMode = localStorage.getItem('amis-viewMode') || 'pc';
export default function (schema, showCode, envOverrides) {
/**
*
* @param {*} schema schema配置
* @param {*} schemaProps props配置
* @param {*} showCode 是否展示代码
* @param {Object} envOverrides 覆写环境变量
* @returns
*/
export default function (schema, schemaProps, showCode, envOverrides) {
if (!schema['$schema']) {
schema = {
...schema
@ -202,6 +211,7 @@ export default function (schema, showCode, envOverrides) {
{
schema: schema,
props: {
...(isPlainObject(schemaProps) ? schemaProps : {}),
location: this.props.location,
theme: this.props.theme,
locale: this.props.locale
@ -244,6 +254,7 @@ export default function (schema, showCode, envOverrides) {
return render(
schema,
{
...(isPlainObject(schemaProps) ? schemaProps : {}),
location,
theme,
locale

View File

@ -0,0 +1,19 @@
{
"status": 0,
"msg": "success",
"data": {
"ProductName": "BOS",
"Banlance": 1234.888,
"ProductNum": 10,
"isOnline": true,
"ProductList": ["BCC", "CDN", "LSS"],
"PROFILE": {
"FirstName": "Amis",
"Age": 18,
"Address": {
"city": "Beijing",
"postcode": 100000
}
}
}
}

View File

@ -1,9 +1,9 @@
import isPlainObject from 'lodash/isPlainObject';
import React from 'react';
import isPlainObject from 'lodash/isPlainObject';
import {RendererEnv} from './env';
import {RendererProps} from './factory';
import {LocaleContext, TranslateFn} from './locale';
import {RootRenderer, RootRendererProps} from './RootRenderer';
import {RootRenderer} from './RootRenderer';
import {SchemaRenderer} from './SchemaRenderer';
import Scoped from './Scoped';
import {IRendererStore} from './store';
@ -68,9 +68,9 @@ export class Root extends React.Component<RootProps> {
translate,
...rest
} = this.props;
const theme = env.theme;
let themeName = this.props.theme || 'cxd';
if (themeName === 'default') {
themeName = 'cxd';
}
@ -100,7 +100,7 @@ export class Root extends React.Component<RootProps> {
rootStore: rootStore,
resolveDefinitions: this.resolveDefinitions,
location: location,
data: data,
data,
env: env,
classnames: theme.classnames,
classPrefix: theme.classPrefix,

View File

@ -1,5 +1,4 @@
import {observer} from 'mobx-react';
import {getEnv} from 'mobx-state-tree';
import React from 'react';
import type {RootProps} from './Root';
import {IScopedContext, ScopedContext} from './Scoped';

View File

@ -5,7 +5,6 @@ import LazyComponent from './components/LazyComponent';
import {
filterSchema,
loadRenderer,
RendererComponent,
RendererConfig,
RendererEnv,
RendererProps,
@ -18,7 +17,6 @@ import {DebugWrapper} from './utils/debug';
import getExprProperties from './utils/filter-schema';
import {anyChanged, chainEvents, autobind} from './utils/helper';
import {SimpleMap} from './utils/SimpleMap';
import {bindEvent, dispatchEvent, RendererEvent} from './utils/renderer-event';
import {isAlive} from 'mobx-state-tree';
import {reaction} from 'mobx';
@ -81,7 +79,6 @@ export class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
this.renderChild = this.renderChild.bind(this);
this.reRender = this.reRender.bind(this);
this.resolveRenderer(this.props);
this.dispatchEvent = this.dispatchEvent.bind(this);
// 监听topStore更新

View File

@ -5,6 +5,7 @@
import React from 'react';
import find from 'lodash/find';
import values from 'lodash/values';
import hoistNonReactStatic from 'hoist-non-react-statics';
import {dataMapping, registerFunction} from './utils/tpl-builtin';
import {RendererEnv, RendererProps} from './factory';
@ -12,12 +13,14 @@ import {
autobind,
qsstringify,
qsparse,
eachTree,
findTree,
TreeItem,
parseQuery,
getVariable
} from './utils/helper';
import {RendererData, ActionObject} from './types';
import {isPureVariable} from './utils/isPureVariable';
export interface ScopedComponentType extends React.Component<RendererProps> {
focus?: () => void;
@ -33,6 +36,11 @@ export interface ScopedComponentType extends React.Component<RendererProps> {
ctx?: RendererData
) => void;
context: any;
setData?: (
value?: string | {[key: string]: string},
replace?: boolean,
index?: number
) => void;
}
export interface IScopedContext {
@ -127,6 +135,75 @@ function createScopedTools(
return component;
},
/**
*
* ${xxx}
*
* @param session store的session,
* @param path ,
*/
getComponentsByRefPath(
session: string,
path: string
): ScopedComponentType[] {
if (!path || typeof path !== 'string') {
return [];
}
const cmptMaps: Record<string, ScopedComponentType> = {};
let root: AliasIScopedContext = this;
while (root.parent) {
root = root.parent;
}
eachTree([root], (item: TreeItem) => {
const scopedCmptList: ScopedComponentType[] =
item.getComponents() || [];
if (Array.isArray(scopedCmptList)) {
for (const cmpt of scopedCmptList) {
const pathKey = cmpt?.props?.$path ?? 'unknown';
const schema = cmpt?.props?.$schema ?? {};
const cmptSession = cmpt?.props.env?.session ?? 'global';
/** 仅查找当前session的组件 */
if (cmptMaps[pathKey] || session !== cmptSession) {
continue;
}
/** 非Scoped组件, 查找其所属的父容器 */
if (cmpt?.setData && typeof cmpt.setData === 'function') {
cmptMaps[pathKey] = cmpt;
continue;
}
/** 查找Scoped组件中的引用 */
for (const key of Object.keys(schema)) {
const expression = schema[key];
if (
typeof expression === 'string' &&
isPureVariable(expression)
) {
/** 考虑到数据映射函数的情况,将宿主变量提取出来 */
const host = expression
.substring(2, expression.length - 1)
.split('|')[0];
if (host && host === path) {
cmptMaps[pathKey] = cmpt;
break;
}
}
}
}
}
});
return values(cmptMaps);
},
getComponents() {
return components.concat();
},

View File

@ -21,7 +21,7 @@ export enum LoopStatus {
export interface ListenerAction {
actionType: string; // 动作类型 逻辑动作|自定义(脚本支撑)|reload|url|ajax|dialog|drawer 其他扩充的组件动作
description?: string; // 事件描述actionType: broadcast
componentId?: string; // 组件ID用于直接执行指定组件的动作
componentId?: string; // 组件ID用于直接执行指定组件的动作,指定多个组件时使用英文逗号分隔
args?: Record<string, any>; // 动作配置,可以配置数据映射
data?: Record<string, any> | null; // 动作数据参数,可以配置数据映射
dataMergeMode?: 'merge' | 'override'; // 参数模式,合并或者覆盖

View File

@ -19,6 +19,8 @@ export interface ICmptAction extends ListenerAction {
| 'usability'
| 'reload';
args: {
/** actionType为setValue时目标变量的path */
path?: string;
value?: string | {[key: string]: string};
index?: number; // setValue支持更新指定索引的数据一般用于数组类型
};
@ -69,8 +71,24 @@ export class CmptAction implements RendererAction {
return renderer.props.topStore.setDisable(action.componentId, usability);
}
// 数据更新
if (action.actionType === 'setValue') {
const beforeSetData = renderer?.props?.env?.beforeSetData;
const path = action.args?.path;
/** 如果args中携带path参数, 则认为是全局变量赋值, 否则认为是组件变量赋值 */
if (
path &&
typeof path === 'string' &&
beforeSetData &&
typeof beforeSetData === 'function'
) {
const res = await beforeSetData(renderer, action, event);
if (res === false) {
return;
}
}
if (component?.setData) {
return component?.setData(
action.args?.value,

View File

@ -15,8 +15,11 @@ import {
ToastLevel
} from './types';
import hoistNonReactStatic from 'hoist-non-react-statics';
import {IScopedContext} from './Scoped';
import {RendererEvent} from './utils/renderer-event';
import type {IScopedContext} from './Scoped';
import type {RendererEvent} from './utils/renderer-event';
import type {ListenerContext} from './actions/Action';
import type {ICmptAction} from './actions/CmptAction';
export interface wsObject {
url: string;
@ -25,6 +28,7 @@ export interface wsObject {
}
export interface RendererEnv {
session?: string;
fetcher: (api: Api, data?: any, options?: object) => Promise<Payload>;
isCancel: (val: any) => boolean;
wsFetcher: (
@ -102,14 +106,23 @@ export interface RendererEnv {
* URL
*/
replaceText?: {[propName: string]: any};
/**
* fangs
* flags
*/
replaceTextIgnoreKeys?: String[];
/**
* url参数
*/
parseLocation?: (location: any) => Object;
/** 数据更新前触发的Hook */
beforeSetData?: (
renderer: ListenerContext,
action: ICmptAction,
event: RendererEvent<any, any>
) => Promise<void | boolean>;
}
export const EnvContext = React.createContext<RendererEnv | void>(undefined);

View File

@ -5,6 +5,7 @@
* This source code is licensed under the Apache license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
Renderer,
getRendererByName,