Merge branch 'feat/extract-module' into publish

* feat/extract-module:
  feat(editor): add extract module feature
  feat(editor): add view state button in component action menu
  refactor(module): the type of events is rolled back to string[]
  fix(ModuleWidget): no need to check if it is an expression
  style(RecordEditor): change the background of the record wiget's key
  style(EditorMask): change the z index value of the editor mask
  feat: add the default values of properties for the core traits
  fix(module): fix type error
  refactor(ModuleWidget): spec generation inferred from example properties
  refactor(Editor): refactoring module forms
  feat: support default value for property spec
  fix(arco): fix incorrect path import
  fix(Editor): fix modal going out of the middle area
  fix(localStorage): set initial value when value is empty
  feat: display the `$slot` hint
  feat(editor): add Component Relationship View Modal

# Conflicts:
#	packages/editor/src/components/StructureTree/ComponentNode.tsx
This commit is contained in:
Bowen Tan 2023-01-17 16:41:54 +08:00
commit 5725cb7481
55 changed files with 1890 additions and 325 deletions

View File

@ -6,7 +6,7 @@ import { FALLBACK_METADATA, getComponentProps } from '../sunmao-helper';
import { NumberInputPropsSpec as BaseNumberInputPropsSpec } from '../generated/types/NumberInput';
import { useEffect, useRef } from 'react';
import { RefInputType } from '@arco-design/web-react/es/Input/interface';
import { useStateValue } from 'src/hooks/useStateValue';
import { useStateValue } from '../hooks/useStateValue';
const InputPropsSpec = Type.Object({
...BaseNumberInputPropsSpec,

View File

@ -4,7 +4,7 @@ import { css } from '@emotion/css';
import { Type, Static } from '@sinclair/typebox';
import { FALLBACK_METADATA, getComponentProps } from '../sunmao-helper';
import { PasswordInputPropsSpec as BasePasswordInputPropsSpec } from '../generated/types/PasswordInput';
import { useStateValue } from 'src/hooks/useStateValue';
import { useStateValue } from '../hooks/useStateValue';
const InputPropsSpec = Type.Object({
...BasePasswordInputPropsSpec,

View File

@ -43,12 +43,6 @@ export function createModule(options: CreateModuleOptions): RuntimeModule {
exampleProperties: options.metadata.exampleProperties || {},
},
spec: {
// `json-schema-editor` has a readonly root object by default,
// it provides two schema formats,array({type:'array'}) and object({type:'object'}).
// In sunmao, we only use the object schema, so we need to specify a default value here
// and silently fail when root selects array.
// This is a bit obscure, so should remove the array type of root from the json-schema-editor later
// TODO remove the array type of root from the json-schema-editor
properties: { type: 'object' },
events: [],
stateMap: {},

View File

@ -20,6 +20,7 @@ import { mergeWidgetOptionsIntoSpec } from '../../utils/widget';
import { ExpressionEditorProps } from './ExpressionEditor';
import { generateDefaultValueFromSpec } from '@sunmao-ui/shared';
import { JSONSchema7 } from 'json-schema';
import { css } from '@emotion/css';
const IGNORE_SPEC_TYPES = ['array', 'object'];
@ -127,6 +128,11 @@ const RowItem = (props: RowItemProps) => {
return (
<HStack spacing="1" display="flex" alignItems="stretch">
<Textarea
className={css`
&&&:focus {
background: var(--chakra-colors-gray-100);
}
`}
resize="none"
rows={1}
paddingTop="6px"

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>> {
@ -144,6 +110,7 @@ export const ExpressionWidgetOptionsSpec = Type.Object({
});
type ExpressionWidgetType = `${typeof CORE_VERSION}/${CoreWidgetName.Expression}`;
type Container = { id: string; slot: string };
declare module '../../types/widget' {
interface WidgetOptionsMap {
'core/v1/expression': Static<typeof ExpressionWidgetOptionsSpec>;
@ -151,7 +118,7 @@ declare module '../../types/widget' {
}
export const ExpressionWidget: React.FC<WidgetProps<ExpressionWidgetType>> = props => {
const { value, services, spec, onChange } = props;
const { value, services, spec, component, onChange } = props;
const { widgetOptions } = spec;
const { stateManager } = services;
const code = useMemo(() => getCode(value), [value]);
@ -163,6 +130,25 @@ 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(() => {
if (component.traits) {
return component.traits.find(trait =>
['core/v1/slot', 'core/v2/slot'].includes(trait.type)
);
}
return undefined;
}, [component]);
const $slot = useMemo(
() =>
slotTrait
? Object.entries(services.stateManager.slotStore).find(([key]) => {
const { id, slot } = slotTrait.properties.container as Container;
return key.includes(`${id}_${slot}`);
})?.[1]
: null,
[services.stateManager.slotStore, slotTrait]
);
const evalCode = useCallback(
async (code: string) => {
@ -219,8 +205,8 @@ export const ExpressionWidget: React.FC<WidgetProps<ExpressionWidgetType>> = pro
}, [code, evalCode]);
useEffect(() => {
setDefs([customTreeTypeDefCreator(stateManager.store)]);
}, [stateManager]);
setDefs([customTreeTypeDefCreator({ ...stateManager.store, $slot })]);
}, [stateManager, $slot]);
useEffect(() => {
editorRef.current?.setCode(code);
}, [code]);

View File

@ -5,6 +5,9 @@ import { implementWidget } from '../../utils/widget';
import { SpecWidget } from './SpecWidget';
import { CORE_VERSION, CoreWidgetName, isJSONSchema } from '@sunmao-ui/shared';
import { css } from '@emotion/css';
import { mapValues } from 'lodash';
import type { JSONSchema7 } from 'json-schema';
import { json2JsonSchema } from '../../utils/type';
const LabelStyle = css`
font-weight: normal;
@ -65,6 +68,16 @@ 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);
return json2JsonSchema(result);
});
return { type: 'object', properties: obj };
}, [module?.metadata.exampleProperties, services.stateManager]);
return (
<Box p="2" border="1px solid" borderColor="gray.200" borderRadius="4">
<SpecWidget
@ -99,7 +112,7 @@ export const ModuleWidget: React.FC<WidgetProps<ModuleWidgetType>> = props => {
<SpecWidget
component={component}
spec={{
...module.spec.properties,
...modulePropertiesSpec,
title: 'Module Properties',
}}
path={[]}

View File

@ -23,7 +23,11 @@ const EnumField: React.FC<WidgetProps> = props => {
const options = (spec.enum || []).map(item => item?.toString() || '');
return (
<Select value={value} onChange={evt => onChange(evt.currentTarget.value)}>
<Select
value={value}
onChange={evt => onChange(evt.currentTarget.value)}
placeholder="Select option"
>
{options.map((value, idx) => {
return <option key={idx}>{value}</option>;
})}

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

@ -5,6 +5,7 @@ import { JSONTree } from 'react-json-tree';
import { pickBy } from 'lodash';
import { watch } from '@sunmao-ui/runtime';
import ErrorBoundary from '../ErrorBoundary';
import { EditorServices } from '../../types';
const theme = {
base0A: '#fded02',
@ -34,7 +35,13 @@ const style = css`
}
`;
export const StateViewer: React.FC<{ store: Record<string, unknown> }> = ({ store }) => {
type Props = {
store: Record<string, unknown>;
services: EditorServices;
};
export const StateViewer: React.FC<Props> = ({ store, services }) => {
const { viewStateComponentId, setViewStateComponentId } = services.editorStore;
const [filterText, setFilterText] = useState('');
const [refreshFlag, setRefreshFlag] = useState(0);
const data = useMemo(() => {
@ -52,6 +59,13 @@ export const StateViewer: React.FC<{ store: Record<string, unknown> }> = ({ stor
return stop;
}, [store]);
useEffect(() => {
if (viewStateComponentId) {
setFilterText(viewStateComponentId);
setViewStateComponentId('');
}
}, [setViewStateComponentId, viewStateComponentId]);
return (
<ErrorBoundary>
<Box height="100%" className={style} display="flex" flexDirection="column">
@ -60,7 +74,15 @@ export const StateViewer: React.FC<{ store: Record<string, unknown> }> = ({ stor
value={filterText}
onChange={evt => setFilterText(evt.currentTarget.value)}
/>
<JSONTree data={data} theme={theme} hideRoot sortObjectKeys />
<JSONTree
data={data}
theme={theme}
hideRoot
sortObjectKeys
shouldExpandNode={keyPath => {
return keyPath[0] === filterText;
}}
/>
</Box>
</ErrorBoundary>
);

View File

@ -3,7 +3,6 @@ import { observer } from 'mobx-react-lite';
import { Accordion, Input, Text, VStack } from '@chakra-ui/react';
import { ComponentFormElementId, SpecWidget } from '@sunmao-ui/editor-sdk';
import { parseType } from '@sunmao-ui/core';
import { generateDefaultValueFromSpec } from '@sunmao-ui/shared';
import { css } from '@emotion/css';
import { EventTraitForm } from './EventTraitForm';
@ -47,10 +46,7 @@ export const ComponentForm: React.FC<Props> = observer(props => {
}
const { version, name } = parseType(selectedComponent.type);
const cImpl = registry.getComponent(version, name);
const properties = Object.assign(
generateDefaultValueFromSpec(cImpl.spec.properties)!,
selectedComponent.properties
);
const properties = selectedComponent.properties;
const changeComponentId = (selectedComponentId: string, value: string) => {
eventBus.send(
@ -100,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

@ -3,7 +3,6 @@ import { ComponentSchema, TraitSchema } from '@sunmao-ui/core';
import { HStack, IconButton, VStack } from '@chakra-ui/react';
import { CloseIcon } from '@chakra-ui/icons';
import { SpecWidget } from '@sunmao-ui/editor-sdk';
import { generateDefaultValueFromSpec } from '@sunmao-ui/shared';
import { formWrapperCSS } from '../style';
import { EditorServices } from '../../../types';
import { genOperation } from '../../../operations';
@ -21,10 +20,7 @@ export const GeneralTraitForm: React.FC<Props> = props => {
const { registry, eventBus } = services;
const tImpl = registry.getTraitByType(trait.type);
const properties = Object.assign(
generateDefaultValueFromSpec(tImpl.spec.properties)!,
trait.properties
);
const properties = trait.properties;
const onChange = (newValue: any) => {
const operation = genOperation(registry, 'modifyTraitProperty', {
componentId: component.id,

View File

@ -20,10 +20,11 @@ export const GeneralTraitFormList: React.FC<Props> = props => {
const { eventBus, registry } = services;
const onAddTrait = (type: string) => {
const traitSpec = registry.getTraitByType(type).spec;
const initProperties = generateDefaultValueFromSpec(
traitSpec.properties
) as JSONSchema7Object;
const traitDefine = registry.getTraitByType(type);
const traitSpec = traitDefine.spec;
const initProperties =
traitDefine.metadata.exampleProperties ||
(generateDefaultValueFromSpec(traitSpec.properties) as JSONSchema7Object);
eventBus.send(
'operation',
genOperation(registry, 'createTrait', {

View File

@ -99,10 +99,13 @@ export const DataSourceList: React.FC<Props> = props => {
);
const onCreateDSFromTrait = useCallback(
(type: string) => {
const propertiesSpec = registry.getTraitByType(type).spec.properties;
const defaultProperties = generateDefaultValueFromSpec(propertiesSpec, {
genArrayItemDefaults: false,
});
const traitDefine = registry.getTraitByType(type);
const propertiesSpec = traitDefine.spec.properties;
const defaultProperties =
traitDefine.metadata.exampleProperties ||
generateDefaultValueFromSpec(propertiesSpec, {
genArrayItemDefaults: false,
});
const name = type.split('/')[2];
const id = getNewId(name);

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 = (
@ -95,6 +98,7 @@ export const Editor: React.FC<Props> = observer(
<Box
id="editor-main"
display="flex"
transform="auto"
flexDirection="column"
width="full"
height="full"
@ -165,7 +169,7 @@ export const Editor: React.FC<Props> = observer(
<DataSourceList services={services} />
</TabPanel>
<TabPanel overflow="auto" p={0} height="100%">
<StateViewer store={stateStore} />
<StateViewer store={stateStore} services={services} />
</TabPanel>
</TabPanels>
</Tabs>

View File

@ -101,7 +101,7 @@ export const EditorMask: React.FC<Props> = observer((props: Props) => {
right="0"
bottom="0"
pointerEvents="none"
zIndex="99999"
zIndex="editorMask"
ref={maskContainerRef}
>
{isDraggingNewComponent ? dragMask : hoverMask}

View File

@ -3,7 +3,15 @@ import ErrorBoundary from '../ErrorBoundary';
import { ExplorerForm } from './ExplorerForm/ExplorerForm';
import { ExplorerTree } from './ExplorerTree';
import { EditorServices } from '../../types';
import { Box } from '@chakra-ui/react';
import {
Box,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
type Props = {
services: EditorServices;
@ -20,30 +28,41 @@ export const Explorer: React.FC<Props> = ({ services }) => {
setCurrentName(name);
setIsEditingMode(true);
};
const onBack = () => {
setIsEditingMode(false);
};
if (isEditingMode) {
return (
<ErrorBoundary>
<Box padding={4}>
<ExplorerForm
formType={formType}
version={currentVersion}
name={currentName}
setCurrentVersion={setCurrentVersion}
setCurrentName={setCurrentName}
onBack={onBack}
services={services}
/>
</Box>
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<Box padding={4}>
<ExplorerTree onEdit={onEdit} services={services} />
<Modal
onClose={() => {
setIsEditingMode(false);
}}
closeOnOverlayClick
isOpen={isEditingMode}
>
<ModalOverlay />
<ModalContent p="10px" maxW="800px">
<ModalCloseButton />
<ModalHeader> {formType === 'app' ? 'Application' : 'Module'}</ModalHeader>
<ModalBody
w="full"
flex="1 1 auto"
height="75vh"
alignItems="start"
overflow="auto"
>
<ExplorerForm
formType={formType}
version={currentVersion}
name={currentName}
setCurrentVersion={setCurrentVersion}
setCurrentName={setCurrentName}
services={services}
onClose={() => setIsEditingMode(false)}
/>
</ModalBody>
</ModalContent>
</Modal>
</Box>
</ErrorBoundary>
);

View File

@ -1,5 +1,12 @@
import React from 'react';
import { FormControl, FormLabel, Input, VStack } from '@chakra-ui/react';
import {
FormControl,
FormErrorMessage,
FormLabel,
HStack,
Input,
VStack,
} from '@chakra-ui/react';
import { useFormik } from 'formik';
import { observer } from 'mobx-react-lite';
import { EditorServices } from '../../../types';
@ -26,26 +33,55 @@ export const AppMetaDataForm: React.FC<AppMetaDataFormProps> = observer(
initialValues: data,
onSubmit,
});
const isAppVersionError = formik.values.version === '';
const isAppNameError = formik.values.name === '';
return (
<VStack>
<FormControl isRequired>
<FormLabel>App Version</FormLabel>
<Input
name="version"
value={formik.values.version}
onChange={formik.handleChange}
onBlur={() => formik.submitForm()}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>App Name</FormLabel>
<Input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={() => formik.submitForm()}
/>
</FormControl>
<VStack w="full" spacing="5">
<HStack w="full" align="normal">
<FormControl isInvalid={isAppVersionError}>
<HStack align="normal">
<FormLabel>Version</FormLabel>
<VStack w="full" align="normal">
<Input
name="version"
value={formik.values.version}
onChange={formik.handleChange}
onBlur={() => {
if (formik.values.version && formik.values.name) {
formik.submitForm();
}
}}
/>
{isAppVersionError && (
<FormErrorMessage>
Application version can not be empty
</FormErrorMessage>
)}
</VStack>
</HStack>
</FormControl>
<FormControl isInvalid={isAppNameError}>
<HStack align="normal">
<FormLabel>Name</FormLabel>
<VStack w="full" align="normal">
<Input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={() => {
if (formik.values.version && formik.values.name) {
formik.submitForm();
}
}}
/>
{isAppNameError && (
<FormErrorMessage>Application name can not be empty</FormErrorMessage>
)}
</VStack>
</HStack>
</FormControl>
</HStack>
</VStack>
);
}

View File

@ -1,11 +1,11 @@
import React from 'react';
import React, { useRef } from 'react';
import { observer } from 'mobx-react-lite';
import { Button, Text, VStack } from '@chakra-ui/react';
import { ArrowLeftIcon } from '@chakra-ui/icons';
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,17 +13,50 @@ type Props = {
name: string;
setCurrentVersion?: (version: string) => void;
setCurrentName?: (name: string) => void;
onBack: () => void;
services: EditorServices;
onClose: () => void;
};
export const ExplorerForm: React.FC<Props> = observer(
({ formType, version, name, setCurrentVersion, setCurrentName, onBack, 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':
@ -32,46 +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 alignItems="start">
<Button
aria-label="go back to tree"
size="sm"
leftIcon={<ArrowLeftIcon />}
variant="ghost"
colorScheme="blue"
onClick={onBack}
padding="0"
>
Back
</Button>
<Text fontSize="lg" fontWeight="bold">
{formType === 'app' ? 'Application' : 'Module'}
</Text>
<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

@ -1,34 +1,28 @@
import React, { Suspense } from 'react';
import React, { useEffect, useState } from 'react';
import {
Box,
Button,
FormControl,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useDisclosure,
VStack,
IconButton,
HStack,
FormErrorMessage,
} from '@chakra-ui/react';
import { RecordEditor, SpecWidget } from '@sunmao-ui/editor-sdk';
import { RecordEditor } from '@sunmao-ui/editor-sdk';
import { useFormik } from 'formik';
import { observer } from 'mobx-react-lite';
import { EditorServices } from '../../../types';
import { generateDefaultValueFromSpec } from '@sunmao-ui/shared';
import { JSONSchema7, JSONSchema7Object } from 'json-schema';
const JsonSchemaEditor = React.lazy(() => import('@optum/json-schema-editor'));
import { CloseIcon } from '@chakra-ui/icons';
import produce from 'immer';
export type ModuleMetaDataFormData = {
name: string;
version: string;
stateMap: Record<string, string>;
properties: JSONSchema7;
events: string[];
exampleProperties: JSONSchema7Object;
};
@ -36,56 +30,115 @@ type ModuleMetaDataFormProps = {
initData: ModuleMetaDataFormData;
services: EditorServices;
onSubmit?: (value: ModuleMetaDataFormData) => void;
disabled?: boolean;
};
const genEventsName = (events: string[]) => {
let count = events.length;
let name = `event${count}`;
while (events.includes(name)) {
name = `event${++count}`;
}
return `event${count}`;
};
const EventInput: React.FC<{
name: string;
events: string[];
index: number;
onChange: (value: string) => void;
}> = ({ name: defaultName, onChange, events, index }) => {
const [name, setName] = useState(defaultName);
const [isRepeated, setIsRepeated] = useState(false);
useEffect(() => {
setName(defaultName);
}, [defaultName]);
return (
<FormControl w="33.33%" isInvalid={isRepeated}>
<Input
name="name"
onChange={e => {
setName(e.target.value);
}}
onBlur={() => {
const newEvents = [...events];
newEvents.splice(index, 1);
if (newEvents.find(eventName => eventName === name)) {
setIsRepeated(true);
return;
}
setIsRepeated(false);
onChange(name);
}}
value={name}
/>
<FormErrorMessage mt="0" pl="10px">
event name already exists
</FormErrorMessage>
</FormControl>
);
};
export const ModuleMetaDataForm: React.FC<ModuleMetaDataFormProps> = observer(
({ initData, services, onSubmit: onSubmitForm }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
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,
});
const moduleSpec = formik.values.properties;
const moduleProperties = {
...(generateDefaultValueFromSpec(moduleSpec) as JSONSchema7Object),
...formik.values.exampleProperties,
};
const moduleSpecs = (moduleSpec.properties || {}) as Record<string, JSONSchema7>;
const isModuleVersionError = formik.values.version === '';
const isModuleNameError = formik.values.name === '';
return (
<VStack>
<FormControl isRequired>
<FormLabel>Module Version</FormLabel>
<Input
name="version"
value={formik.values.version}
onChange={formik.handleChange}
onBlur={() => formik.submitForm()}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Module Name</FormLabel>
<Input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={() => formik.submitForm()}
/>
</FormControl>
<VStack w="full" spacing="5">
<HStack w="full" align="normal">
<FormControl isInvalid={isModuleVersionError}>
<HStack align="normal">
<FormLabel>Version</FormLabel>
<VStack w="full" align="normal">
<Input
name="version"
value={formik.values.version}
onChange={formik.handleChange}
onBlur={() => {
if (formik.values.version && formik.values.name) {
formik.submitForm();
}
}}
/>
{isModuleVersionError && (
<FormErrorMessage>Module version can not be empty</FormErrorMessage>
)}
</VStack>
</HStack>
</FormControl>
<FormControl isInvalid={isModuleNameError}>
<HStack align="normal">
<FormLabel>Name</FormLabel>
<VStack w="full" align="normal">
<Input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={() => {
if (formik.values.version && formik.values.name) {
formik.submitForm();
}
}}
/>
{isModuleNameError && (
<FormErrorMessage>Module name can not be empty</FormErrorMessage>
)}
</VStack>
</HStack>
</FormControl>
</HStack>
<FormControl>
<FormLabel>Module StateMap</FormLabel>
<RecordEditor
@ -98,75 +151,62 @@ export const ModuleMetaDataForm: React.FC<ModuleMetaDataFormProps> = observer(
/>
</FormControl>
<FormControl>
<Button onClick={onOpen}>Edit Spec</Button>
<Modal
isOpen={isOpen}
onClose={onClose}
size="900px"
closeOnEsc={false}
trapFocus={false}
>
<ModalOverlay />
<ModalContent w="900px">
<ModalHeader>Module Spec</ModalHeader>
<ModalCloseButton />
<ModalBody overflow="auto">
{isOpen && (
<Box>
<Suspense fallback="Loading Spec Editor">
<JsonSchemaEditor
data={moduleSpec}
onSchemaChange={s => {
const curSpec = JSON.parse(s);
if (
s === JSON.stringify(moduleSpec) ||
curSpec.type === 'array'
)
return;
formik.setFieldValue('properties', curSpec);
}}
/>
</Suspense>
</Box>
)}
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
onClick={() => {
onClose();
formik.submitForm();
}}
>
Save
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<FormLabel>Example Properties</FormLabel>
<RecordEditor
services={services}
value={formik.values.exampleProperties}
onChange={json => {
formik.setFieldValue('exampleProperties', json);
formik.submitForm();
}}
/>
</FormControl>
<FormControl>
<FormLabel>Properties</FormLabel>
{Object.keys(moduleSpecs).map(key => {
<FormLabel>Events</FormLabel>
{formik.values.events.map((eventName, i) => {
return (
<SpecWidget
key={key}
spec={{ ...moduleSpecs[key], title: moduleSpecs[key].title || key }}
value={moduleProperties[key]}
path={[]}
component={{} as any}
level={1}
services={services}
onChange={newFormData => {
formik.setFieldValue('exampleProperties', {
...moduleProperties,
[key]: newFormData,
});
formik.submitForm();
}}
/>
<HStack m="10px 0 10px 0" alignItems="normal" key={eventName}>
<EventInput
events={formik.values.events}
index={i}
onChange={newName => {
const newEvents = produce(formik.values.events, draft => {
draft[i] = newName;
});
formik.setFieldValue('events', newEvents);
formik.submitForm();
}}
name={eventName}
/>
<IconButton
aria-label="remove row"
icon={<CloseIcon />}
size="xs"
onClick={() => {
const newEvents = produce(formik.values.events, draft => {
draft.splice(i, 1);
});
formik.setFieldValue('events', newEvents);
formik.submitForm();
}}
variant="ghost"
/>
</HStack>
);
})}
<Button
onClick={() => {
const newEvents = produce(formik.values.events, draft => {
draft.push(genEventsName(draft));
});
formik.setFieldValue('events', newEvents);
formik.submitForm();
}}
size="xs"
alignSelf="start"
>
+ Add
</Button>
</FormControl>
</VStack>
);

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,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<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">
<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,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<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">
<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,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<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">
<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,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<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> = {};
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<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: 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 (
<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

@ -19,6 +19,8 @@ import { AppModel } from '../../AppModel/AppModel';
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;
@ -49,8 +51,9 @@ const ComponentNodeImpl = (props: Props) => {
onDragEnd,
prefix,
} = props;
const { registry, eventBus, appModelManager } = services;
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;
@ -92,6 +95,18 @@ const ComponentNodeImpl = (props: Props) => {
e.stopPropagation();
setIsShowRelationshipModal(true);
}, []);
const onClickShowState = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
editorStore.setExplorerMenuTab(ExplorerMenuTabs.STATE);
editorStore.setViewStateComponentId(component.id);
},
[component.id, editorStore]
);
const onClickExtractToModule = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setIsShowExtractModuleModal(true);
}, []);
const onClickItem = useCallback(() => {
onSelectComponent(component.id);
@ -108,11 +123,11 @@ const ComponentNodeImpl = (props: Props) => {
[component.id, onDragEnd]
);
const onMouseOver = useCallback(() => {
services.editorStore.setHoverComponentId(component.id);
}, [component.id, services.editorStore]);
editorStore.setHoverComponentId(component.id);
}, [component.id, editorStore]);
const onMouseLeave = useCallback(() => {
services.editorStore.setHoverComponentId('');
}, [services.editorStore]);
editorStore.setHoverComponentId('');
}, [editorStore]);
const emptySlots = xor(notEmptySlots, slots);
const emptyChildrenSlotsPlaceholder = isExpanded
@ -171,6 +186,12 @@ const ComponentNodeImpl = (props: Props) => {
<MenuItem icon={<ViewIcon />} onClick={onClickShowRelationshipModal}>
Show Relationship
</MenuItem>
<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>
@ -186,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}
@ -243,6 +272,7 @@ const ComponentNodeImpl = (props: Props) => {
</DropComponentWrapper>
{emptyChildrenSlotsPlaceholder}
{relationshipViewModal}
{extractModuleModal}
</VStack>
);
};

View File

@ -12,6 +12,7 @@ import {
extendTheme,
withDefaultSize,
withDefaultVariant,
theme,
} from '@chakra-ui/react';
import { initEventBus } from './services/eventBus';
import { EditorStore } from './services/EditorStore';
@ -28,6 +29,14 @@ type SunmaoUIEditorProps = {
defaultModules?: Module[];
};
const zIndices = {
zIndices: {
...theme.zIndices,
// smaller than the default value of zIndex for chakra modal (1400)
editorMask: 1399,
},
};
export function initSunmaoUIEditor(props: SunmaoUIEditorProps = {}) {
const editorTheme = extendTheme(
withDefaultSize({
@ -45,9 +54,9 @@ export function initSunmaoUIEditor(props: SunmaoUIEditorProps = {}) {
withDefaultVariant({
variant: 'filled',
components: ['Input', 'NumberInput', 'Textarea', 'Select'],
})
}),
zIndices
);
const didMount = () => {
eventBus.send('HTMLElementsUpdated');
if (props.runtimeProps?.hooks?.didMount) props.runtimeProps.hooks.didMount();

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

@ -18,10 +18,13 @@ export type CreateDataSourceBranchOperationContext = {
export class CreateDataSourceBranchOperation extends BaseBranchOperation<CreateDataSourceBranchOperationContext> {
do(prev: AppModel): AppModel {
const { id, type, defaultProperties } = this.context;
const traitSpec = this.registry.getTraitByType(type).spec;
const initProperties = generateDefaultValueFromSpec(traitSpec.properties, {
genArrayItemDefaults: true,
}) as JSONSchema7Object;
const traitDefine = this.registry.getTraitByType(type);
const traitSpec = traitDefine.spec;
const initProperties =
traitDefine.metadata.exampleProperties ||
(generateDefaultValueFromSpec(traitSpec.properties, {
genArrayItemDefaults: true,
}) as JSONSchema7Object);
this.operationStack.insert(
new CreateComponentBranchOperation(this.registry, {

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) {
@ -116,12 +166,14 @@ export class AppStorage {
stateMap,
properties,
exampleProperties,
events,
}: {
version: string;
name: string;
stateMap: Record<string, string>;
properties: JSONSchema7;
exampleProperties: JSONSchema7Object;
events: string[];
}
) {
const i = this.modules.findIndex(
@ -133,6 +185,7 @@ export class AppStorage {
draft[i].spec.stateMap = stateMap;
draft[i].spec.properties = properties;
draft[i].version = version;
draft[i].spec.events = events;
});
this.setModules(newModules);

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';
@ -28,11 +28,13 @@ export class EditorStore {
hoverComponentId = '';
explorerMenuTab = ExplorerMenuTabs.UI_TREE;
toolMenuTab = ToolMenuTabs.INSERT;
viewStateComponentId = '';
validateResult: ValidateErrorResult[] = [];
// current editor editing target(app or module)
currentEditingTarget: EditingTarget = {
kind: 'app',
version: '',
name: '',
};
@ -114,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,
() => {
@ -272,6 +286,10 @@ export class EditorStore {
this.explorerMenuTab = val;
};
setViewStateComponentId = (val: string) => {
this.viewStateComponentId = val;
};
setToolMenuTab = (val: ToolMenuTabs) => {
this.toolMenuTab = val;
};

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

@ -5,7 +5,7 @@ import { CORE_VERSION, CoreTraitName } from '@sunmao-ui/shared';
type KeyValue = { key: string; value: unknown };
export const ArrayStateTraitPropertiesSpec = Type.Object({
key: Type.String(),
key: Type.String({ default: 'value' }),
initialValue: Type.Optional(Type.Array(Type.Any())),
});

View File

@ -65,6 +65,7 @@ function getLocalStorage(
export const LocalStorageTraitPropertiesSpec = Type.Object({
key: Type.String({
title: 'Key',
default: 'value',
}),
initialValue: Type.Any({
title: 'Initial Value',
@ -86,7 +87,6 @@ export default implementRuntimeTrait({
spec: {
properties: LocalStorageTraitPropertiesSpec,
state: Type.Object({
value: Type.Any(),
version: Type.Number(),
}),
methods: [
@ -117,7 +117,7 @@ export default implementRuntimeTrait({
if (key) {
if (!hasInitialized) {
const storageItem = getLocalStorage(hashId, initialValue, { version });
setValue(storageItem?.value, storageItem.version);
setValue(storageItem?.value || initialValue, storageItem.version);
subscribeMethods({
setValue: ({ value: newValue }: { value: any }) => {

View File

@ -7,6 +7,7 @@ type KeyValue = { key: string; value: unknown };
export const StateTraitPropertiesSpec = Type.Object({
key: Type.String({
title: 'Key',
default: 'value',
}),
initialValue: Type.Any({
title: 'Initial Value',

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

View File

@ -34,8 +34,12 @@ function getArray(items: JSONSchema7Definition[], options?: Options): JSONSchema
);
}
function isJSONSchema7Object(value: unknown): value is JSONSchema7Object {
return !!value && typeof value === 'object' && value instanceof Array === false;
}
function getObject(spec: JSONSchema7, options?: Options): JSONSchema7Object | string {
const obj: JSONSchema7Object = {};
const obj: JSONSchema7Object = isJSONSchema7Object(spec.default) ? spec.default : {};
if (spec.allOf && spec.allOf.length > 0) {
return (getArray(spec.allOf, options) as JSONSchema7Object[]).reduce((prev, cur) => {
@ -47,10 +51,10 @@ function getObject(spec: JSONSchema7, options?: Options): JSONSchema7Object | st
// if not specific property, treat it as any type
if (!spec.properties) {
if (options?.returnPlaceholderForAny) {
return AnyTypePlaceholder;
return isJSONSchema7Object(spec.default) ? spec.default : AnyTypePlaceholder;
}
return {};
return isJSONSchema7Object(spec.default) ? spec.default : {};
}
for (const key in spec.properties) {
@ -58,7 +62,8 @@ function getObject(spec: JSONSchema7, options?: Options): JSONSchema7Object | st
if (typeof subSpec === 'boolean') {
obj[key] = null;
} else if (subSpec) {
obj[key] = generateDefaultValueFromSpec(subSpec, options);
obj[key] =
subSpec.default ?? obj[key] ?? generateDefaultValueFromSpec(subSpec, options);
}
}
return obj;
@ -98,14 +103,14 @@ export function generateDefaultValueFromSpec(
}
case spec.type === 'string':
if (spec.enum && spec.enum.length > 0) {
return spec.enum[0];
return spec.default ?? spec.enum[0];
} else {
return '';
return spec.default ?? '';
}
case spec.type === 'boolean':
return false;
return spec.default ?? false;
case spec.type === 'array':
return spec.items
return spec.default ?? spec.items
? Array.isArray(spec.items)
? getArray(spec.items, options)
: isJSONSchema(spec.items)
@ -116,13 +121,13 @@ export function generateDefaultValueFromSpec(
: [];
case spec.type === 'number':
case spec.type === 'integer':
return 0;
return spec.default ?? 0;
case spec.type === 'object':
return getObject(spec, options);
case spec.type === 'null':
return null;
default:
return {};
return spec.default ?? {};
}
}