diff --git a/examples/components/App.tsx b/examples/components/App.tsx index 6b8bc608b..6b43a163b 100644 --- a/examples/components/App.tsx +++ b/examples/components/App.tsx @@ -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 ( appVariables, 可以通过\\${appVariables.xxx}来取值' + }, + { + type: 'container', + style: { + padding: '8px', + marginBottom: '8px', + backgroundColor: '#f5f5f5', + borderRadius: '4px' + }, + body: [ + { + type: 'tpl', + tpl: '

数据域appVariables

' + }, + { + type: 'json', + id: 'u:44521540e64c', + source: '${appVariables}', + levelExpand: 10 + }, + { + type: 'tpl', + tpl: '

接口中的ProductName (\\${ProductName}): ${ProductName|default:-}

', + inline: false, + id: 'u:98ed5c5534ef' + }, + { + type: 'tpl', + tpl: '

变量中的ProductName (\\${appVariables.ProductName}): ${appVariables.ProductName|default:-}

', + 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); + } + } + } + } +}; diff --git a/examples/components/Example.jsx b/examples/components/Example.jsx index 0e2b694d3..4d78b03a3 100644 --- a/examples/components/Example.jsx +++ b/examples/components/Example.jsx @@ -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 + ) } ] }, diff --git a/examples/components/SchemaRender.jsx b/examples/components/SchemaRender.jsx index 38c9a3b14..5db614401 100644 --- a/examples/components/SchemaRender.jsx +++ b/examples/components/SchemaRender.jsx @@ -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 diff --git a/mock/cfc/mock/page/initData2.json b/mock/cfc/mock/page/initData2.json new file mode 100644 index 000000000..cc0a9b1de --- /dev/null +++ b/mock/cfc/mock/page/initData2.json @@ -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 + } + } + } +} diff --git a/packages/amis-core/src/Root.tsx b/packages/amis-core/src/Root.tsx index 3a024d4a7..440bdbb95 100644 --- a/packages/amis-core/src/Root.tsx +++ b/packages/amis-core/src/Root.tsx @@ -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 { 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 { rootStore: rootStore, resolveDefinitions: this.resolveDefinitions, location: location, - data: data, + data, env: env, classnames: theme.classnames, classPrefix: theme.classPrefix, diff --git a/packages/amis-core/src/RootRenderer.tsx b/packages/amis-core/src/RootRenderer.tsx index 1484e5a8a..b21e884ae 100644 --- a/packages/amis-core/src/RootRenderer.tsx +++ b/packages/amis-core/src/RootRenderer.tsx @@ -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'; diff --git a/packages/amis-core/src/SchemaRenderer.tsx b/packages/amis-core/src/SchemaRenderer.tsx index db2b33e9c..91ce8804a 100644 --- a/packages/amis-core/src/SchemaRenderer.tsx +++ b/packages/amis-core/src/SchemaRenderer.tsx @@ -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 { this.renderChild = this.renderChild.bind(this); this.reRender = this.reRender.bind(this); this.resolveRenderer(this.props); - this.dispatchEvent = this.dispatchEvent.bind(this); // 监听topStore更新 diff --git a/packages/amis-core/src/Scoped.tsx b/packages/amis-core/src/Scoped.tsx index b424e5407..ab48851e4 100644 --- a/packages/amis-core/src/Scoped.tsx +++ b/packages/amis-core/src/Scoped.tsx @@ -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 { focus?: () => void; @@ -33,6 +36,11 @@ export interface ScopedComponentType extends React.Component { 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 = {}; + 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(); }, diff --git a/packages/amis-core/src/actions/Action.ts b/packages/amis-core/src/actions/Action.ts index ce03571c3..dab491fa7 100644 --- a/packages/amis-core/src/actions/Action.ts +++ b/packages/amis-core/src/actions/Action.ts @@ -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; // 动作配置,可以配置数据映射 data?: Record | null; // 动作数据参数,可以配置数据映射 dataMergeMode?: 'merge' | 'override'; // 参数模式,合并或者覆盖 diff --git a/packages/amis-core/src/actions/CmptAction.ts b/packages/amis-core/src/actions/CmptAction.ts index b5451bcd0..6f56c3026 100644 --- a/packages/amis-core/src/actions/CmptAction.ts +++ b/packages/amis-core/src/actions/CmptAction.ts @@ -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, diff --git a/packages/amis-core/src/env.tsx b/packages/amis-core/src/env.tsx index 4b47910b6..edcbf03a2 100644 --- a/packages/amis-core/src/env.tsx +++ b/packages/amis-core/src/env.tsx @@ -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; 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 + ) => Promise; } export const EnvContext = React.createContext(undefined); diff --git a/packages/amis-core/src/index.tsx b/packages/amis-core/src/index.tsx index cc077b4e0..b4a848887 100644 --- a/packages/amis-core/src/index.tsx +++ b/packages/amis-core/src/index.tsx @@ -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,