diff --git a/packages/editor-sdk/src/components/Widgets/EventWidget.tsx b/packages/editor-sdk/src/components/Widgets/EventWidget.tsx index a4eb370f7..4706fec74 100644 --- a/packages/editor-sdk/src/components/Widgets/EventWidget.tsx +++ b/packages/editor-sdk/src/components/Widgets/EventWidget.tsx @@ -14,6 +14,8 @@ import { CoreWidgetName, generateDefaultValueFromSpec, MountEvents, + GLOBAL_MODULE_ID, + ModuleEventMethodSpec, } from '@sunmao-ui/shared'; import { JSONSchema7Object } from 'json-schema'; import { PREVENT_POPOVER_WIDGET_CLOSE_CLASS } from '../../constants/widget'; @@ -32,7 +34,7 @@ declare module '../../types/widget' { export const EventWidget: React.FC> = observer(props => { const { value, path, level, component, spec, services, onChange } = props; const { registry, editorStore, appModelManager } = services; - const { components } = editorStore; + const { components, currentEditingTarget } = editorStore; const utilMethods = useMemo(() => registry.getAllUtilMethods(), [registry]); const [methods, setMethods] = useState([]); @@ -62,9 +64,22 @@ export const EventWidget: React.FC> = observer(prop }, [registry] ); + const eventTypes = useMemo(() => { - return [...registry.getComponentByType(component.type).spec.events, ...MountEvents]; - }, [component.type, registry]); + let moduleEvents: string[] = []; + if (component.type === 'core/v1/moduleContainer') { + // if component is moduleContainer, add module events to it + const moduleType = component.properties.type as string; + const moduleSpec = registry.getModuleByType(moduleType); + moduleEvents = moduleSpec.spec.events; + } + return [ + ...registry.getComponentByType(component.type).spec.events, + ...moduleEvents, + ...MountEvents, + ]; + }, [component.properties.type, component.type, registry]); + const hasParams = useMemo( () => Object.keys(formik.values.method.parameters ?? {}).length, [formik.values.method.parameters] @@ -79,6 +94,8 @@ export const EventWidget: React.FC> = observer(prop const targetMethod = registry.getUtilMethodByType(methodType)!; spec = targetMethod.spec.parameters; + } else if (componentId === GLOBAL_MODULE_ID) { + spec = ModuleEventMethodSpec; } else { const targetComponent = appModelManager.appModel.getComponentById(componentId); const targetMethod = (findMethodsByComponent(targetComponent) ?? []).find( @@ -140,19 +157,42 @@ export const EventWidget: React.FC> = observer(prop utilMethod => `${utilMethod.version}/${utilMethod.metadata.name}` ) ); + } else if ( + componentId === GLOBAL_MODULE_ID && + currentEditingTarget.kind === 'module' + ) { + // if user is editing module, show the events of module spec as method + const moduleType = `${currentEditingTarget.version}/${currentEditingTarget.name}`; + let methodNames: string[] = []; + if (moduleType) { + const moduleSpec = services.registry.getModuleByType(moduleType); + + if (moduleSpec) { + methodNames = moduleSpec.spec.events; + } + } + setMethods(methodNames); } else { + // if user is editing application, show methods of component const component = components.find(c => c.id === componentId); if (component) { const methodNames: string[] = findMethodsByComponent(component).map( ({ name }) => name ); - setMethods(methodNames); } } }, - [components, utilMethods, findMethodsByComponent] + [ + currentEditingTarget.kind, + currentEditingTarget.version, + currentEditingTarget.name, + utilMethods, + services.registry, + components, + findMethodsByComponent, + ] ); useEffect(() => { @@ -235,6 +275,11 @@ export const EventWidget: React.FC> = observer(prop style={{ width: '100%' }} value={formik.values.componentId === '' ? undefined : formik.values.componentId} > + {currentEditingTarget.kind === 'module' ? ( + + {GLOBAL_MODULE_ID} + + ) : undefined} {[{ id: GLOBAL_UTIL_METHOD_ID }].concat(components).map(c => ( {c.id} diff --git a/packages/editor-sdk/src/components/Widgets/ExpressionWidget.tsx b/packages/editor-sdk/src/components/Widgets/ExpressionWidget.tsx index 18c4667cb..656d6ffcf 100644 --- a/packages/editor-sdk/src/components/Widgets/ExpressionWidget.tsx +++ b/packages/editor-sdk/src/components/Widgets/ExpressionWidget.tsx @@ -1,21 +1,11 @@ import React, { useEffect, useMemo, useCallback, useState, useRef } from 'react'; -import { - toNumber, - isString, - isNumber, - isBoolean, - isFunction, - isObject, - isUndefined, - isNull, - debounce, -} from 'lodash'; +import { toNumber, debounce } from 'lodash'; import { Type, Static } from '@sinclair/typebox'; import { WidgetProps } from '../../types/widget'; import { implementWidget } from '../../utils/widget'; import { ExpressionEditor, ExpressionEditorHandle } from '../Form'; import { isExpression } from '../../utils/validator'; -import { getTypeString } from '../../utils/type'; +import { getType, getTypeString, Types } from '../../utils/type'; import { ValidateFunction } from 'ajv'; import { ExpressionError } from '@sunmao-ui/runtime'; import { CORE_VERSION, CoreWidgetName, initAjv } from '@sunmao-ui/shared'; @@ -26,30 +16,6 @@ export function isNumeric(x: string | number) { } // highly inspired by appsmith -export enum Types { - STRING = 'STRING', - NUMBER = 'NUMBER', - BOOLEAN = 'BOOLEAN', - OBJECT = 'OBJECT', - ARRAY = 'ARRAY', - FUNCTION = 'FUNCTION', - UNDEFINED = 'UNDEFINED', - NULL = 'NULL', - UNKNOWN = 'UNKNOWN', -} - -export const getType = (value: unknown) => { - if (isString(value)) return Types.STRING; - if (isNumber(value)) return Types.NUMBER; - if (isBoolean(value)) return Types.BOOLEAN; - if (Array.isArray(value)) return Types.ARRAY; - if (isFunction(value)) return Types.FUNCTION; - if (isObject(value)) return Types.OBJECT; - if (isUndefined(value)) return Types.UNDEFINED; - if (isNull(value)) return Types.NULL; - return Types.UNKNOWN; -}; - function generateTypeDef( obj: any ): string | Record> { @@ -164,13 +130,14 @@ export const ExpressionWidget: React.FC> = pro const [error, setError] = useState(null); const editorRef = useRef(null); const validateFuncRef = useRef(null); - const slotTrait = useMemo( - () => - component.traits.find(trait => + const slotTrait = useMemo(() => { + if (component.traits) { + return component.traits.find(trait => ['core/v1/slot', 'core/v2/slot'].includes(trait.type) - ), - [component.traits] - ); + ); + } + return undefined; + }, [component]); const $slot = useMemo( () => slotTrait diff --git a/packages/editor-sdk/src/components/Widgets/ModuleWidget.tsx b/packages/editor-sdk/src/components/Widgets/ModuleWidget.tsx index 79d28159e..11890edce 100644 --- a/packages/editor-sdk/src/components/Widgets/ModuleWidget.tsx +++ b/packages/editor-sdk/src/components/Widgets/ModuleWidget.tsx @@ -6,9 +6,8 @@ import { SpecWidget } from './SpecWidget'; import { CORE_VERSION, CoreWidgetName, isJSONSchema } from '@sunmao-ui/shared'; import { css } from '@emotion/css'; import { mapValues } from 'lodash'; -import { Type, TSchema } from '@sinclair/typebox'; import type { JSONSchema7 } from 'json-schema'; -import { getType, Types } from './ExpressionWidget'; +import { json2JsonSchema } from '../../utils/type'; const LabelStyle = css` font-weight: normal; @@ -23,34 +22,6 @@ declare module '../../types/widget' { } } -const genSpec = (type: Types, target: any): TSchema => { - switch (type) { - case Types.ARRAY: { - const arrayType = getType(target[0]); - return Type.Array(genSpec(arrayType, target[0])); - } - case Types.OBJECT: { - const objType: Record = {}; - Object.keys(target).forEach(k => { - const type = getType(target[k]); - objType[k] = genSpec(type, target[k]); - }); - return Type.Object(objType); - } - case Types.STRING: - return Type.String(); - case Types.NUMBER: - return Type.Number(); - case Types.BOOLEAN: - return Type.Boolean(); - case Types.NULL: - case Types.UNDEFINED: - return Type.Any(); - default: - return Type.Any(); - } -}; - export const ModuleWidget: React.FC> = props => { const { component, value, spec, services, path, level, onChange } = props; const { registry } = services; @@ -100,13 +71,11 @@ export const ModuleWidget: React.FC> = props => { const modulePropertiesSpec = useMemo(() => { const obj = mapValues(module?.metadata.exampleProperties, p => { const result = services.stateManager.deepEval(p); - const type = getType(result); - const spec = genSpec(type, result); - return spec; + return json2JsonSchema(result); }); - return Type.Object(obj); + return { type: 'object', properties: obj }; }, [module?.metadata.exampleProperties, services.stateManager]); return ( diff --git a/packages/editor-sdk/src/types/editor.ts b/packages/editor-sdk/src/types/editor.ts index 24df5b88c..5e14690f0 100644 --- a/packages/editor-sdk/src/types/editor.ts +++ b/packages/editor-sdk/src/types/editor.ts @@ -7,6 +7,11 @@ export interface EditorServicesInterface extends UIServices { registry: RegistryInterface; editorStore: { components: ComponentSchema[]; + currentEditingTarget: { + kind: 'app' | 'module'; + version: string; + name: string; + }; }; appModelManager: { appModel: any; diff --git a/packages/editor-sdk/src/types/operation.ts b/packages/editor-sdk/src/types/operation.ts index 6fd835566..deda5475e 100644 --- a/packages/editor-sdk/src/types/operation.ts +++ b/packages/editor-sdk/src/types/operation.ts @@ -1,7 +1,7 @@ type OperationType = | 'createComponent' | 'removeComponent' - | 'modifyComponentProperty' + | 'modifyComponentProperties' | 'modifyComponentId' | 'adjustComponentOrder' | 'createTrait' diff --git a/packages/editor-sdk/src/utils/index.ts b/packages/editor-sdk/src/utils/index.ts index 19d16a6eb..af0442233 100644 --- a/packages/editor-sdk/src/utils/index.ts +++ b/packages/editor-sdk/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './widget'; export * from './validator'; +export * from './type'; diff --git a/packages/editor-sdk/src/utils/type.ts b/packages/editor-sdk/src/utils/type.ts index 67eb7fc53..90449f75f 100644 --- a/packages/editor-sdk/src/utils/type.ts +++ b/packages/editor-sdk/src/utils/type.ts @@ -1,3 +1,15 @@ +import { + isString, + isNumber, + isBoolean, + isFunction, + isObject, + isUndefined, + isNull, +} from 'lodash'; +import type { JSONSchema7 } from 'json-schema'; +import { TSchema, Type } from '@sinclair/typebox'; + const TypeMap = { undefined: 'Undefined', string: 'String', @@ -18,3 +30,59 @@ export function getTypeString(value: any) { return TypeMap[typeof value]; } } + +export enum Types { + STRING = 'STRING', + NUMBER = 'NUMBER', + BOOLEAN = 'BOOLEAN', + OBJECT = 'OBJECT', + ARRAY = 'ARRAY', + FUNCTION = 'FUNCTION', + UNDEFINED = 'UNDEFINED', + NULL = 'NULL', + UNKNOWN = 'UNKNOWN', +} + +export const getType = (value: unknown) => { + if (isString(value)) return Types.STRING; + if (isNumber(value)) return Types.NUMBER; + if (isBoolean(value)) return Types.BOOLEAN; + if (Array.isArray(value)) return Types.ARRAY; + if (isFunction(value)) return Types.FUNCTION; + if (isObject(value)) return Types.OBJECT; + if (isUndefined(value)) return Types.UNDEFINED; + if (isNull(value)) return Types.NULL; + return Types.UNKNOWN; +}; + +const genSpec = (type: Types, target: any): TSchema => { + switch (type) { + case Types.ARRAY: { + const arrayType = getType(target[0]); + return Type.Array(genSpec(arrayType, target[0])); + } + case Types.OBJECT: { + const objType: Record = {}; + Object.keys(target).forEach(k => { + const type = getType(target[k]); + objType[k] = genSpec(type, target[k]); + }); + return Type.Object(objType); + } + case Types.STRING: + return Type.String(); + case Types.NUMBER: + return Type.Number(); + case Types.BOOLEAN: + return Type.Boolean(); + case Types.NULL: + case Types.UNDEFINED: + return Type.Any(); + default: + return Type.Any(); + } +}; +export const json2JsonSchema = (value: any): JSONSchema7 => { + const type = getType(value); + return genSpec(type, value) as JSONSchema7; +}; diff --git a/packages/editor/src/AppModel/ComponentModel.ts b/packages/editor/src/AppModel/ComponentModel.ts index 72e1362fa..26769cb3a 100644 --- a/packages/editor/src/AppModel/ComponentModel.ts +++ b/packages/editor/src/AppModel/ComponentModel.ts @@ -29,6 +29,7 @@ import { } from './IAppModel'; import { TraitModel } from './TraitModel'; import { FieldModel } from './FieldModel'; +import { AppModel } from './AppModel'; const SlotTraitType: TraitType = `${CORE_VERSION}/${CoreTraitName.Slot}` as TraitType; const SlotTraitTypeV2: TraitType = `core/v2/${CoreTraitName.Slot}` as TraitType; @@ -304,6 +305,19 @@ export class ComponentModel implements IComponentModel { this._isDirty = true; } + removeSlotTrait() { + if (this._slotTrait) { + this.removeTrait(this._slotTrait.id); + } + } + + clone() { + return new AppModel( + this.allComponents.map(c => c.toSchema()), + this.registry + ).getComponentById(this.id)!; + } + private traverseTree(cb: (c: IComponentModel) => void) { function traverse(root: IComponentModel) { cb(root); diff --git a/packages/editor/src/AppModel/IAppModel.ts b/packages/editor/src/AppModel/IAppModel.ts index 33fd5f147..8d6c2cc7e 100644 --- a/packages/editor/src/AppModel/IAppModel.ts +++ b/packages/editor/src/AppModel/IAppModel.ts @@ -98,6 +98,7 @@ export interface IComponentModel { _isDirty: boolean; _slotTrait: ITraitModel | null; toSchema(): ComponentSchema; + clone(): IComponentModel; updateComponentProperty: (property: string, value: unknown) => void; // move component from old parent to new parent(or top level if parent is undefined). appendTo: (parent?: IComponentModel, slot?: SlotName) => void; @@ -110,6 +111,7 @@ export interface IComponentModel { removeTrait: (traitId: TraitId) => void; updateTraitProperties: (traitId: TraitId, properties: Record) => void; updateSlotTrait: (parent: ComponentId, slot: SlotName) => void; + removeSlotTrait: () => void; removeChild: (child: IComponentModel) => void; } diff --git a/packages/editor/src/components/ComponentForm/ComponentForm.tsx b/packages/editor/src/components/ComponentForm/ComponentForm.tsx index 589115df3..1cfd864c6 100644 --- a/packages/editor/src/components/ComponentForm/ComponentForm.tsx +++ b/packages/editor/src/components/ComponentForm/ComponentForm.tsx @@ -96,7 +96,7 @@ export const ComponentForm: React.FC = observer(props => { onChange={newFormData => { eventBus.send( 'operation', - genOperation(registry, 'modifyComponentProperty', { + genOperation(registry, 'modifyComponentProperties', { componentId: selectedComponentId, properties: newFormData, }) diff --git a/packages/editor/src/components/Editor.tsx b/packages/editor/src/components/Editor.tsx index 051e16ed5..139ed1e14 100644 --- a/packages/editor/src/components/Editor.tsx +++ b/packages/editor/src/components/Editor.tsx @@ -85,9 +85,12 @@ export const Editor: React.FC = observer( } }, [isDisplayApp]); const onPreview = useCallback(() => setPreview(true), []); - const onRightTabChange = useCallback(activatedTab => { - setToolMenuTab(activatedTab); - }, []); + const onRightTabChange = useCallback( + activatedTab => { + setToolMenuTab(activatedTab); + }, + [setToolMenuTab] + ); const renderMain = () => { const appBox = ( diff --git a/packages/editor/src/components/Explorer/Explorer.tsx b/packages/editor/src/components/Explorer/Explorer.tsx index cf2160578..c62c023fd 100644 --- a/packages/editor/src/components/Explorer/Explorer.tsx +++ b/packages/editor/src/components/Explorer/Explorer.tsx @@ -58,6 +58,7 @@ export const Explorer: React.FC = ({ services }) => { setCurrentVersion={setCurrentVersion} setCurrentName={setCurrentName} services={services} + onClose={() => setIsEditingMode(false)} /> diff --git a/packages/editor/src/components/Explorer/ExplorerForm/ExplorerForm.tsx b/packages/editor/src/components/Explorer/ExplorerForm/ExplorerForm.tsx index cf7fb3e9c..21ad6d44e 100644 --- a/packages/editor/src/components/Explorer/ExplorerForm/ExplorerForm.tsx +++ b/packages/editor/src/components/Explorer/ExplorerForm/ExplorerForm.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { observer } from 'mobx-react-lite'; -import { VStack } from '@chakra-ui/react'; +import { Button, ButtonGroup, Spacer, VStack } from '@chakra-ui/react'; import { AppMetaDataForm, AppMetaDataFormData } from './AppMetaDataForm'; import { ModuleMetaDataForm, ModuleMetaDataFormData } from './ModuleMetaDataForm'; import { EditorServices } from '../../../types'; import { Type } from '@sinclair/typebox'; +import { cloneDeep } from 'lodash'; type Props = { formType: 'app' | 'module'; @@ -13,15 +14,49 @@ type Props = { setCurrentVersion?: (version: string) => void; setCurrentName?: (name: string) => void; services: EditorServices; + onClose: () => void; }; export const ExplorerForm: React.FC = observer( - ({ formType, version, name, setCurrentVersion, setCurrentName, services }) => { + ({ formType, version, name, setCurrentVersion, setCurrentName, services, onClose }) => { const { editorStore } = services; - const onSubmit = (value: AppMetaDataFormData | ModuleMetaDataFormData) => { - setCurrentVersion?.(value.version); - setCurrentName?.(value.name); + const newModuleMetaDataRef = useRef(); + const newAppMetaDataRef = useRef(); + const onModuleMetaDataChange = (value: ModuleMetaDataFormData) => { + newModuleMetaDataRef.current = value; }; + const onAppMetaDataChange = (value: AppMetaDataFormData) => { + newAppMetaDataRef.current = value; + }; + const saveModuleMetaData = () => { + if (!newModuleMetaDataRef.current) return; + editorStore.appStorage.saveModuleMetaData( + { originName: name, originVersion: version }, + newModuleMetaDataRef.current + ); + editorStore.setModuleDependencies(newModuleMetaDataRef.current.exampleProperties); + setCurrentVersion?.(newModuleMetaDataRef.current.version); + setCurrentName?.(newModuleMetaDataRef.current.name); + }; + const saveAppMetaData = () => { + if (!newAppMetaDataRef.current) return; + editorStore.appStorage.saveAppMetaData(newAppMetaDataRef.current); + setCurrentVersion?.(newAppMetaDataRef.current.version); + setCurrentName?.(newAppMetaDataRef.current.name); + }; + + const onSave = () => { + switch (formType) { + case 'app': + saveAppMetaData(); + break; + case 'module': + saveModuleMetaData(); + break; + } + onClose(); + }; + let form; switch (formType) { case 'app': @@ -30,33 +65,49 @@ export const ExplorerForm: React.FC = observer( version, }; form = ( - + ); break; case 'module': + // Don't get from registry, because module from registry has __$moduleId const moduleSpec = editorStore.appStorage.modules.find( m => m.version === version && m.metadata.name === name )!; - const moduleMetaData = { + + const moduleMetaData = cloneDeep({ name, version, stateMap: moduleSpec?.spec.stateMap || {}, properties: moduleSpec?.spec.properties || Type.Object({}), exampleProperties: moduleSpec?.metadata.exampleProperties || {}, events: moduleSpec?.spec.events || [], - }; + }); form = ( ); break; } + return ( - + {form} + + + + + ); } diff --git a/packages/editor/src/components/Explorer/ExplorerForm/ModuleMetaDataForm.tsx b/packages/editor/src/components/Explorer/ExplorerForm/ModuleMetaDataForm.tsx index d285a0561..f35c49d36 100644 --- a/packages/editor/src/components/Explorer/ExplorerForm/ModuleMetaDataForm.tsx +++ b/packages/editor/src/components/Explorer/ExplorerForm/ModuleMetaDataForm.tsx @@ -30,6 +30,7 @@ type ModuleMetaDataFormProps = { initData: ModuleMetaDataFormData; services: EditorServices; onSubmit?: (value: ModuleMetaDataFormData) => void; + disabled?: boolean; }; const genEventsName = (events: string[]) => { @@ -84,17 +85,9 @@ const EventInput: React.FC<{ export const ModuleMetaDataForm: React.FC = observer( ({ initData, services, onSubmit: onSubmitForm }) => { - const { editorStore } = services; - const onSubmit = (value: ModuleMetaDataFormData) => { - editorStore.appStorage.saveModuleMetaData( - { originName: initData.name, originVersion: initData.version }, - value - ); - editorStore.setModuleDependencies(value.exampleProperties); onSubmitForm?.(value); }; - const formik = useFormik({ initialValues: initData, onSubmit, @@ -158,7 +151,7 @@ export const ModuleMetaDataForm: React.FC = observer( /> - Properties + Example Properties = observer( aria-label="create module" size="xs" icon={} - onClick={() => editorStore.appStorage.createModule()} + onClick={() => editorStore.appStorage.createModule({})} /> {moduleItems} diff --git a/packages/editor/src/components/ExtractModuleModal/ExtractModuleEventForm.tsx b/packages/editor/src/components/ExtractModuleModal/ExtractModuleEventForm.tsx new file mode 100644 index 000000000..836ba694b --- /dev/null +++ b/packages/editor/src/components/ExtractModuleModal/ExtractModuleEventForm.tsx @@ -0,0 +1,91 @@ +import React, { useCallback } from 'react'; +import { + Heading, + Table, + Tbody, + Td, + Th, + Thead, + Tr, + VStack, + Link, + Text, +} from '@chakra-ui/react'; +import { EditorServices } from '../../types'; +import { InsideMethodRelation } from './type'; +import { Placeholder } from './Placeholder'; + +type Props = { + methodRelations: InsideMethodRelation[]; + services: EditorServices; +}; + +export const ExtractModuleEventForm: React.FC = ({ + methodRelations, + services, +}) => { + const { editorStore } = services; + + const idLink = useCallback( + (id: string) => { + return ( + { + editorStore.setSelectedComponentId(id); + }} + > + {id} + + ); + }, + [editorStore] + ); + + let content = ( + + ); + + if (methodRelations.length) { + content = ( + + + + + + + + + + + + {methodRelations.map((d, i) => { + return ( + + + + + + + + ); + })} + +
SourceEventTargetMethodModule Event Name
+ {d.source} + {d.event}{idLink(d.target)}{d.method}{d.source + d.event}
+ ); + } + + return ( + + Module Events + + {`These components' event handlers call outside components' methods. + These events will be convert automatically to module's events and exposed to outside components. + You don't have to do anything.`} + + {content} + + ); +}; diff --git a/packages/editor/src/components/ExtractModuleModal/ExtractModuleModal.tsx b/packages/editor/src/components/ExtractModuleModal/ExtractModuleModal.tsx new file mode 100644 index 000000000..23ab0ee61 --- /dev/null +++ b/packages/editor/src/components/ExtractModuleModal/ExtractModuleModal.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { + Modal, + ModalOverlay, + ModalHeader, + ModalContent, + ModalCloseButton, + ModalBody, + Text, +} from '@chakra-ui/react'; +import { EditorServices } from '../../types'; +import { ExtractModuleView } from './ExtractModuleView'; + +type Props = { + componentId: string; + onClose: () => void; + services: EditorServices; +}; + +export const ExtractModuleModal: React.FC = ({ + componentId, + onClose, + services, +}) => { + return ( + + + + + Extract component{' '} + + {componentId} + {' '} + to module + + + + + + + + ); +}; diff --git a/packages/editor/src/components/ExtractModuleModal/ExtractModulePropertyForm.tsx b/packages/editor/src/components/ExtractModuleModal/ExtractModulePropertyForm.tsx new file mode 100644 index 000000000..fcba287bf --- /dev/null +++ b/packages/editor/src/components/ExtractModuleModal/ExtractModulePropertyForm.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Heading, + VStack, + Button, + HStack, + Radio, + RadioGroup, + FormControl, + FormLabel, + Text, + Tooltip, + Table, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@chakra-ui/react'; +import { groupBy, uniq } from 'lodash'; +import { EditorServices } from '../../types'; +import { InsideExpRelation, RefTreatmentMap, RefTreatment } from './type'; +import { RelationshipModal } from '../RelationshipModal'; +import { Placeholder } from './Placeholder'; + +type Props = { + insideExpRelations: InsideExpRelation[]; + onChange: (value: RefTreatmentMap) => void; + services: EditorServices; +}; + +const RadioOptions = [ + { + value: RefTreatment.keep, + label: 'Keep outside', + desc: `Keep this outside and pass this component's state into module by properties.`, + }, + { + value: RefTreatment.move, + label: 'Move into module', + desc: 'Move this component into module and delete it outside.', + }, + { + value: RefTreatment.duplicate, + label: 'Dupliacte', + desc: 'Copy and paste this component into module and keep it outside at the same time.', + }, + { + value: RefTreatment.ignore, + label: 'Ignore', + desc: `Don't do anything.`, + }, +]; + +export const ExtractModulePropertyForm: React.FC = ({ + insideExpRelations, + onChange, + services, +}) => { + const [showRelationId, setShowRelationId] = useState(''); + const [value, setValue] = useState({}); + + const [relationGroups, refIds] = useMemo( + () => [ + groupBy(insideExpRelations, r => r.componentId), + uniq(insideExpRelations.map(r => r.componentId)), + ], + [insideExpRelations] + ); + + useEffect(() => { + const map: RefTreatmentMap = {}; + refIds.forEach(r => { + map[r] = RefTreatment.keep; + }); + setValue(map); + }, [refIds]); + + useEffect(() => { + onChange(value); + }, [onChange, value]); + + let content = ; + if (refIds.length) { + content = ( + + {Object.keys(value).map(id => { + return ( + + + + + + + { + const next = { ...value, [id]: newValue as any }; + setValue(next); + }} + value={value[id]} + > + + {RadioOptions.map(o => ( + + + {o.label} + + + ))} + + + + + + + + + + + + + + {relationGroups[id].map((r, i) => { + return ( + + + + + + ); + })} + +
Component IdProperty KeyExpression
+ + {r.source} + {r.traitType ? `-${r.traitType}` : ''} + + {r.key}{r.exp}
+
+ ); + })} +
+ ); + } + + const relationshipViewModal = showRelationId ? ( + setShowRelationId('')} + /> + ) : null; + return ( + <> + + Module Properties + + These components are used by the components of module, you have to decide how to + treat them. + + {content} + + {relationshipViewModal} + + ); +}; diff --git a/packages/editor/src/components/ExtractModuleModal/ExtractModuleStateForm.tsx b/packages/editor/src/components/ExtractModuleModal/ExtractModuleStateForm.tsx new file mode 100644 index 000000000..001916231 --- /dev/null +++ b/packages/editor/src/components/ExtractModuleModal/ExtractModuleStateForm.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from 'react'; +import { + Heading, + Table, + Tbody, + Td, + Th, + Thead, + Tr, + VStack, + Input, + Text, +} from '@chakra-ui/react'; +import { OutsideExpRelation, OutsideExpRelationWithState } from './type'; +import { Placeholder } from './Placeholder'; +import produce from 'immer'; + +type Props = { + outsideExpRelations: OutsideExpRelation[]; + onChange: (value: OutsideExpRelationWithState[]) => void; +}; + +export const ExtractModuleStateForm: React.FC = ({ + outsideExpRelations, + onChange, +}) => { + const [value, setValue] = useState([]); + + useEffect(() => { + const newValue = outsideExpRelations.map(r => { + return { + ...r, + stateName: `${r.relyOn}${r.valuePath}`, + }; + }); + setValue(newValue); + }, [outsideExpRelations]); + + useEffect(() => { + onChange(value); + }, [onChange, value]); + + let content = ( + + ); + if (outsideExpRelations.length) { + content = ( + + + + + + + + + + + + {value.map((d, i) => { + const onChange = (e: React.ChangeEvent) => { + const newValue = produce(value, draft => { + draft[i].stateName = e.target.value; + return draft; + }); + setValue(newValue); + }; + return ( + + + + + + + + ); + })} + +
ComponentKeyExpressionRawExpStateName
+ {d.componentId} + {d.key}{d.exp}{`${d.relyOn}.${d.valuePath}`} + +
+ ); + } + + return ( + + Module State + + {`These outside components used in-module components' state. + You have to give these expression a new name which will become the state exposed by module.`} + + {content} + + ); +}; diff --git a/packages/editor/src/components/ExtractModuleModal/ExtractModuleStep.tsx b/packages/editor/src/components/ExtractModuleModal/ExtractModuleStep.tsx new file mode 100644 index 000000000..2eff49bc3 --- /dev/null +++ b/packages/editor/src/components/ExtractModuleModal/ExtractModuleStep.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { List, ListItem, ListIcon } from '@chakra-ui/react'; +import { CheckCircleIcon, SettingsIcon } from '@chakra-ui/icons'; + +type Props = { + activeIndex: number; +}; + +export const ExtractModuleStep: React.FC = ({ activeIndex }) => { + return ( + + 0 ? 'gray.500' : 'gray.900'} + > + 0 ? CheckCircleIcon : SettingsIcon} /> + Module Properties + + + 1 ? 'gray.500' : 'gray.900'} + > + 1 ? CheckCircleIcon : SettingsIcon} /> + Module State + + + 2 ? 'gray.500' : 'gray.900'} + > + 2 ? CheckCircleIcon : SettingsIcon} /> + Module Events + + + 3 ? 'gray.500' : 'gray.900'} + > + 3 ? CheckCircleIcon : SettingsIcon} /> + Preview + + + ); +}; diff --git a/packages/editor/src/components/ExtractModuleModal/ExtractModuleView.tsx b/packages/editor/src/components/ExtractModuleModal/ExtractModuleView.tsx new file mode 100644 index 000000000..b715947f9 --- /dev/null +++ b/packages/editor/src/components/ExtractModuleModal/ExtractModuleView.tsx @@ -0,0 +1,358 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + VStack, + Button, + HStack, + Box, + ButtonGroup, + Spacer, + Heading, + Text, +} from '@chakra-ui/react'; +import { EventHandlerSpec, GLOBAL_MODULE_ID } from '@sunmao-ui/shared'; +import { Static } from '@sinclair/typebox'; +import { set, uniq } from 'lodash'; +import { EditorServices } from '../../types'; +import { ComponentId } from '../../AppModel/IAppModel'; +import { + RefTreatmentMap, + OutsideExpRelationWithState, + RefTreatment, + InsideExpRelation, + InsideMethodRelation, +} from './type'; +import { ExtractModuleStep } from './ExtractModuleStep'; +import { ExtractModulePropertyForm } from './ExtractModulePropertyForm'; +import { ExtractModuleStateForm } from './ExtractModuleStateForm'; +import { ExtractModuleEventForm } from './ExtractModuleEventForm'; +import { + ModuleMetaDataForm, + ModuleMetaDataFormData, +} from '../Explorer/ExplorerForm/ModuleMetaDataForm'; +import { toJS } from 'mobx'; +import { json2JsonSchema } from '@sunmao-ui/editor-sdk'; +import { genOperation } from '../../operations'; +import { getInsideRelations, getOutsideExpRelations } from './utils'; + +type Props = { + componentId: string; + services: EditorServices; + onClose: () => void; +}; + +type InsideRelations = { + exp: InsideExpRelation[]; + method: InsideMethodRelation[]; +}; + +export const ExtractModuleView: React.FC = ({ + componentId, + services, + onClose, +}) => { + const { appModelManager } = services; + const { appModel } = appModelManager; + const refTreatmentMap = useRef({}); + const outsideExpRelationsValueRef = useRef([]); + const [activeIndex, setActiveIndex] = useState(0); + const [genModuleResult, serGenModuleResult] = useState< + ReturnType | undefined + >(); + const [moduleFormInitData, setModuleFormInitData] = useState< + ModuleMetaDataFormData | undefined + >(); + const moduleFormValueRef = useRef(); + + const { insideExpRelations, methodRelations, outsideExpRelations } = useMemo(() => { + const root = appModel.getComponentById(componentId as ComponentId)!; + const moduleComponents = root.allComponents; + const insideRelations = moduleComponents.reduce( + (res, c) => { + const { expressionRelations, methodRelations } = getInsideRelations( + c, + moduleComponents + ); + res.exp = res.exp.concat(expressionRelations); + res.method = res.method.concat(methodRelations); + return res; + }, + { exp: [], method: [] } + ); + const outsideExpRelations = getOutsideExpRelations(appModel.allComponents, root); + return { + insideExpRelations: insideRelations.exp, + methodRelations: insideRelations.method, + outsideExpRelations, + }; + }, [appModel, componentId]); + + const genModule = useCallback(() => { + const exampleProperties: Record = {}; + const moduleContainerProperties: Record = {}; + const toMoveComponentIds: string[] = []; + const toDeleteComponentIds: string[] = []; + insideExpRelations.forEach(relation => { + switch (refTreatmentMap.current[relation.componentId]) { + case RefTreatment.move: + toMoveComponentIds.push(relation.componentId); + toDeleteComponentIds.push(relation.componentId); + break; + case RefTreatment.keep: + moduleContainerProperties[relation.componentId] = `{{${relation.componentId}}}`; + const value = toJS(services.stateManager.store[relation.componentId]); + if (typeof value === 'string') { + exampleProperties[relation.componentId] = value; + } else { + // save value as expression + exampleProperties[relation.componentId] = `{{${JSON.stringify(value)}}}`; + } + break; + case RefTreatment.duplicate: + toMoveComponentIds.push(relation.componentId); + break; + } + }); + const root = services.appModelManager.appModel + .getComponentById(componentId as ComponentId)! + .clone(); + const newModuleContainerId = `${componentId}__module`; + const newModuleId = `${componentId}Module`; + // remove root slot + root.removeSlotTrait(); + + // covert in-module components to schema + const eventSpec: string[] = []; + const moduleComponentsSchema = root?.allComponents.map(c => { + const eventTrait = c.traits.find(t => t.type === 'core/v1/event'); + // conver in-module components' event handlers + if (eventTrait) { + const cache: Record = {}; + const handlers: Static[] = []; + eventTrait?.rawProperties.handlers.forEach( + (h: Static) => { + const newEventName = `${c.id}${h.type}`; + const hasRelation = methodRelations.find(r => { + return ( + r.source === c.id && r.event === h.type && r.target === h.componentId + ); + }); + if (hasRelation) { + // if component has another handler emit the same event, don't emit it again + if (cache[newEventName]) { + return; + } + // emit new $module event + cache[newEventName] = true; + eventSpec.push(newEventName); + handlers.push({ + type: h.type, + componentId: GLOBAL_MODULE_ID, + method: { + name: newEventName, + parameters: { + moduleId: '{{$moduleId}}', + }, + }, + disabled: false, + wait: { type: 'delay', time: 0 }, + }); + } else { + handlers.push(h); + } + } + ); + eventTrait.updateProperty('handlers', handlers); + } + return c.toSchema(); + }); + + // add moved and duplicated components + if (toMoveComponentIds.length) { + toMoveComponentIds.forEach(id => { + const comp = services.appModelManager.appModel.getComponentById( + id as ComponentId + )!; + moduleComponentsSchema.push(comp.toSchema()); + }); + } + + // generate event handlers for module container + const moduleHandlers = methodRelations.map(r => { + const { handler } = r; + return { + ...handler, + type: `${r.source}${r.event}`, + }; + }); + + // generate StateMap + const stateMap: Record = {}; + const outsideComponentNewProperties: Record = {}; + type TraitNewProperties = { + componentId: string; + traitIndex: number; + properties: Record; + }; + const outsideTraitNewProperties: TraitNewProperties[] = []; + outsideExpRelationsValueRef.current.forEach(r => { + if (r.stateName) { + const origin = `${r.relyOn}.${r.valuePath}`; + stateMap[r.stateName] = origin; + // replace ref with new state name in expressions + const newExp = r.exp.replaceAll(origin, `${newModuleId}.${r.stateName}`); + const c = services.appModelManager.appModel.getComponentById( + r.componentId as ComponentId + )!; + const fieldKey = r.key.startsWith('.') ? r.key.slice(1) : r.key; + if (r.traitType) { + c.traits.forEach((t, i) => { + const newProperties = set(t.properties.rawValue, fieldKey, newExp); + if (t.type === r.traitType) { + outsideTraitNewProperties.push({ + componentId: r.componentId, + traitIndex: i, + properties: newProperties, + }); + } + }); + } else { + const fieldKey = r.key.startsWith('.') ? r.key.slice(1) : r.key; + const newProperties = set(c.properties.rawValue, fieldKey, newExp); + outsideComponentNewProperties[r.componentId] = newProperties; + } + } + }); + + return { + exampleProperties, + moduleContainerProperties, + eventSpec, + toMoveComponentIds: uniq(toMoveComponentIds), + toDeleteComponentIds: uniq(toDeleteComponentIds), + methodRelations, + moduleComponentsSchema, + moduleHandlers, + stateMap, + newModuleContainerId, + newModuleId, + outsideComponentNewProperties, + outsideTraitNewProperties, + moduleRootId: componentId, + }; + }, [ + componentId, + insideExpRelations, + methodRelations, + services.appModelManager.appModel, + services.stateManager.store, + ]); + + const onExtract = () => { + if (!genModuleResult || !moduleFormValueRef.current) return; + services.editorStore.appStorage.createModule({ + components: genModuleResult.moduleComponentsSchema, + propertySpec: moduleFormValueRef.current.properties, + exampleProperties: genModuleResult.exampleProperties, + events: genModuleResult.eventSpec, + moduleVersion: moduleFormValueRef.current.version, + moduleName: moduleFormValueRef.current.name, + stateMap: genModuleResult.stateMap, + }); + + services.eventBus.send( + 'operation', + genOperation(services.registry, 'extractModule', { + moduleContainerId: genModuleResult.newModuleContainerId, + moduleContainerProperties: genModuleResult.moduleContainerProperties, + moduleId: genModuleResult.newModuleId, + moduleRootId: genModuleResult.moduleRootId, + moduleType: `${moduleFormValueRef.current.version}/${moduleFormValueRef.current.name}`, + moduleHandlers: genModuleResult.moduleHandlers, + outsideComponentNewProperties: genModuleResult.outsideComponentNewProperties, + outsideTraitNewProperties: genModuleResult.outsideTraitNewProperties, + toDeleteComponentIds: genModuleResult.toDeleteComponentIds, + }) + ); + onClose(); + }; + + // generate module spec for preview + useEffect(() => { + if (activeIndex === 3) { + const result = genModule(); + serGenModuleResult(result); + const moduleFormData = { + name: componentId, + version: 'custom/v1', + stateMap: result.stateMap, + properties: json2JsonSchema(result.exampleProperties), + events: result.eventSpec, + exampleProperties: result.exampleProperties, + }; + setModuleFormInitData(moduleFormData); + moduleFormValueRef.current = moduleFormData; + } + }, [activeIndex, componentId, genModule]); + + return ( + + + + + + (refTreatmentMap.current = val)} + services={services} + /> + + + { + outsideExpRelationsValueRef.current = v; + }} + /> + + + + + + Preview Module Spec + + {`The Spec has generated automatically, you don't need to change anything except version and name.`} + + {activeIndex === 3 && moduleFormInitData ? ( + (moduleFormValueRef.current = value)} + /> + ) : undefined} + + + + + {activeIndex > 0 ? ( + + ) : undefined} + {activeIndex < 3 ? ( + + ) : undefined} + {activeIndex === 3 ? ( + + ) : undefined} + + + + ); +}; diff --git a/packages/editor/src/components/ExtractModuleModal/Placeholder.tsx b/packages/editor/src/components/ExtractModuleModal/Placeholder.tsx new file mode 100644 index 000000000..44fd533a7 --- /dev/null +++ b/packages/editor/src/components/ExtractModuleModal/Placeholder.tsx @@ -0,0 +1,17 @@ +import { Text } from '@chakra-ui/react'; +import React from 'react'; + +export const Placeholder: React.FC<{ text: string }> = ({ text }) => { + return ( + + {text} + + ); +}; diff --git a/packages/editor/src/components/ExtractModuleModal/index.ts b/packages/editor/src/components/ExtractModuleModal/index.ts new file mode 100644 index 000000000..e06c4b004 --- /dev/null +++ b/packages/editor/src/components/ExtractModuleModal/index.ts @@ -0,0 +1 @@ +export * from './ExtractModuleModal'; diff --git a/packages/editor/src/components/ExtractModuleModal/type.ts b/packages/editor/src/components/ExtractModuleModal/type.ts new file mode 100644 index 000000000..7308762bf --- /dev/null +++ b/packages/editor/src/components/ExtractModuleModal/type.ts @@ -0,0 +1,43 @@ +import { Static } from '@sinclair/typebox'; +import { EventHandlerSpec } from '@sunmao-ui/shared'; + +// out-module components' expression rely on in-module component +export type OutsideExpRelation = { + componentId: string; + traitType?: string; + exp: string; + key: string; + valuePath: string; + relyOn: string; +}; + +export type OutsideExpRelationWithState = OutsideExpRelation & { + stateName: string; +}; + +// in-module components rely on out-module components +export type InsideExpRelation = { + source: string; + traitType?: string; + componentId: string; + exp: string; + key: string; +}; + +export enum RefTreatment { + 'keep' = 'keep', + 'move' = 'move', + 'duplicate' = 'duplicate', + 'ignore' = 'ignore', +} + +export type RefTreatmentMap = Record; + +// in-module components call out-module components' method +export type InsideMethodRelation = { + handler: Static; + source: string; + target: string; + event: string; + method: string; +}; diff --git a/packages/editor/src/components/ExtractModuleModal/utils.ts b/packages/editor/src/components/ExtractModuleModal/utils.ts new file mode 100644 index 000000000..1b09b14ed --- /dev/null +++ b/packages/editor/src/components/ExtractModuleModal/utils.ts @@ -0,0 +1,110 @@ +import { + CoreTraitName, + CORE_VERSION, + EventHandlerSpec, + ExpressionKeywords, +} from '@sunmao-ui/shared'; +import { Static } from '@sinclair/typebox'; +import { IComponentModel, IFieldModel, ComponentId } from '../../AppModel/IAppModel'; +import { OutsideExpRelation, InsideExpRelation, InsideMethodRelation } from './type'; + +export function getOutsideExpRelations( + allComponents: IComponentModel[], + moduleRoot: IComponentModel +): OutsideExpRelation[] { + const res: OutsideExpRelation[] = []; + const clonedRoot = moduleRoot.clone(); + const ids = clonedRoot.allComponents.map(c => c.id); + allComponents.forEach(c => { + if (clonedRoot.appModel.getComponentById(c.id)) { + // component is in module, ignore. + return; + } + const handler = (field: IFieldModel, key: string, traitType?: string) => { + if (field.isDynamic) { + const relyRefs = Object.keys(field.refComponentInfos).filter(refId => + ids.includes(refId as ComponentId) + ); + relyRefs.forEach(refId => { + res.push({ + componentId: c.id, + traitType, + exp: field.getValue() as string, + key, + valuePath: + field.refComponentInfos[refId as ComponentId].refProperties.slice(-1)[0], + relyOn: refId, + }); + }); + } + }; + // traverse all the expressions of all outisde components and traits + c.properties.traverse((field, key) => { + handler(field, key); + c.traits.forEach(t => { + t.properties.traverse((field, key) => { + handler(field, key, t.type); + }); + }); + }); + }); + return res; +} + +export function getInsideRelations( + component: IComponentModel, + moduleComponents: IComponentModel[] +) { + const expressionRelations: InsideExpRelation[] = []; + const methodRelations: InsideMethodRelation[] = []; + const ids = moduleComponents.map(c => c.id) as string[]; + + const handler = (field: IFieldModel, key: string, traitType?: string) => { + if (field.isDynamic) { + const usedIds = Object.keys(field.refComponentInfos); + usedIds.forEach(usedId => { + // ignore global vraiable and sunmao keywords + if ( + !ids.includes(usedId) && + !(usedId in window) && + !ExpressionKeywords.includes(usedId) + ) { + expressionRelations.push({ + traitType: traitType, + source: component.id, + componentId: usedId, + exp: field.rawValue, + key: key, + }); + } + }); + } + }; + + component.properties.traverse((field, key) => { + handler(field, key); + }); + + component.traits.forEach(t => { + t.properties.traverse((field, key) => { + handler(field, key, t.type); + }); + + // check event traits, see if component call outside methods + if (t.type === `${CORE_VERSION}/${CoreTraitName.Event}`) { + t.rawProperties.handlers.forEach((h: Static) => { + if (!ids.includes(h.componentId)) { + methodRelations.push({ + source: component.id, + event: h.type, + target: h.componentId, + method: h.method.name, + handler: h, + }); + } + }); + } + }); + + return { expressionRelations, methodRelations }; +} diff --git a/packages/editor/src/components/StructureTree/ComponentNode.tsx b/packages/editor/src/components/StructureTree/ComponentNode.tsx index 1d5825ca0..1e34183ea 100644 --- a/packages/editor/src/components/StructureTree/ComponentNode.tsx +++ b/packages/editor/src/components/StructureTree/ComponentNode.tsx @@ -20,6 +20,7 @@ import { ComponentId } from '../../AppModel/IAppModel'; import { RootId } from '../../constants'; import { RelationshipModal } from '../RelationshipModal'; import { ExplorerMenuTabs } from '../../constants/enum'; +import { ExtractModuleModal } from '../ExtractModuleModal'; const IndextWidth = 24; @@ -52,6 +53,7 @@ const ComponentNodeImpl = (props: Props) => { } = props; const { registry, eventBus, appModelManager, editorStore } = services; const [isShowRelationshipModal, setIsShowRelationshipModal] = useState(false); + const [isShowExtractModuleModal, setIsShowExtractModuleModal] = useState(false); const slots = Object.keys(registry.getComponentByType(component.type).spec.slots); const paddingLeft = depth * IndextWidth; @@ -101,6 +103,10 @@ const ComponentNodeImpl = (props: Props) => { }, [component.id, editorStore] ); + const onClickExtractToModule = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setIsShowExtractModuleModal(true); + }, []); const onClickItem = useCallback(() => { onSelectComponent(component.id); @@ -183,6 +189,9 @@ const ComponentNodeImpl = (props: Props) => { } onClick={onClickShowState}> Show State + } onClick={onClickExtractToModule}> + Extract to Module + } color="red.500" onClick={onClickRemove}> Remove @@ -198,6 +207,14 @@ const ComponentNodeImpl = (props: Props) => { /> ) : null; + const extractModuleModal = isShowExtractModuleModal ? ( + setIsShowExtractModuleModal(false)} + /> + ) : null; + return ( { {emptyChildrenSlotsPlaceholder} {relationshipViewModal} + {extractModuleModal} ); }; diff --git a/packages/editor/src/operations/branch/component/extractModuleBranchOperation.ts b/packages/editor/src/operations/branch/component/extractModuleBranchOperation.ts new file mode 100644 index 000000000..5e8154f14 --- /dev/null +++ b/packages/editor/src/operations/branch/component/extractModuleBranchOperation.ts @@ -0,0 +1,112 @@ +import { AppModel } from '../../../AppModel/AppModel'; +import { + CreateComponentLeafOperation, + ModifyComponentPropertiesLeafOperation, + ModifyTraitPropertiesLeafOperation, +} from '../../leaf'; +import { BaseBranchOperation } from '../../type'; +import { EventHandlerSpec } from '@sunmao-ui/shared'; +import { Static } from '@sinclair/typebox'; +import { MoveComponentBranchOperation, RemoveComponentBranchOperation } from '..'; +import { ComponentId, SlotName } from '../../../AppModel/IAppModel'; + +type TraitNewProperties = { + componentId: string; + traitIndex: number; + properties: Record; +}; +export type ExtractModuleBranchOperationContext = { + moduleContainerId: string; + moduleContainerProperties: Record; + moduleId: string; + moduleRootId: string; + moduleType: string; + moduleHandlers: Static[]; + outsideComponentNewProperties: Record; + outsideTraitNewProperties: TraitNewProperties[]; + toDeleteComponentIds: string[]; +}; + +export class ExtractModuleBranchOperation extends BaseBranchOperation { + do(prev: AppModel): AppModel { + const root = prev.getComponentById(this.context.moduleRootId as ComponentId); + if (!root) { + console.warn('component not found'); + return prev; + } + // create module container component + this.operationStack.insert( + new CreateComponentLeafOperation(this.registry, { + componentId: this.context.moduleContainerId, + componentType: `core/v1/moduleContainer`, + }) + ); + + // add properties to module container component + this.operationStack.insert( + new ModifyComponentPropertiesLeafOperation(this.registry, { + componentId: this.context.moduleContainerId, + properties: { + id: this.context.moduleId, + type: this.context.moduleType, + properties: this.context.moduleContainerProperties, + handlers: this.context.moduleHandlers, + }, + }) + ); + + // move module container to the position of root + this.operationStack.insert( + new MoveComponentBranchOperation(this.registry, { + fromId: this.context.moduleContainerId, + toId: root.parentId as ComponentId, + slot: root.parentSlot as SlotName, + targetId: root.id, + direction: 'next', + }) + ); + + // update the properties of outside components + for (const id in this.context.outsideComponentNewProperties) { + this.operationStack.insert( + new ModifyComponentPropertiesLeafOperation(this.registry, { + componentId: id, + properties: this.context.outsideComponentNewProperties[id], + }) + ); + } + // update the properties of outside components' trait + this.context.outsideTraitNewProperties.forEach( + ({ componentId, traitIndex, properties }) => { + this.operationStack.insert( + new ModifyTraitPropertiesLeafOperation(this.registry, { + componentId, + traitIndex, + properties, + }) + ); + } + ); + + // remove the module root component + this.operationStack.insert( + new RemoveComponentBranchOperation(this.registry, { + componentId: this.context.moduleRootId, + }) + ); + + // remove other components which are moved in to module + this.context.toDeleteComponentIds.forEach(id => { + this.operationStack.insert( + new RemoveComponentBranchOperation(this.registry, { + componentId: id, + }) + ); + }); + + return this.operationStack.reduce((prev, node) => { + prev = node.do(prev); + return prev; + }, prev); + } +} diff --git a/packages/editor/src/operations/branch/index.ts b/packages/editor/src/operations/branch/index.ts index 2baf398f4..aa129d21c 100644 --- a/packages/editor/src/operations/branch/index.ts +++ b/packages/editor/src/operations/branch/index.ts @@ -2,4 +2,5 @@ export * from './component/createComponentBranchOperation'; export * from './component/modifyComponentIdBranchOperation'; export * from './component/removeComponentBranchOperation'; export * from './component/moveComponentBranchOperation'; +export * from './component/extractModuleBranchOperation'; export * from './datasource/createDataSourceBranchOperation'; diff --git a/packages/editor/src/operations/index.ts b/packages/editor/src/operations/index.ts index 87349aa33..1fa22356e 100644 --- a/packages/editor/src/operations/index.ts +++ b/packages/editor/src/operations/index.ts @@ -8,6 +8,8 @@ import { RemoveComponentBranchOperationContext, MoveComponentBranchOperation, MoveComponentBranchOperationContext, + ExtractModuleBranchOperation, + ExtractModuleBranchOperationContext, CreateDataSourceBranchOperation, CreateDataSourceBranchOperationContext, } from './branch'; @@ -35,7 +37,7 @@ export const OperationConstructors: Record< > = { createComponent: CreateComponentBranchOperation, removeComponent: RemoveComponentBranchOperation, - modifyComponentProperty: ModifyComponentPropertiesLeafOperation, + modifyComponentProperties: ModifyComponentPropertiesLeafOperation, modifyComponentId: ModifyComponentIdBranchOperation, adjustComponentOrder: AdjustComponentOrderLeafOperation, createTrait: CreateTraitLeafOperation, @@ -44,6 +46,7 @@ export const OperationConstructors: Record< replaceApp: ReplaceAppLeafOperation, pasteComponent: PasteComponentLeafOperation, moveComponent: MoveComponentBranchOperation, + extractModule: ExtractModuleBranchOperation, createDataSource: CreateDataSourceBranchOperation, }; @@ -64,7 +67,7 @@ export type OperationConfigMaps = { RemoveComponentBranchOperation, RemoveComponentBranchOperationContext >; - modifyComponentProperty: OperationConfigMap< + modifyComponentProperties: OperationConfigMap< ModifyComponentPropertiesLeafOperation, ModifyComponentPropertiesLeafOperationContext >; @@ -98,6 +101,10 @@ export type OperationConfigMaps = { MoveComponentBranchOperation, MoveComponentBranchOperationContext >; + extractModule: OperationConfigMap< + ExtractModuleBranchOperation, + ExtractModuleBranchOperationContext + >; createDataSource: OperationConfigMap< CreateDataSourceBranchOperation, CreateDataSourceBranchOperationContext diff --git a/packages/editor/src/operations/leaf/component/modifyComponentPropertyLeafOperation.ts b/packages/editor/src/operations/leaf/component/modifyComponentPropertyLeafOperation.ts new file mode 100644 index 000000000..ac17cb23a --- /dev/null +++ b/packages/editor/src/operations/leaf/component/modifyComponentPropertyLeafOperation.ts @@ -0,0 +1,53 @@ +import { BaseLeafOperation } from '../../type'; +import { AppModel } from '../../../AppModel/AppModel'; +import { ComponentId } from '../../../AppModel/IAppModel'; +export type ModifyComponentPropertyLeafOperationContext = { + componentId: string; + path: string; + property: any; +}; + +export class ModifyComponentPropertyLeafOperation extends BaseLeafOperation { + private previousValue: any = undefined; + do(prev: AppModel): AppModel { + const component = prev.getComponentById(this.context.componentId as ComponentId); + if (component) { + const oldValue = component.properties.getPropertyByPath(this.context.path); + if (oldValue) { + // assign previous data + this.previousValue = oldValue; + const newValue = this.context.property; + oldValue.update(newValue); + } else { + console.warn('property not found'); + } + } else { + console.warn('component not found'); + } + + return prev; + } + + redo(prev: AppModel): AppModel { + const component = prev.getComponentById(this.context.componentId as ComponentId); + if (!component) { + console.warn('component not found'); + return prev; + } + const newValue = this.context.property; + component.properties.getPropertyByPath(this.context.path)!.update(newValue); + return prev; + } + + undo(prev: AppModel): AppModel { + const component = prev.getComponentById(this.context.componentId as ComponentId); + if (!component) { + console.warn('component not found'); + return prev; + } + + component.properties.getPropertyByPath(this.context.path)!.update(this.previousValue); + + return prev; + } +} diff --git a/packages/editor/src/services/AppStorage.ts b/packages/editor/src/services/AppStorage.ts index c2c9b6ec3..257efd478 100644 --- a/packages/editor/src/services/AppStorage.ts +++ b/packages/editor/src/services/AppStorage.ts @@ -1,5 +1,11 @@ import { observable, makeObservable, action, toJS } from 'mobx'; -import { Application, ComponentSchema, Module, RuntimeModule } from '@sunmao-ui/core'; +import { + Application, + ComponentSchema, + Module, + parseVersion, + RuntimeModule, +} from '@sunmao-ui/core'; import { produce } from 'immer'; import { DefaultNewModule, EmptyAppSchema } from '../constants'; import { addModuleId, removeModuleId } from '../utils/addModuleId'; @@ -33,7 +39,24 @@ export class AppStorage { }); } - createModule() { + createModule(props: { + components?: ComponentSchema[]; + propertySpec?: JSONSchema7; + exampleProperties?: Record; + events?: string[]; + moduleVersion?: string; + moduleName?: string; + stateMap?: Record; + }): Module { + const { + components, + propertySpec, + exampleProperties, + events, + moduleVersion, + moduleName, + stateMap, + } = props; let index = this.modules.length; this.modules.forEach(module => { @@ -45,19 +68,46 @@ export class AppStorage { const name = `myModule${index}`; const newModule: RuntimeModule = { ...DefaultNewModule, - parsedVersion: { - ...DefaultNewModule.parsedVersion, - value: name, - }, + version: moduleVersion || DefaultNewModule.version, + parsedVersion: moduleVersion + ? parseVersion(moduleVersion) + : DefaultNewModule.parsedVersion, metadata: { ...DefaultNewModule.metadata, - name, + name: moduleName || name, }, }; + if (components) { + newModule.impl = components; + } + + if (propertySpec) { + newModule.spec.properties = propertySpec; + } + if (exampleProperties) { + for (const key in exampleProperties) { + const value = exampleProperties[key]; + if (typeof value === 'string') { + newModule.metadata.exampleProperties![key] = value; + } else { + // save value as expression + newModule.metadata.exampleProperties![key] = `{{${JSON.stringify(value)}}}`; + } + } + } + if (events) { + newModule.spec.events = events; + } + if (stateMap) { + newModule.spec.stateMap = stateMap; + } + this.setModules([...this.modules, newModule]); - this.setRawModules(this.modules.map(addModuleId)); + const rawModules = this.modules.map(addModuleId); + this.setRawModules(rawModules); this.saveModules(); + return rawModules[rawModules.length - 1]; } removeModule(v: string, n: string) { diff --git a/packages/editor/src/services/EditorStore.ts b/packages/editor/src/services/EditorStore.ts index 37be8305c..cc277ed88 100644 --- a/packages/editor/src/services/EditorStore.ts +++ b/packages/editor/src/services/EditorStore.ts @@ -1,13 +1,13 @@ import { action, makeAutoObservable, observable, reaction, toJS } from 'mobx'; import { ComponentSchema, createModule } from '@sunmao-ui/core'; import { RegistryInterface, StateManagerInterface } from '@sunmao-ui/runtime'; +import { isEqual } from 'lodash'; import { EventBusType } from './eventBus'; import { AppStorage } from './AppStorage'; import type { SchemaValidator, ValidateErrorResult } from '../validator'; import { ExplorerMenuTabs, ToolMenuTabs } from '../constants/enum'; -import { isEqual } from 'lodash'; import { AppModelManager } from '../operations/AppModelManager'; import type { Metadata } from '@sunmao-ui/core'; @@ -34,6 +34,7 @@ export class EditorStore { currentEditingTarget: EditingTarget = { kind: 'app', version: '', + name: '', }; @@ -115,6 +116,18 @@ export class EditorStore { } ); + reaction( + () => this.rawModules, + () => { + // Remove old modules and re-register all modules, + this.registry.unregisterAllModules(); + this.rawModules.forEach(m => { + const modules = createModule(m); + this.registry.registerModule(modules, true); + }); + } + ); + reaction( () => this.components, () => { diff --git a/packages/runtime/src/components/_internal/ModuleRenderer.tsx b/packages/runtime/src/components/_internal/ModuleRenderer.tsx index a451f4f92..acc682b8f 100644 --- a/packages/runtime/src/components/_internal/ModuleRenderer.tsx +++ b/packages/runtime/src/components/_internal/ModuleRenderer.tsx @@ -21,6 +21,7 @@ type Props = Static & { evalScope?: Record; services: UIServices; app: RuntimeApplication; + className?: string; }; export const ModuleRenderer = React.forwardRef((props, ref) => { @@ -37,7 +38,7 @@ const ModuleRendererContent = React.forwardRef< HTMLDivElement, Props & { moduleSpec: ImplementedRuntimeModule } >((props, ref) => { - const { moduleSpec, properties, handlers, evalScope, services, app } = props; + const { moduleSpec, properties, handlers, evalScope, services, app, className } = props; const moduleId = services.stateManager.deepEval(props.id, { scopeObject: evalScope, }) as string | ExpressionError; @@ -166,7 +167,7 @@ const ModuleRendererContent = React.forwardRef< }, [evaledModuleTemplate, services, app]); return ( -
+
{result}
); diff --git a/packages/runtime/src/components/core/ModuleContainer.tsx b/packages/runtime/src/components/core/ModuleContainer.tsx index b8b94e94c..94e4160c9 100644 --- a/packages/runtime/src/components/core/ModuleContainer.tsx +++ b/packages/runtime/src/components/core/ModuleContainer.tsx @@ -2,6 +2,7 @@ import { implementRuntimeComponent } from '../../utils/buildKit'; import { ModuleRenderSpec, CORE_VERSION, CoreComponentName } from '@sunmao-ui/shared'; import { ModuleRenderer } from '../_internal/ModuleRenderer'; import React from 'react'; +import { css } from '@emotion/css'; export default implementRuntimeComponent({ version: CORE_VERSION, @@ -22,10 +23,10 @@ export default implementRuntimeComponent({ state: {}, methods: {}, slots: {}, - styleSlots: [], + styleSlots: ['content'], events: [], }, -})(({ id, type, properties, handlers, services, app, elementRef }) => { +})(({ id, type, properties, handlers, services, app, elementRef, customStyle }) => { if (!type) { return Please choose a module to render.; } @@ -36,6 +37,9 @@ export default implementRuntimeComponent({ return ( ; overrideScope?: boolean; - fallbackWhenError?: (exp: string) => any; + fallbackWhenError?: (exp: string, err: Error) => any; // when ignoreEvalError is true, the eval process will continue after error happens in nests expression. ignoreEvalError?: boolean; slotKey?: string; @@ -128,7 +128,9 @@ export class StateManager { consoleError(ConsoleType.Expression, raw, expressionError.message); } - return fallbackWhenError ? fallbackWhenError(raw) : expressionError; + return fallbackWhenError + ? fallbackWhenError(raw, expressionError) + : expressionError; } return undefined; } @@ -169,18 +171,10 @@ export class StateManager { options: EvalOptions = {} ): EvaledResult { const store = this.slotStore; - const redirector = new Proxy( - {}, - { - get(_, prop) { - return options.slotKey ? store[options.slotKey][prop] : undefined; - }, - } - ); options.scopeObject = { ...options.scopeObject, - $slot: redirector, + $slot: options.slotKey ? store[options.slotKey] : undefined, }; // just eval if (typeof value !== 'string') { @@ -208,17 +202,9 @@ export class StateManager { : PropsAfterEvaled>; const store = this.slotStore; - const redirector = new Proxy( - {}, - { - get(_, prop) { - return options.slotKey ? store[options.slotKey][prop] : undefined; - }, - } - ); options.scopeObject = { ...options.scopeObject, - $slot: redirector, + $slot: options.slotKey ? store[options.slotKey] : undefined, }; // watch change if (value && typeof value === 'object') { diff --git a/packages/runtime/src/utils/runEventHandler.ts b/packages/runtime/src/utils/runEventHandler.ts index 115bcaa69..702bfa1f5 100644 --- a/packages/runtime/src/utils/runEventHandler.ts +++ b/packages/runtime/src/utils/runEventHandler.ts @@ -1,6 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { debounce, throttle, delay } from 'lodash'; -import { EventCallBackHandlerSpec } from '@sunmao-ui/shared'; +import { EventCallBackHandlerSpec, MODULE_ID_EXP } from '@sunmao-ui/shared'; import { type PropsBeforeEvaled } from '@sunmao-ui/core'; import { UIServices } from '../types'; @@ -18,6 +18,10 @@ export const runEventHandler = ( // Eval before sending event to assure the handler object is evaled from the latest state. const evalOptions = { slotKey, + // keep MODULE_ID_EXP when error + fallbackWhenError(exp: string, err: Error) { + return exp === MODULE_ID_EXP ? exp : err; + }, }; const evaledHandlers = stateManager.deepEval(rawHandlers, evalOptions) as Static< typeof EventCallBackHandlerSpec diff --git a/packages/shared/src/constants/expression.ts b/packages/shared/src/constants/expression.ts index 8de94eca3..4435aeb11 100644 --- a/packages/shared/src/constants/expression.ts +++ b/packages/shared/src/constants/expression.ts @@ -7,6 +7,7 @@ export const LIST_ITEM_INDEX_EXP = '$i'; export const SLOT_PROPS_EXP = '$slot'; export const GLOBAL_UTIL_METHOD_ID = '$utils'; export const GLOBAL_MODULE_ID = '$module'; +export const MODULE_ID_EXP = '{{$moduleId}}'; export const ExpressionKeywords = [ LIST_ITEM_EXP, diff --git a/packages/shared/src/specs/module.ts b/packages/shared/src/specs/module.ts index 389f747b7..7d6649db3 100644 --- a/packages/shared/src/specs/module.ts +++ b/packages/shared/src/specs/module.ts @@ -1,6 +1,7 @@ import { EventHandlerSpec } from './event'; import { Type } from '@sinclair/typebox'; import { CORE_VERSION, CoreWidgetName } from '../constants/core'; +import { MODULE_ID_EXP } from '../constants'; export const ModuleRenderSpec = Type.Object( { @@ -27,3 +28,7 @@ export const ModuleRenderSpec = Type.Object( widget: 'core/v1/module', } ); + +export const ModuleEventMethodSpec = Type.Object({ + moduleId: Type.Literal(MODULE_ID_EXP), +});