feat(editor): add extract module feature

This commit is contained in:
Bowen Tan 2023-01-09 18:14:15 +08:00
parent 145df0da75
commit 10bc79556f
38 changed files with 1524 additions and 143 deletions

View File

@ -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<WidgetProps<EventWidgetType>> = 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<string[]>([]);
@ -62,9 +64,22 @@ export const EventWidget: React.FC<WidgetProps<EventWidgetType>> = 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<WidgetProps<EventWidgetType>> = 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<WidgetProps<EventWidgetType>> = 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<WidgetProps<EventWidgetType>> = observer(prop
style={{ width: '100%' }}
value={formik.values.componentId === '' ? undefined : formik.values.componentId}
>
{currentEditingTarget.kind === 'module' ? (
<ComponentTargetSelect.Option key={GLOBAL_MODULE_ID} value={GLOBAL_MODULE_ID}>
{GLOBAL_MODULE_ID}
</ComponentTargetSelect.Option>
) : undefined}
{[{ id: GLOBAL_UTIL_METHOD_ID }].concat(components).map(c => (
<ComponentTargetSelect.Option key={c.id} value={c.id}>
{c.id}

View File

@ -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<string, string | Record<string, unknown>> {
@ -164,13 +130,14 @@ export const ExpressionWidget: React.FC<WidgetProps<ExpressionWidgetType>> = pro
const [error, setError] = useState<string | null>(null);
const editorRef = useRef<ExpressionEditorHandle>(null);
const validateFuncRef = useRef<ValidateFunction | null>(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

View File

@ -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<string, any> = {};
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<WidgetProps<ModuleWidgetType>> = props => {
const { component, value, spec, services, path, level, onChange } = props;
const { registry } = services;
@ -100,13 +71,11 @@ export const ModuleWidget: React.FC<WidgetProps<ModuleWidgetType>> = props => {
const modulePropertiesSpec = useMemo<JSONSchema7>(() => {
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 (

View File

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

View File

@ -1,7 +1,7 @@
type OperationType =
| 'createComponent'
| 'removeComponent'
| 'modifyComponentProperty'
| 'modifyComponentProperties'
| 'modifyComponentId'
| 'adjustComponentOrder'
| 'createTrait'

View File

@ -1,2 +1,3 @@
export * from './widget';
export * from './validator';
export * from './type';

View File

@ -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<string, any> = {};
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;
};

View File

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

View File

@ -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<string, unknown>) => void;
updateSlotTrait: (parent: ComponentId, slot: SlotName) => void;
removeSlotTrait: () => void;
removeChild: (child: IComponentModel) => void;
}

View File

@ -96,7 +96,7 @@ export const ComponentForm: React.FC<Props> = observer(props => {
onChange={newFormData => {
eventBus.send(
'operation',
genOperation(registry, 'modifyComponentProperty', {
genOperation(registry, 'modifyComponentProperties', {
componentId: selectedComponentId,
properties: newFormData,
})

View File

@ -85,9 +85,12 @@ export const Editor: React.FC<Props> = observer(
}
}, [isDisplayApp]);
const onPreview = useCallback(() => setPreview(true), []);
const onRightTabChange = useCallback(activatedTab => {
setToolMenuTab(activatedTab);
}, []);
const onRightTabChange = useCallback(
activatedTab => {
setToolMenuTab(activatedTab);
},
[setToolMenuTab]
);
const renderMain = () => {
const appBox = (

View File

@ -58,6 +58,7 @@ export const Explorer: React.FC<Props> = ({ services }) => {
setCurrentVersion={setCurrentVersion}
setCurrentName={setCurrentName}
services={services}
onClose={() => setIsEditingMode(false)}
/>
</ModalBody>
</ModalContent>

View File

@ -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<Props> = 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<ModuleMetaDataFormData | undefined>();
const newAppMetaDataRef = useRef<AppMetaDataFormData | undefined>();
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<Props> = observer(
version,
};
form = (
<AppMetaDataForm data={appMetaData} services={services} onSubmit={onSubmit} />
<AppMetaDataForm
data={appMetaData}
services={services}
onSubmit={onAppMetaDataChange}
/>
);
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 = (
<ModuleMetaDataForm
services={services}
initData={moduleMetaData}
onSubmit={onSubmit}
onSubmit={onModuleMetaDataChange}
/>
);
break;
}
return (
<VStack w="full" alignItems="start">
<VStack h="full" w="full" alignItems="end">
{form}
<Spacer />
<ButtonGroup flex="0 0">
<Button variant="outline" onClick={() => onClose()}>
Cancel
</Button>
<Button colorScheme="blue" onClick={() => onSave()}>
Save
</Button>
</ButtonGroup>
</VStack>
);
}

View File

@ -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<ModuleMetaDataFormProps> = 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<ModuleMetaDataFormProps> = observer(
/>
</FormControl>
<FormControl>
<FormLabel>Properties</FormLabel>
<FormLabel>Example Properties</FormLabel>
<RecordEditor
services={services}
value={formik.values.exampleProperties}

View File

@ -101,7 +101,7 @@ export const ExplorerTree: React.FC<ExplorerTreeProps> = observer(
aria-label="create module"
size="xs"
icon={<AddIcon />}
onClick={() => editorStore.appStorage.createModule()}
onClick={() => editorStore.appStorage.createModule({})}
/>
</HStack>
{moduleItems}

View File

@ -0,0 +1,98 @@
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<Props> = ({
methodRelations,
services,
}) => {
const { editorStore } = services;
const idLink = useCallback(
(id: string) => {
return (
<Link
size="sm"
onClick={() => {
editorStore.setSelectedComponentId(id);
}}
>
{id}
</Link>
);
},
[editorStore]
);
let content = (
<Placeholder text={`No event handler calls outside components' method.`} />
);
if (methodRelations.length) {
content = (
<Table
size="sm"
border="1px solid"
borderColor="gray.100"
style={{
tableLayout: 'fixed',
}}
>
<Thead>
<Tr>
<Th>Source</Th>
<Th>Event</Th>
<Th>Target</Th>
<Th>Method</Th>
<Th>Module Event Name</Th>
</Tr>
</Thead>
<Tbody>
{methodRelations.map((d, i) => {
return (
<Tr key={i}>
<Td>
<Text color="blue.500">{d.source}</Text>
</Td>
<Td>{d.event}</Td>
<Td>{idLink(d.target)}</Td>
<Td>{d.method}</Td>
<Td fontWeight="bold">{d.source + d.event}</Td>
</Tr>
);
})}
</Tbody>
</Table>
);
}
return (
<VStack width="full" alignItems="start">
<Heading size="md">Module Events</Heading>
<Text>
{`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.`}
</Text>
{content}
</VStack>
);
};

View File

@ -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<Props> = ({
componentId,
onClose,
services,
}) => {
return (
<Modal onClose={onClose} isOpen>
<ModalOverlay />
<ModalContent maxWidth="60vw">
<ModalHeader>
Extract component{' '}
<Text display="inline" color="red.500">
{componentId}
</Text>{' '}
to module
</ModalHeader>
<ModalCloseButton />
<ModalBody flex="1 1 auto" height="75vh" alignItems="start" overflow="auto">
<ExtractModuleView
componentId={componentId}
services={services}
onClose={onClose}
/>
</ModalBody>
</ModalContent>
</Modal>
);
};

View File

@ -0,0 +1,181 @@
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<Props> = ({
insideExpRelations,
onChange,
services,
}) => {
const [showRelationId, setShowRelationId] = useState('');
const [value, setValue] = useState<RefTreatmentMap>({});
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 = <Placeholder text={`No component uses outside components' state.`} />;
if (refIds.length) {
content = (
<VStack width="full" spacing={4}>
{Object.keys(value).map(id => {
return (
<VStack key={id} width="full">
<FormControl as="fieldset" width="full">
<FormLabel>
<Button
variant="link"
colorScheme="blue"
size="sm"
onClick={() => setShowRelationId(id)}
>
{id}
</Button>
</FormLabel>
<RadioGroup
onChange={newValue => {
const next = { ...value, [id]: newValue as any };
setValue(next);
}}
value={value[id]}
>
<HStack>
{RadioOptions.map(o => (
<Radio key={o.value} value={o.value} cursor="pointer">
<Tooltip cursor="pointer" label={o.desc}>
{o.label}
</Tooltip>
</Radio>
))}
</HStack>
</RadioGroup>
</FormControl>
<Table
size="sm"
border="1px solid"
borderColor="gray.100"
width="full"
style={{
tableLayout: 'fixed',
}}
>
<Thead>
<Tr>
<Th>Component Id</Th>
<Th>Property Key</Th>
<Th>Expression</Th>
</Tr>
</Thead>
<Tbody>
{relationGroups[id].map((r, i) => {
return (
<Tr key={i}>
<Td>
<Text color="red.500">
{r.source}
{r.traitType ? `-${r.traitType}` : ''}
</Text>
</Td>
<Td>{r.key}</Td>
<Td>{r.exp}</Td>
</Tr>
);
})}
</Tbody>
</Table>
</VStack>
);
})}
</VStack>
);
}
const relationshipViewModal = showRelationId ? (
<RelationshipModal
componentId={showRelationId}
services={services}
onClose={() => setShowRelationId('')}
/>
) : null;
return (
<>
<VStack width="full" alignItems="start">
<Heading size="md">Module Properties</Heading>
<Text>
These components are used by the components of module, you have to decide how to
treat them.
</Text>
{content}
</VStack>
{relationshipViewModal}
</>
);
};

View File

@ -0,0 +1,103 @@
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<Props> = ({
outsideExpRelations,
onChange,
}) => {
const [value, setValue] = useState<OutsideExpRelationWithState[]>([]);
useEffect(() => {
const newValue = outsideExpRelations.map(r => {
return {
...r,
stateName: `${r.relyOn}${r.valuePath}`,
};
});
setValue(newValue);
}, [outsideExpRelations]);
useEffect(() => {
onChange(value);
}, [onChange, value]);
let content = (
<Placeholder text={`No outside component uses in-module components' state.`} />
);
if (outsideExpRelations.length) {
content = (
<Table
size="sm"
border="1px solid"
borderColor="gray.100"
style={{
tableLayout: 'fixed',
}}
>
<Thead>
<Tr>
<Th>Component</Th>
<Th>Key</Th>
<Th>Expression</Th>
<Th>RawExp</Th>
<Th>StateName</Th>
</Tr>
</Thead>
<Tbody>
{value.map((d, i) => {
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = produce(value, draft => {
draft[i].stateName = e.target.value;
return draft;
});
setValue(newValue);
};
return (
<Tr key={i}>
<Td>
<Text color="blue.500">{d.componentId}</Text>
</Td>
<Td>{d.key}</Td>
<Td>{d.exp}</Td>
<Td>{`${d.relyOn}.${d.valuePath}`}</Td>
<Td>
<Input size="sm" value={d.stateName} onChange={onChange} />
</Td>
</Tr>
);
})}
</Tbody>
</Table>
);
}
return (
<VStack width="full" alignItems="start">
<Heading size="md">Module State</Heading>
<Text>
{`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.`}
</Text>
{content}
</VStack>
);
};

View File

@ -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<Props> = ({ activeIndex }) => {
return (
<List spacing={3} width="150px" flex="0 0 auto">
<ListItem
fontWeight={activeIndex === 0 ? 'bold' : 'normal'}
color={activeIndex > 0 ? 'gray.500' : 'gray.900'}
>
<ListIcon as={activeIndex > 0 ? CheckCircleIcon : SettingsIcon} />
Module Properties
</ListItem>
<ListItem
fontWeight={activeIndex === 1 ? 'bold' : 'normal'}
color={activeIndex > 1 ? 'gray.500' : 'gray.900'}
>
<ListIcon as={activeIndex > 1 ? CheckCircleIcon : SettingsIcon} />
Module State
</ListItem>
<ListItem
fontWeight={activeIndex === 2 ? 'bold' : 'normal'}
color={activeIndex > 2 ? 'gray.500' : 'gray.900'}
>
<ListIcon as={activeIndex > 2 ? CheckCircleIcon : SettingsIcon} />
Module Events
</ListItem>
<ListItem
fontWeight={activeIndex === 3 ? 'bold' : 'normal'}
color={activeIndex > 3 ? 'gray.500' : 'gray.900'}
>
<ListIcon as={activeIndex > 3 ? CheckCircleIcon : SettingsIcon} />
Preview
</ListItem>
</List>
);
};

View File

@ -0,0 +1,362 @@
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<Props> = ({
componentId,
services,
onClose,
}) => {
const { appModelManager } = services;
const { appModel } = appModelManager;
const refTreatmentMap = useRef<RefTreatmentMap>({});
const outsideExpRelationsValueRef = useRef<OutsideExpRelationWithState[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [genModuleResult, serGenModuleResult] = useState<
ReturnType<typeof genModule> | undefined
>();
const [moduleFormInitData, setModuleFormInitData] = useState<
ModuleMetaDataFormData | undefined
>();
const moduleFormValueRef = useRef<ModuleMetaDataFormData | undefined>();
const { insideExpRelations, methodRelations, outsideExpRelations } = useMemo(() => {
const root = appModel.getComponentById(componentId as ComponentId)!;
const moduleComponents = root.allComponents;
const insideRelations = moduleComponents.reduce<InsideRelations>(
(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<string, string> = {};
const moduleContainerProperties: Record<string, string> = {};
let toMoveComponentIds: string[] = [];
let 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;
}
});
toMoveComponentIds = uniq(toMoveComponentIds);
toDeleteComponentIds = uniq(toDeleteComponentIds);
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<string, boolean> = {};
const handlers: Static<typeof EventHandlerSpec>[] = [];
eventTrait?.rawProperties.handlers.forEach(
(h: Static<typeof EventHandlerSpec>) => {
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<string, string> = {};
const outsideComponentNewProperties: Record<string, any> = {};
type TraitNewProperties = {
componentId: string;
traitIndex: number;
properties: Record<string, any>;
};
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,
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 (
<HStack spacing="12" height="full" alignItems="start">
<ExtractModuleStep activeIndex={activeIndex} />
<VStack height="full" width="full" alignItems="end">
<Box width="full" overflow="auto">
<Box width="full" display={activeIndex === 0 ? 'block' : 'none'}>
<ExtractModulePropertyForm
insideExpRelations={insideExpRelations}
onChange={val => (refTreatmentMap.current = val)}
services={services}
/>
</Box>
<Box width="full" display={activeIndex === 1 ? 'block' : 'none'}>
<ExtractModuleStateForm
outsideExpRelations={outsideExpRelations}
onChange={v => {
outsideExpRelationsValueRef.current = v;
}}
/>
</Box>
<Box width="full" display={activeIndex === 2 ? 'block' : 'none'}>
<ExtractModuleEventForm
methodRelations={methodRelations}
services={services}
/>
</Box>
<VStack width="full" display={activeIndex === 3 ? 'block' : 'none'}>
<Heading size="md">Preview Module Spec</Heading>
<Text>
{`The Spec has generated automatically, you don't need to change anything except version and name.`}
</Text>
{activeIndex === 3 && moduleFormInitData ? (
<ModuleMetaDataForm
services={services}
initData={moduleFormInitData}
onSubmit={value => (moduleFormValueRef.current = value)}
/>
) : undefined}
</VStack>
</Box>
<Spacer />
<ButtonGroup flex="0 0">
{activeIndex > 0 ? (
<Button variant="outline" onClick={() => setActiveIndex(v => v - 1)}>
Prev
</Button>
) : undefined}
{activeIndex < 3 ? (
<Button colorScheme="blue" onClick={() => setActiveIndex(v => v + 1)}>
Next
</Button>
) : undefined}
{activeIndex === 3 ? (
<Button colorScheme="blue" onClick={onExtract}>
Extract
</Button>
) : undefined}
</ButtonGroup>
</VStack>
</HStack>
);
};

View File

@ -0,0 +1,17 @@
import { Text } from '@chakra-ui/react';
import React from 'react';
export const Placeholder: React.FC<{ text: string }> = ({ text }) => {
return (
<Text
width="full"
padding="12px"
background="gray.100"
color="gray.500"
borderRadius="4px"
textAlign="center"
>
{text}
</Text>
);
};

View File

@ -0,0 +1 @@
export * from './ExtractModuleModal';

View File

@ -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<string, RefTreatment>;
// in-module components call out-module components' method
export type InsideMethodRelation = {
handler: Static<typeof EventHandlerSpec>;
source: string;
target: string;
event: string;
method: string;
};

View File

@ -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<typeof EventHandlerSpec>) => {
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 };
}

View File

@ -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) => {
<MenuItem icon={<ViewIcon />} onClick={onClickShowState}>
Show State
</MenuItem>
<MenuItem icon={<ViewIcon />} onClick={onClickExtractToModule}>
Extract to Module
</MenuItem>
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={onClickRemove}>
Remove
</MenuItem>
@ -198,6 +207,14 @@ const ComponentNodeImpl = (props: Props) => {
/>
) : null;
const extractModuleModal = isShowExtractModuleModal ? (
<ExtractModuleModal
componentId={component.id}
services={services}
onClose={() => setIsShowExtractModuleModal(false)}
/>
) : null;
return (
<VStack
key={component.id}
@ -255,6 +272,7 @@ const ComponentNodeImpl = (props: Props) => {
</DropComponentWrapper>
{emptyChildrenSlotsPlaceholder}
{relationshipViewModal}
{extractModuleModal}
</VStack>
);
};

View File

@ -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<string, any>;
};
export type ExtractModuleBranchOperationContext = {
moduleContainerId: string;
moduleContainerProperties: Record<string, any>;
moduleId: string;
moduleRootId: string;
moduleType: string;
moduleHandlers: Static<typeof EventHandlerSpec>[];
outsideComponentNewProperties: Record<string, any>;
outsideTraitNewProperties: TraitNewProperties[];
toDeleteComponentIds: string[];
};
export class ExtractModuleBranchOperation extends BaseBranchOperation<ExtractModuleBranchOperationContext> {
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);
}
}

View File

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

View File

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

View File

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

View File

@ -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<string, any>;
events?: string[];
moduleVersion?: string;
moduleName?: string;
stateMap?: Record<string, string>;
}): 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) {

View File

@ -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,
() => {

View File

@ -21,6 +21,7 @@ type Props = Static<typeof ModuleRenderSpec> & {
evalScope?: Record<string, any>;
services: UIServices;
app: RuntimeApplication;
className?: string;
};
export const ModuleRenderer = React.forwardRef<HTMLDivElement, Props>((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 (
<div className="module-container" ref={ref}>
<div className={className} ref={ref}>
{result}
</div>
);

View File

@ -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 <span ref={elementRef}>Please choose a module to render.</span>;
}
@ -36,6 +37,9 @@ export default implementRuntimeComponent({
return (
<ModuleRenderer
id={id}
className={css`
${customStyle?.content}
`}
type={type}
properties={properties}
handlers={handlers}

View File

@ -18,7 +18,7 @@ dayjs.locale('zh-cn');
type EvalOptions = {
scopeObject?: Record<string, any>;
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<T> {
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<Exclude<T, string>>;
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') {

View File

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

View File

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

View File

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