Merge branch 'refactor/datasource2' into publish

* refactor/datasource2:
  feat(DataSource): support create component datasource
  feat(DataSource): add fetch widget
  feat(DataSource): add isDataSource
  feat(DataSource): use ComponentNode to replace DataSourceItem
  feat(PopoverWidget): add appendToParent option
  fix(PopoverWidget): add padding to popover bottom
  feat(PopoverWidget): add appentToBody option
  refactor(editor): flatten ComponentTree

# Conflicts:
#	packages/editor/src/components/StructureTree/ComponentItemView.tsx
#	packages/editor/src/components/StructureTree/ComponentTree.tsx
#	packages/editor/src/components/StructureTree/StructureTree.tsx
#	packages/editor/src/components/StructureTree/type.ts
#	packages/editor/src/components/StructureTree/useStructureTreeState.ts
This commit is contained in:
Bowen Tan 2022-12-07 11:35:23 +08:00
commit fc8e1d309a
43 changed files with 628 additions and 1014 deletions

View File

@ -6,6 +6,7 @@ export type Metadata<
description?: string;
annotations?: Record<string, any> & TAnnotations;
exampleProperties?: TExample;
isDataSource?: boolean;
};
type ComponentCategory =

View File

@ -39,7 +39,7 @@ export function createTrait(options: CreateTraitOptions): RuntimeTrait {
kind: 'Trait' as any,
parsedVersion: parseVersion(options.version),
metadata: {
name: options.metadata.name,
...options.metadata,
description: options.metadata.description || '',
annotations: options.metadata.annotations || {},
},

View File

@ -6,7 +6,6 @@ import {
Box,
VStack,
HStack,
IconButton,
Text,
Tabs,
TabPanels,
@ -14,21 +13,17 @@ import {
TabList,
Tab,
Select,
Input,
Button,
CloseButton,
} from '@chakra-ui/react';
import { ExpressionWidget, WidgetProps } from '@sunmao-ui/editor-sdk';
import { EditIcon } from '@chakra-ui/icons';
import { useFormik } from 'formik';
import { Basic } from './Basic';
import { Headers as HeadersForm } from './Headers';
import { Params } from './Params';
import { Body } from './Body';
import { Response as ResponseInfo } from './Response';
import { EditorServices } from '../../../types';
import { genOperation } from '../../../operations';
import { CORE_VERSION, CoreTraitName } from '@sunmao-ui/shared';
import { EditorServicesInterface } from '../../types/editor';
import { ExpressionWidget } from '../Widgets';
import { WidgetProps } from '../..';
enum TabIndex {
Basic,
@ -37,34 +32,27 @@ enum TabIndex {
Body,
}
interface Props {
api: ComponentSchema;
services: EditorServices;
store: Record<string, any>;
className: string;
value: Static<typeof FetchTraitPropertiesSpec>;
component: ComponentSchema;
services: EditorServicesInterface;
onChange: (value: Static<typeof FetchTraitPropertiesSpec>) => void;
}
const METHODS = ['get', 'post', 'put', 'delete', 'patch'];
const EMPTY_ARRAY: string[] = [];
type FetchResultType = {
data?: unknown;
code?: number;
codeText?: string;
error?: string;
loading?: boolean;
};
export const ApiForm: React.FC<Props> = props => {
const { api, services, store, className } = props;
const { editorStore } = services;
const [reactiveStore, setReactiveStore] = useState<Record<string, any>>({ ...store });
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(api.id);
const { value, onChange, component, services } = props;
const [tabIndex, setTabIndex] = useState(0);
const { registry, eventBus } = services;
const result = useMemo(() => {
return reactiveStore[api.id]?.fetch ?? {};
}, [api.id, reactiveStore]);
const traitIndex = useMemo(
() =>
api.traits.findIndex(
({ type }) => type === `${CORE_VERSION}/${CoreTraitName.Fetch}`
),
[api.traits]
);
const trait = useMemo(() => api.traits[traitIndex], [api.traits, traitIndex]);
const [fetchResult, setFetchResult] = useState<FetchResultType | undefined>();
const compactOptions = useMemo(
() => ({
height: '40px',
@ -73,18 +61,9 @@ export const ApiForm: React.FC<Props> = props => {
[]
);
const formik = useFormik({
initialValues: {
...(trait?.properties as Static<typeof FetchTraitPropertiesSpec>),
},
initialValues: value,
onSubmit: values => {
eventBus.send(
'operation',
genOperation(registry, 'modifyTraitProperty', {
componentId: api.id,
traitIndex: traitIndex,
properties: values,
})
);
onChange(values);
},
});
const { values } = formik;
@ -93,26 +72,13 @@ export const ApiForm: React.FC<Props> = props => {
widgetOptions: { compactOptions },
});
const onFetch = useCallback(async () => {
const onFetch = useCallback(() => {
services.apiService.send('uiMethod', {
componentId: api.id,
componentId: component.id,
name: 'triggerFetch',
parameters: {},
});
}, [services.apiService, api]);
const onNameInputBlur = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value) {
if (value !== api.id) {
editorStore.changeDataSourceName(api, value);
}
setIsEditing(false);
}
},
[api, editorStore]
);
}, [services.apiService, component]);
const onMethodChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
formik.handleChange(e);
@ -136,76 +102,42 @@ export const ApiForm: React.FC<Props> = props => {
}, []);
useEffect(() => {
formik.setValues({
...(trait?.properties as Static<typeof FetchTraitPropertiesSpec>),
});
formik.setValues({ ...value });
// do not add formik into dependencies, otherwise it will cause infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trait?.properties]);
}, [value]);
useEffect(() => {
if (api.id) {
setName(api.id);
setTabIndex(0);
}
}, [api.id]);
useEffect(() => {
const stop = watch(store, newValue => {
setReactiveStore({ ...newValue });
});
const stop = watch(
() => services.stateManager.store[component.id]?.fetch,
newValue => {
setFetchResult({ ...newValue });
}
);
return stop;
}, [store]);
}, [component.id, services.stateManager.store]);
return (
<VStack
className={className}
backgroundColor="#fff"
padding="4"
paddingBottom="0"
align="stretch"
spacing="4"
height="100%"
onKeyDown={onKeyDown}
>
<HStack
alignItems="center"
justifyContent="space-between"
maxWidth="100%"
spacing={46}
<Text
title={component.id}
fontSize="lg"
fontWeight="bold"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
{isEditing ? (
<Input
value={name}
onChange={e => setName(e.target.value)}
onBlur={onNameInputBlur}
autoFocus
/>
) : (
<HStack alignItems="center" flex="1" overflow="hidden">
<Text
title={api.id}
fontSize="lg"
fontWeight="bold"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
{api.id}
</Text>
<IconButton
icon={<EditIcon />}
aria-label="edit"
size="sm"
variant="ghost"
onClick={() => setIsEditing(true)}
/>
</HStack>
)}
<CloseButton
onClick={() => {
editorStore.setActiveDataSourceId(null);
}}
/>
</HStack>
{component.id}
</Text>
<HStack display="flex" spacing={4}>
<HStack display="flex" spacing={1} flex={1} alignItems="stretch">
<Select
@ -223,7 +155,7 @@ export const ApiForm: React.FC<Props> = props => {
</Select>
<Box flex={1}>
<ExpressionWidget
component={api}
component={component}
spec={URLSpec}
value={values.url}
path={EMPTY_ARRAY}
@ -233,12 +165,12 @@ export const ApiForm: React.FC<Props> = props => {
/>
</Box>
</HStack>
<Button colorScheme="blue" isLoading={result.loading} onClick={onFetch}>
<Button colorScheme="blue" isLoading={fetchResult?.loading} onClick={onFetch}>
Run
</Button>
</HStack>
<Tabs
flex={1}
flex="1 1 0"
overflow="hidden"
index={tabIndex}
onChange={index => {
@ -254,22 +186,22 @@ export const ApiForm: React.FC<Props> = props => {
</TabList>
<TabPanels flex={1} overflow="auto">
<TabPanel>
<Basic api={api} formik={formik} services={services} />
<Basic api={component} formik={formik} services={services} />
</TabPanel>
<TabPanel>
<HeadersForm
api={api}
api={component}
spec={FetchTraitPropertiesSpec.properties.headers as WidgetProps['spec']}
services={services}
formik={formik}
/>
</TabPanel>
<TabPanel>
<Params api={api} services={services} formik={formik} />
<Params api={component} services={services} formik={formik} />
</TabPanel>
<TabPanel>
<Body
api={api}
api={component}
spec={FetchTraitPropertiesSpec.properties.body as WidgetProps['spec']}
services={services}
formik={formik}
@ -278,7 +210,7 @@ export const ApiForm: React.FC<Props> = props => {
</TabPanels>
</VStack>
</Tabs>
<ResponseInfo {...result} />
<ResponseInfo {...fetchResult} />
</VStack>
);
};

View File

@ -3,16 +3,17 @@ import { VStack, FormControl, FormLabel, Switch } from '@chakra-ui/react';
import { FormikHelpers, FormikHandlers, FormikState } from 'formik';
import { FetchTraitPropertiesSpec } from '@sunmao-ui/runtime';
import { Static, Type } from '@sinclair/typebox';
import { EditorServices } from '../../../types';
import { ComponentSchema } from '@sunmao-ui/core';
import { SpecWidget, mergeWidgetOptionsIntoSpec } from '@sunmao-ui/editor-sdk';
import { JSONSchema7 } from 'json-schema';
import { EditorServicesInterface } from '../../types/editor';
import { mergeWidgetOptionsIntoSpec } from '../..';
import { SpecWidget } from '../Widgets';
type Values = Static<typeof FetchTraitPropertiesSpec>;
interface Props {
api: ComponentSchema;
formik: FormikHelpers<Values> & FormikHandlers & FormikState<Values>;
services: EditorServices;
services: EditorServicesInterface;
}
const DisabledSpec = Type.Boolean({

View File

@ -1,22 +1,19 @@
import React, { useCallback, useMemo } from 'react';
import { Box, Select, Text, VStack } from '@chakra-ui/react';
import {
SpecWidget,
WidgetProps,
mergeWidgetOptionsIntoSpec,
} from '@sunmao-ui/editor-sdk';
import { FormikHelpers, FormikHandlers, FormikState } from 'formik';
import { FetchTraitPropertiesSpec } from '@sunmao-ui/runtime';
import { ComponentSchema } from '@sunmao-ui/core';
import { Static } from '@sinclair/typebox';
import { EditorServices } from '../../../types';
import { EditorServicesInterface } from '../../types/editor';
import { mergeWidgetOptionsIntoSpec, WidgetProps } from '../..';
import { SpecWidget } from '../Widgets';
type Values = Static<typeof FetchTraitPropertiesSpec>;
interface Props {
api: ComponentSchema;
spec: WidgetProps['spec'];
formik: FormikHelpers<Values> & FormikHandlers & FormikState<Values>;
services: EditorServices;
services: EditorServicesInterface;
}
const EMPTY_ARRAY: string[] = [];

View File

@ -1,22 +1,19 @@
import React, { useCallback, useMemo } from 'react';
import { Box } from '@chakra-ui/react';
import {
RecordWidget,
WidgetProps,
mergeWidgetOptionsIntoSpec,
} from '@sunmao-ui/editor-sdk';
import { FormikHelpers, FormikHandlers, FormikState } from 'formik';
import { FetchTraitPropertiesSpec } from '@sunmao-ui/runtime';
import { ComponentSchema } from '@sunmao-ui/core';
import { Static } from '@sinclair/typebox';
import { EditorServices } from '../../../types';
import { EditorServicesInterface } from '../../types/editor';
import { mergeWidgetOptionsIntoSpec, WidgetProps } from '../..';
import { RecordWidget } from '../Widgets';
type Values = Static<typeof FetchTraitPropertiesSpec>;
interface Props {
api: ComponentSchema;
spec: WidgetProps['spec'];
formik: FormikHelpers<Values> & FormikHandlers & FormikState<Values>;
services: EditorServices;
services: EditorServicesInterface;
}
const EMPTY_ARRAY: string[] = [];

View File

@ -1,17 +1,18 @@
import React, { useCallback, useMemo } from 'react';
import { Box } from '@chakra-ui/react';
import { RecordWidget, mergeWidgetOptionsIntoSpec } from '@sunmao-ui/editor-sdk';
import { FormikHelpers, FormikHandlers, FormikState } from 'formik';
import { Type, Static } from '@sinclair/typebox';
import { FetchTraitPropertiesSpec } from '@sunmao-ui/runtime';
import { ComponentSchema } from '@sunmao-ui/core';
import { EditorServices } from '../../../types';
import { EditorServicesInterface } from '../../types/editor';
import { RecordWidget } from '../Widgets';
import { mergeWidgetOptionsIntoSpec } from '../..';
type Values = Static<typeof FetchTraitPropertiesSpec>;
interface Props {
api: ComponentSchema;
formik: FormikHelpers<Values> & FormikHandlers & FormikState<Values>;
services: EditorServices;
services: EditorServicesInterface;
}
const EMPTY_ARRAY: string[] = [];

View File

@ -10,8 +10,6 @@ import {
Spinner,
Tag,
} from '@chakra-ui/react';
import { CodeEditor } from '../../CodeEditor';
import { css } from '@emotion/css';
interface Props {
data?: unknown;
@ -39,7 +37,7 @@ export const Response: React.FC<Props> = props => {
const error = useMemo(() => {
return stringify(props.error);
}, [props.error]);
return props.data || props.error || props.loading ? (
return props.data || props.error || props.loading || props.codeText ? (
<Accordion
onChange={i => setIsOpen(i === 0)}
allowToggle
@ -52,7 +50,7 @@ export const Response: React.FC<Props> = props => {
<AccordionButton>
<HStack flex="1" textAlign="left" spacing={2}>
<span>Response</span>
{props.data || props.error ? (
{props.data || props.error || props.codeText ? (
<Tag colorScheme={CODE_MAP[String(props.code)[0]] || 'red'}>
{props.code} {(props.codeText || '').toLocaleUpperCase()}
</Tag>
@ -66,18 +64,9 @@ export const Response: React.FC<Props> = props => {
{props.loading || !isOpen ? (
<Spinner />
) : (
<CodeEditor
className={css`
width: 100%;
height: 100%;
`}
mode={{
name: 'javascript',
json: true,
}}
defaultCode={error || data}
readOnly
/>
<pre>
<code>{error || data}</code>
</pre>
)}
</Flex>
</AccordionPanel>

View File

@ -0,0 +1,51 @@
import React, { useState } from 'react';
import { WidgetProps } from '../../types/widget';
import { implementWidget } from '../../utils/widget';
import { CORE_VERSION } from '@sunmao-ui/shared';
import {
Box,
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
} from '@chakra-ui/react';
import { ApiForm } from '../ApiForm';
type FetchWidgetType = `${typeof CORE_VERSION}/fetch`;
declare module '../../types/widget' {
interface WidgetOptionsMap {
'core/v1/fetch': {};
}
}
export const FetchWidget: React.FC<WidgetProps<FetchWidgetType>> = props => {
const { value, onChange, component, services } = props;
const [isOpen, setIsOpen] = useState(false);
return (
<Box>
<Button onClick={() => setIsOpen(true)}>Edit In Modal</Button>
<Modal onClose={() => setIsOpen(false)} isOpen={isOpen}>
<ModalContent height="75vh" minWidth="75vw">
<ModalCloseButton />
<ModalBody>
<ApiForm
value={value}
onChange={onChange}
component={component}
services={services}
/>
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
};
export default implementWidget<FetchWidgetType>({
version: CORE_VERSION,
metadata: {
name: 'fetch',
},
})(FetchWidget);

View File

@ -14,6 +14,7 @@ import moduleWidgetSpec from './ModuleWidget';
import recordWidgetSpec from './RecordField';
import eventWidgetSpec from './EventWidget';
import popoverWidgetSpec from './PopoverWidget';
import fetchWidgetSpec from './FetchWidget';
import sizeWidgetSpec from './StyleWidgets/SizeWidget';
import fontWidgetSpec from './StyleWidgets/FontWidget';
import colorWidgetSpec from './StyleWidgets/ColorWidget';
@ -34,6 +35,7 @@ export * from './ModuleWidget';
export * from './RecordField';
export * from './EventWidget';
export * from './PopoverWidget';
export * from './FetchWidget';
export * from './StyleWidgets/SizeWidget';
export * from './StyleWidgets/FontWidget';
export * from './StyleWidgets/ColorWidget';
@ -56,6 +58,7 @@ export const widgets: ImplementedWidget<any>[] = [
recordWidgetSpec,
eventWidgetSpec,
popoverWidgetSpec,
fetchWidgetSpec,
sizeWidgetSpec,
fontWidgetSpec,
colorWidgetSpec,

View File

@ -1,16 +1,9 @@
import { ComponentSchema } from '@sunmao-ui/core';
import { RegistryInterface } from '@sunmao-ui/runtime';
import { RegistryInterface, UIServices } from '@sunmao-ui/runtime';
import WidgetManager from '../models/WidgetManager';
import type { Operations } from '../types/operation';
type EvalOptions = {
scopeObject?: Record<string, any>;
overrideScope?: boolean;
fallbackWhenError?: (exp: string) => any;
ignoreEvalError?: boolean;
};
export interface EditorServices {
export interface EditorServicesInterface extends UIServices {
registry: RegistryInterface;
editorStore: {
components: ComponentSchema[];
@ -19,9 +12,5 @@ export interface EditorServices {
appModel: any;
doOperations: (operations: Operations) => void;
};
stateManager: {
store: Record<string, any>;
deepEval: (value: any, options?: EvalOptions) => any;
};
widgetManager: WidgetManager;
}

View File

@ -1,7 +1,7 @@
import React from 'react';
import { JSONSchema7 } from 'json-schema';
import { ComponentSchema } from '@sunmao-ui/core';
import { EditorServices } from './editor';
import { EditorServicesInterface } from './editor';
import * as TypeBox from '@sinclair/typebox';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@ -144,7 +144,7 @@ export type WidgetProps<
widget?: keyof WidgetOptionsMap;
widgetOptions?: WidgetOptionsMap[WidgetType];
};
services: EditorServices;
services: EditorServicesInterface;
path: string[];
level: number;
value: ValueType;

View File

@ -28,7 +28,8 @@ type Props = {
export const ComponentForm: React.FC<Props> = observer(props => {
const { services } = props;
const { editorStore, registry, eventBus } = services;
const { selectedComponent, selectedComponentId } = editorStore;
const { selectedComponent, selectedComponentId, selectedComponentIsDataSource } =
editorStore;
if (!selectedComponentId) {
return (
<Text p={2} fontSize="md">
@ -123,6 +124,7 @@ export const ComponentForm: React.FC<Props> = observer(props => {
services={services}
/>
),
hide: selectedComponentIsDataSource,
},
{
title: 'Traits',
@ -142,15 +144,18 @@ export const ComponentForm: React.FC<Props> = observer(props => {
allowMultiple
onKeyDown={onKeyDown}
>
{sections.map((section, i) => (
<FormSection
style={{ position: 'relative', zIndex: sections.length - i }}
title={section.title}
key={`${section.title}-${selectedComponentId}`}
>
{section.node}
</FormSection>
))}
{sections.map((section, i) => {
if (section.hide) return undefined;
return (
<FormSection
style={{ position: 'relative', zIndex: sections.length - i }}
title={section.title}
key={`${section.title}-${selectedComponentId}`}
>
{section.node}
</FormSection>
);
})}
</Accordion>
</ErrorBoundary>
);

View File

@ -12,7 +12,7 @@ import {
InputRightElement,
Tag,
} from '@chakra-ui/react';
import { CoreComponentName, CORE_VERSION } from '@sunmao-ui/shared';
import { CORE_VERSION } from '@sunmao-ui/shared';
import { groupBy, sortBy } from 'lodash';
import { EditorServices } from '../../types';
import { ExplorerMenuTabs } from '../../constants/enum';
@ -66,8 +66,6 @@ const tagStyle = css`
white-space: nowrap;
`;
const IGNORE_COMPONENTS: string[] = [CoreComponentName.Dummy];
export const ComponentList: React.FC<Props> = ({ services }) => {
const { registry, editorStore } = services;
const [filterText, setFilterText] = useState('');
@ -84,10 +82,7 @@ export const ComponentList: React.FC<Props> = ({ services }) => {
const categories = useMemo<Category[]>(() => {
const grouped = groupBy(
registry.getAllComponents().filter(c => {
if (
IGNORE_COMPONENTS.includes(c.metadata.name) ||
(checkedVersions.length && !checkedVersions.includes(c.version))
) {
if (checkedVersions.length && !checkedVersions.includes(c.version)) {
return false;
} else if (!filterText) {
return true;

View File

@ -1,84 +0,0 @@
import React, { useState, useMemo } from 'react';
import {
Box,
Text,
Input,
AccordionItem,
AccordionButton,
AccordionIcon,
AccordionPanel,
} from '@chakra-ui/react';
import { ComponentSchema } from '@sunmao-ui/core';
import { DataSourceItem } from './DataSourceItem';
import { CORE_VERSION, CoreTraitName } from '@sunmao-ui/shared';
const COLOR_MAP = {
GET: 'green',
POST: 'orange',
PUT: 'yellow',
PATCH: 'yellow',
DELETE: 'red',
};
interface Props {
apis: ComponentSchema[];
active: string;
onItemClick: (api: ComponentSchema) => void;
onItemRemove: (api: ComponentSchema) => void;
}
export const Api: React.FC<Props> = props => {
const [search, setSearch] = useState('');
const { apis, active, onItemClick, onItemRemove } = props;
const list = useMemo(
() => apis.filter(({ id }) => id.includes(search)),
[search, apis]
);
const ApiItems = () => (
<>
{list.map(api => {
const trait = api.traits.find(({ type }) => type === `${CORE_VERSION}/${CoreTraitName.Fetch}`);
const properties = trait!.properties;
const method = (
properties.method as string
).toUpperCase() as keyof typeof COLOR_MAP;
return (
<DataSourceItem
key={api.id}
dataSource={api}
tag={method}
name={api.id}
active={active === api.id}
colorMap={COLOR_MAP}
onItemClick={onItemClick}
onItemRemove={onItemRemove}
/>
);
})}
</>
);
return (
<AccordionItem>
<h2>
<AccordionButton borderBottom="solid" borderColor="inherit">
<Box flex="1" textAlign="left">
API
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb="4" padding="0">
<Input
placeholder="filter the apis"
value={search}
onChange={e => {
setSearch(e.target.value);
}}
/>
{list.length ? <ApiItems /> : <Text padding="2">No APIs.</Text>}
</AccordionPanel>
</AccordionItem>
);
};

View File

@ -1,108 +0,0 @@
import React, { useState, useMemo, useEffect } from 'react';
import {
Box,
Text,
Input,
AccordionItem,
AccordionButton,
AccordionIcon,
AccordionPanel,
} from '@chakra-ui/react';
import { DataSourceItem } from './DataSourceItem';
import { EditorServices } from '../../types';
import { ComponentSchema } from '@sunmao-ui/core';
import { watch } from '@sunmao-ui/runtime';
interface Props {
datas: ComponentSchema[];
active: string;
title: string;
traitType: string;
filterPlaceholder: string;
emptyPlaceholder: string;
services: EditorServices;
onItemClick: (state: ComponentSchema) => void;
onItemRemove: (state: ComponentSchema) => void;
}
const STATE_MAP: Record<string, string> = {
undefined: 'Any',
boolean: 'Boolean',
string: 'String',
number: 'Number',
object: 'Object',
};
export const Data: React.FC<Props> = props => {
const {
datas = [],
active,
onItemClick,
onItemRemove,
filterPlaceholder,
emptyPlaceholder,
title,
services,
} = props;
const { stateManager } = services;
const { store } = stateManager;
const [search, setSearch] = useState('');
const [reactiveStore, setReactiveStore] = useState<Record<string, any>>({...store});
const list = useMemo(
() => datas.filter(({ id }) => id.includes(search)),
[search, datas]
);
useEffect(() => {
const stop = watch(store, newValue => {
setReactiveStore({ ...newValue });
});
return stop;
}, [store]);
const StateItems = () => (
<>
{list.map(state => {
return (
<DataSourceItem
key={state.id}
dataSource={state}
tag={
Array.isArray(reactiveStore[state.id]?.value)
? 'Array'
: STATE_MAP[typeof reactiveStore[state.id]?.value] ?? 'Any'
}
name={state.id}
active={active === state.id}
onItemClick={onItemClick}
onItemRemove={onItemRemove}
/>
);
})}
</>
);
return (
<AccordionItem>
<h2>
<AccordionButton borderBottom="solid" borderColor="inherit">
<Box flex="1" textAlign="left">
{title}
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb="4" padding="0">
<Input
placeholder={filterPlaceholder}
value={search}
onChange={e => {
setSearch(e.target.value);
}}
/>
{list.length ? <StateItems /> : <Text padding="2">{emptyPlaceholder}</Text>}
</AccordionPanel>
</AccordionItem>
);
};

View File

@ -1,92 +0,0 @@
import React, { useState, useEffect } from 'react';
import { VStack, FormControl, FormLabel, Input } from '@chakra-ui/react';
import { ComponentSchema } from '@sunmao-ui/core';
import { ObjectField, mergeWidgetOptionsIntoSpec } from '@sunmao-ui/editor-sdk';
import { EditorServices } from '../../../types';
import { genOperation } from '../../../operations';
import { css } from '@emotion/css';
interface Props {
datasource: ComponentSchema;
services: EditorServices;
traitType: string;
}
const LabelStyle = css`
font-weight: normal;
font-size: 14px;
`;
export const DataForm: React.FC<Props> = props => {
const { datasource, services, traitType } = props;
const [name, setName] = useState(datasource.id);
const { registry, eventBus, editorStore } = services;
const traitSpec = registry.getTraitByType(traitType);
const traitIndex = datasource.traits.findIndex(({ type }) => type === traitType);
const trait = datasource.traits[traitIndex];
const onChange = (values: any) => {
eventBus.send(
'operation',
genOperation(registry, 'modifyTraitProperty', {
componentId: datasource.id,
traitIndex: traitIndex,
properties: {
key: 'value',
...values,
},
})
);
};
const onNameInputBlur = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value) {
if (value !== datasource.id) {
editorStore.changeDataSourceName(datasource, name);
}
}
};
const onKeyDown = (e: React.KeyboardEvent) => {
// prevent form keyboard events to accidentally trigger operation shortcut
e.stopPropagation();
};
useEffect(() => {
setName(datasource.id);
}, [datasource.id]);
return (
<VStack
p="2"
spacing="2"
background="gray.50"
alignItems="stretch"
onKeyDown={onKeyDown}
>
<FormControl>
<FormLabel>
<span className={LabelStyle}>Name</span>
</FormLabel>
<Input
value={name}
onChange={e => {
setName(e.target.value);
}}
onBlur={onNameInputBlur}
/>
</FormControl>
<ObjectField
spec={mergeWidgetOptionsIntoSpec<'core/v1/object'>(traitSpec.spec.properties, {
ignoreKeys: ['key'],
})}
level={0}
path={[]}
component={datasource}
services={services}
value={trait.properties}
onChange={onChange}
/>
</VStack>
);
};

View File

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

View File

@ -1,119 +0,0 @@
import React from 'react';
import {
VStack,
Flex,
Spacer,
Text,
Menu,
MenuItem,
MenuButton,
MenuList,
IconButton,
Accordion,
} from '@chakra-ui/react';
import { AddIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { ComponentSchema } from '@sunmao-ui/core';
import { Api } from './Api';
import { Data } from './Data';
import { EditorServices } from '../../types';
import { ToolMenuTabs } from '../../constants/enum';
import { DataSourceType, DATA_DATASOURCES } from '../../constants/dataSource';
interface Props {
active: string;
services: EditorServices;
}
const DATASOURCE_TYPES = Object.values(DataSourceType);
export const DataSource: React.FC<Props> = props => {
const { active, services } = props;
const { editorStore } = services;
const NORMAL_DATASOURCES = DATA_DATASOURCES.map(item => ({
...item,
title: item.type,
datas: editorStore.dataSources[item.type],
}));
const onMenuItemClick = (type: DataSourceType) => {
editorStore.createDataSource(
type,
type === DataSourceType.API ? {} : { key: 'value' }
);
editorStore.setSelectedComponentId('');
};
const onApiItemClick = (api: ComponentSchema) => {
editorStore.setActiveDataSourceId(api.id);
editorStore.setSelectedComponentId('');
};
const onDataSourceItemClick = (dataSource: ComponentSchema) => {
editorStore.setActiveDataSourceId(dataSource.id);
editorStore.setToolMenuTab(ToolMenuTabs.INSPECT);
editorStore.setSelectedComponentId('');
};
const onApiItemRemove = (api: ComponentSchema) => {
editorStore.removeDataSource(api);
};
const onStateItemRemove = (state: ComponentSchema) => {
editorStore.removeDataSource(state);
};
const MenuItems = () => (
<>
{DATASOURCE_TYPES.map(type => (
<MenuItem key={type} onClick={() => onMenuItemClick(type)}>
{type}
</MenuItem>
))}
</>
);
return (
<VStack spacing="2" alignItems="stretch">
<Flex padding="4" paddingBottom="0">
<Text fontSize="lg" fontWeight="bold">
DataSource
</Text>
<Spacer />
<Menu isLazy>
<MenuButton
as={IconButton}
aria-label="add event"
size="sm"
variant="ghost"
colorScheme="blue"
icon={<AddIcon />}
rightIcon={<ChevronDownIcon />}
/>
<MenuList>
<MenuItems />
</MenuList>
</Menu>
</Flex>
<Accordion
reduceMotion
defaultIndex={[0].concat(NORMAL_DATASOURCES.map((_, i) => i + 1))}
allowMultiple
>
<Api
apis={editorStore.dataSources[DataSourceType.API] || []}
active={active}
onItemClick={onApiItemClick}
onItemRemove={onApiItemRemove}
/>
{NORMAL_DATASOURCES.map(dataSourceItem => (
<Data
key={dataSourceItem.title}
title={dataSourceItem.title}
filterPlaceholder={dataSourceItem.filterPlaceholder}
emptyPlaceholder={dataSourceItem.emptyPlaceholder}
traitType={dataSourceItem.traitType}
datas={dataSourceItem.datas}
active={active}
services={services}
onItemClick={onDataSourceItemClick}
onItemRemove={onStateItemRemove}
/>
))}
</Accordion>
</VStack>
);
};

View File

@ -0,0 +1,106 @@
import React from 'react';
import { ComponentSchema } from '@sunmao-ui/core';
import {
Text,
AccordionItem,
AccordionButton,
AccordionIcon,
AccordionPanel,
Tag,
HStack,
} from '@chakra-ui/react';
import { EditorServices } from '../../types';
import { ComponentNode } from '../StructureTree/ComponentNode';
interface Props {
dataSources: ComponentSchema[];
title: string;
services: EditorServices;
type: string;
}
const COLOR_MAP = {
GET: 'green',
POST: 'orange',
PUT: 'yellow',
PATCH: 'yellow',
DELETE: 'red',
};
const STATE_MAP: Record<string, string> = {
undefined: 'Any',
boolean: 'Boolean',
string: 'String',
number: 'Number',
object: 'Object',
};
export const DataSourceGroup: React.FC<Props> = props => {
const { dataSources = [], title, services, type } = props;
const { stateManager, editorStore } = services;
const { store } = stateManager;
const StateItems = () => (
<>
{dataSources.map(dataSource => {
let tag = '';
const trait = dataSource.traits.find(({ type }) => type === `core/v1/fetch`);
if (trait?.properties) {
tag = ((trait.properties.config as any).method as string).toUpperCase();
} else {
tag = Array.isArray(store[dataSource.id]?.value)
? 'Array'
: STATE_MAP[typeof store[dataSource.id]?.value] ?? 'Any';
}
return (
<ComponentNode
id={dataSource.id}
key={dataSource.id}
component={dataSource}
parentId={null}
slot={null}
onSelectComponent={editorStore.setSelectedComponentId}
services={services}
droppable={false}
depth={0}
isSelected={editorStore.selectedComponent?.id === dataSource.id}
isExpanded={false}
onToggleExpand={() => undefined}
shouldShowSelfSlotName={false}
notEmptySlots={[]}
onDragStart={() => undefined}
onDragEnd={() => undefined}
prefix={
tag ? (
<Tag
size="sm"
colorScheme={COLOR_MAP[tag as keyof typeof COLOR_MAP]}
marginLeft="-3"
marginRight="1"
>
{tag}
</Tag>
) : undefined
}
/>
);
})}
</>
);
return (
<AccordionItem>
<AccordionButton justifyContent="space-between">
<HStack>
{type === 'component' ? <Tag colorScheme="blue">C</Tag> : undefined}
<Text fontWeight="bold">{title}</Text>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel padding="0">
{dataSources.length ? <StateItems /> : <Text padding="2">Empty</Text>}
</AccordionPanel>
</AccordionItem>
);
};

View File

@ -1,61 +0,0 @@
import React from 'react';
import { HStack, Tag, Text, CloseButton } from '@chakra-ui/react';
import { ComponentSchema } from '@sunmao-ui/core';
import { css, cx } from '@emotion/css';
const ItemStyle = css`
&:hover {
background: var(--chakra-colors-blue-50);
}
`;
const TextStyle = css`
&.active {
color: var(--chakra-colors-blue-600);
}
`;
interface Props {
dataSource: ComponentSchema;
tag: string;
name: string;
active?: boolean;
colorMap?: Record<string, string>;
onItemClick: (dataSource: ComponentSchema) => void;
onItemRemove: (dataSource: ComponentSchema) => void;
}
export const DataSourceItem: React.FC<Props> = props => {
const {
dataSource,
active,
colorMap = {},
tag,
name,
onItemClick,
onItemRemove,
} = props;
return (
<HStack padding="2" display="flex" className={ItemStyle}>
<HStack
flex={1}
onClick={() => onItemClick(dataSource)}
cursor="pointer"
className={cx(TextStyle, active ? 'active' : '')}
overflow="hidden"
>
<Tag colorScheme={colorMap[tag]}>{tag}</Tag>
<Text
flex={1}
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
title={name}
>
{name}
</Text>
</HStack>
<CloseButton size="sm" onClick={() => onItemRemove(dataSource)} />
</HStack>
);
};

View File

@ -0,0 +1,177 @@
import React, { useCallback, useMemo } from 'react';
import {
VStack,
Flex,
Spacer,
Text,
Menu,
MenuItem,
MenuButton,
MenuList,
IconButton,
Accordion,
MenuGroup,
} from '@chakra-ui/react';
import { AddIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { DataSourceGroup } from './DataSourceGroup';
import { EditorServices } from '../../types';
import { groupBy } from 'lodash';
import { genOperation } from '../../operations';
import { generateDefaultValueFromSpec } from '@sunmao-ui/shared';
import { JSONSchema7 } from 'json-schema';
import { ToolMenuTabs } from '../../constants/enum';
interface Props {
services: EditorServices;
}
export const DataSourceList: React.FC<Props> = props => {
const { services } = props;
const { editorStore, eventBus, registry } = services;
const { dataSources, setSelectedComponentId, setToolMenuTab } = editorStore;
const tDataSources = dataSources.filter(ds => ds.type === 'core/v1/dummy');
const cDataSources = dataSources.filter(ds => ds.type !== 'core/v1/dummy');
const cdsMap = groupBy(cDataSources, c => c.type);
const tdsMap = groupBy(tDataSources, c => c.traits[0]?.type);
const cdsGroups = Object.keys(cdsMap).map(type => {
return {
title: type,
dataSources: cdsMap[type],
type: 'component',
};
});
const tdsGroups = Object.keys(tdsMap).map(type => {
return {
title: type,
dataSources: tdsMap[type],
type: 'trait',
};
});
const dsGroups = cdsGroups.concat(tdsGroups);
// cdsTypes: component data source types
// tdsTypes: trait data source types
const { cdsTypes, tdsTypes } = useMemo(() => {
const cdsTypes = registry
.getAllComponents()
.filter(c => c.metadata.isDataSource && c.metadata.name !== 'dummy')
.map(c => `${c.version}/${c.metadata.name}`);
const tdsTypes = registry
.getAllTraits()
.filter(t => t.metadata.isDataSource)
.map(t => `${t.version}/${t.metadata.name}`);
return { cdsTypes, tdsTypes };
}, [registry]);
const getNewId = useCallback(
(name: string): string => {
let count = dataSources.length;
let id = `${name}${count}`;
const ids = dataSources.map(({ id }) => id);
while (ids.includes(id)) {
id = `${name}${++count}`;
}
return `${name}${count}`;
},
[dataSources]
);
const onCreateDSFromComponent = useCallback(
(type: string) => {
const name = type.split('/')[2];
const id = getNewId(name);
eventBus.send(
'operation',
genOperation(registry, 'createComponent', {
componentType: type,
componentId: id,
})
);
setSelectedComponentId(id);
setToolMenuTab(ToolMenuTabs.INSPECT);
},
[eventBus, getNewId, registry, setSelectedComponentId, setToolMenuTab]
);
const onCreateDSFromTrait = useCallback(
(type: string) => {
const propertiesSpec = registry.getTraitByType(type).spec.properties;
const defaultProperties = generateDefaultValueFromSpec(propertiesSpec, {
genArrayItemDefaults: true,
});
const name = type.split('/')[2];
const id = getNewId(name);
eventBus.send(
'operation',
genOperation(registry, 'createDataSource', {
id,
type,
defaultProperties: defaultProperties as JSONSchema7,
})
);
setSelectedComponentId(id);
setToolMenuTab(ToolMenuTabs.INSPECT);
},
[eventBus, getNewId, registry, setSelectedComponentId, setToolMenuTab]
);
return (
<VStack spacing="2" alignItems="stretch">
<Flex padding="4" paddingBottom="0">
<Text fontSize="lg" fontWeight="bold">
DataSource
</Text>
<Spacer />
<Menu isLazy>
<MenuButton
as={IconButton}
aria-label="add event"
size="sm"
variant="ghost"
colorScheme="blue"
icon={<AddIcon />}
rightIcon={<ChevronDownIcon />}
/>
<MenuList>
{cdsTypes.length ? (
<MenuGroup title="From Component">
{cdsTypes.map(type => (
<MenuItem key={type} onClick={() => onCreateDSFromComponent(type)}>
{type}
</MenuItem>
))}
</MenuGroup>
) : undefined}
<MenuGroup title="From Trait">
{tdsTypes.map(type => (
<MenuItem key={type} onClick={() => onCreateDSFromTrait(type)}>
{type}
</MenuItem>
))}
</MenuGroup>
</MenuList>
</Menu>
</Flex>
<Accordion
reduceMotion
defaultIndex={[0].concat(dsGroups.map((_, i) => i + 1))}
allowMultiple
>
{dsGroups.map(group => (
<DataSourceGroup
key={group.title}
title={group.title}
type={group.type}
dataSources={group.dataSources}
services={services}
/>
))}
</Accordion>
</VStack>
);
};

View File

@ -1 +1 @@
export * from './DataSource';
export * from './DataSourceList';

View File

@ -8,17 +8,13 @@ import { ComponentList } from './ComponentsList';
import { EditorHeader } from './EditorHeader';
import { KeyboardEventWrapper } from './KeyboardEventWrapper';
import { StateViewer } from './CodeEditor';
import { DataSource } from './DataSource';
import { DataSourceType, DATASOURCE_TRAIT_TYPE_MAP } from '../constants/dataSource';
import { ApiForm } from './DataSource/ApiForm';
import { DataSourceList } from './DataSource';
import { ComponentForm } from './ComponentForm';
import ErrorBoundary from './ErrorBoundary';
import { PreviewModal } from './PreviewModal';
import { WarningArea } from './WarningArea';
import { EditorServices } from '../types';
import { css } from '@emotion/css';
import { EditorMaskWrapper } from './EditorMaskWrapper';
import { DataForm } from './DataSource/DataForm';
import { Explorer } from './Explorer';
import { Resizable } from 're-resizable';
import { CodeModeModal } from './CodeModeModal';
@ -36,15 +32,6 @@ type Props = {
onRefresh: () => void;
};
const ApiFormStyle = css`
width: 100%;
height: 100%;
position: absolute;
z-index: 1;
top: 0;
left: 0;
`;
export const Editor: React.FC<Props> = observer(
({ App, stateStore, services, libs, dependencies, onRefresh: onRefreshApp }) => {
const { editorStore } = services;
@ -52,8 +39,6 @@ export const Editor: React.FC<Props> = observer(
components,
selectedComponentId,
modules,
activeDataSource,
activeDataSourceType,
toolMenuTab,
explorerMenuTab,
setToolMenuTab,
@ -86,19 +71,7 @@ export const Editor: React.FC<Props> = observer(
) : null;
}, [App, app, isDisplayApp]);
const inspectForm = useMemo(() => {
if (activeDataSource && activeDataSourceType) {
return activeDataSourceType === DataSourceType.API ? null : (
<DataForm
datasource={activeDataSource}
services={services}
traitType={DATASOURCE_TRAIT_TYPE_MAP[activeDataSourceType]}
/>
);
} else {
return <ComponentForm services={services} />;
}
}, [activeDataSource, services, activeDataSourceType]);
const inspectForm = <ComponentForm services={services} />;
const onRefresh = useCallback(() => {
setIsDisplayApp(false);
@ -184,7 +157,7 @@ export const Editor: React.FC<Props> = observer(
<StructureTree services={services} />
</TabPanel>
<TabPanel height="full" overflow="auto" p={0}>
<DataSource active={activeDataSource?.id ?? ''} services={services} />
<DataSourceList services={services} />
</TabPanel>
<TabPanel overflow="auto" p={0} height="100%">
<StateViewer store={stateStore} />
@ -235,15 +208,6 @@ export const Editor: React.FC<Props> = observer(
</Tabs>
</Box>
</Resizable>
{activeDataSource && activeDataSourceType === DataSourceType.API ? (
<ApiForm
key={activeDataSource.id}
api={activeDataSource}
services={services}
store={stateStore}
className={ApiFormStyle}
/>
) : null}
</Flex>
</>
);

View File

@ -16,6 +16,7 @@ type Props = {
onMouseLeave: () => void;
paddingLeft: number;
actionMenu?: React.ReactNode;
prefix?: React.ReactNode;
};
const ChevronWidth = 24;
@ -35,6 +36,7 @@ export const ComponentItemView: React.FC<Props> = props => {
onMouseLeave,
paddingLeft,
actionMenu,
prefix,
} = props;
const [isHover, setIsHover] = useState(false);
@ -117,6 +119,7 @@ export const ComponentItemView: React.FC<Props> = props => {
paddingLeft={`${paddingLeft + (noChevron ? ChevronWidth : 0)}px`}
>
{noChevron ? null : expandIcon}
{prefix}
<Text
cursor="pointer"
overflow="hidden"

View File

@ -27,9 +27,10 @@ type Props = ComponentNodeWithState & {
onToggleExpand: (id: string) => void;
onDragStart: (id: string) => void;
onDragEnd: (id: string) => void;
prefix?: React.ReactNode;
};
const ComponentTree = (props: Props) => {
const ComponentNodeImpl = (props: Props) => {
const {
component,
onSelectComponent,
@ -42,9 +43,10 @@ const ComponentTree = (props: Props) => {
parentId,
slot,
shouldShowSelfSlotName,
hasChildrenSlots,
notEmptySlots,
onDragStart,
onDragEnd,
prefix,
} = props;
const { registry, eventBus, appModelManager } = services;
const slots = Object.keys(registry.getComponentByType(component.type).spec.slots);
@ -96,7 +98,7 @@ const ComponentTree = (props: Props) => {
const onMouseLeave = useCallback(() => {
services.editorStore.setHoverComponentId('');
}, [services.editorStore]);
const emptySlots = xor(hasChildrenSlots, slots);
const emptySlots = xor(notEmptySlots, slots);
const emptyChildrenSlotsPlaceholder = isExpanded
? emptySlots.map(_slot => {
@ -135,6 +137,29 @@ const ComponentTree = (props: Props) => {
})
: undefined;
const actionMenu = (
<Menu isLazy gutter={4}>
<MenuButton
as={IconButton}
variant="ghost"
height="24px"
width="24px"
minWidth="24px"
marginInlineEnd="8px !important"
icon={<HamburgerIcon width="16px" height="16px" />}
onClick={e => e.stopPropagation()}
/>
<MenuList>
<MenuItem icon={<CopyIcon />} onClick={onClickDuplicate}>
Duplicate
</MenuItem>
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={onClickRemove}>
Remove
</MenuItem>
</MenuList>
</Menu>
);
return (
<VStack
key={component.id}
@ -186,28 +211,8 @@ const ComponentTree = (props: Props) => {
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
paddingLeft={paddingLeft}
actionMenu={
<Menu isLazy gutter={4}>
<MenuButton
as={IconButton}
variant="ghost"
height="24px"
width="24px"
minWidth="24px"
marginInlineEnd="8px !important"
icon={<HamburgerIcon width="16px" height="16px" />}
onClick={e => e.stopPropagation()}
/>
<MenuList>
<MenuItem icon={<CopyIcon />} onClick={onClickDuplicate}>
Duplicate
</MenuItem>
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={onClickRemove}>
Remove
</MenuItem>
</MenuList>
</Menu>
}
actionMenu={actionMenu}
prefix={prefix}
/>
</DropComponentWrapper>
{emptyChildrenSlotsPlaceholder}
@ -215,8 +220,8 @@ const ComponentTree = (props: Props) => {
);
};
const MemoComponentTree: React.FC<Props> = React.memo(
ComponentTree,
const MemoComponentNode: React.FC<Props> = React.memo(
ComponentNodeImpl,
(prevProps, nextProps) => {
const isSame =
prevProps.component === nextProps.component &&
@ -227,9 +232,9 @@ const MemoComponentTree: React.FC<Props> = React.memo(
prevProps.isSelected === nextProps.isSelected &&
prevProps.isExpanded === nextProps.isExpanded &&
prevProps.shouldShowSelfSlotName === nextProps.shouldShowSelfSlotName &&
isEqual(prevProps.hasChildrenSlots, nextProps.hasChildrenSlots);
isEqual(prevProps.notEmptySlots, nextProps.notEmptySlots);
return isSame;
}
);
export const ComponentTreeWrapper = MemoComponentTree;
export const ComponentNode = MemoComponentNode;

View File

@ -1,13 +1,13 @@
import React, { useRef, useEffect } from 'react';
import { Box, Text, VStack } from '@chakra-ui/react';
import { ComponentTreeWrapper } from './ComponentTree';
import { DropComponentWrapper } from './DropComponentWrapper';
import ErrorBoundary from '../ErrorBoundary';
import { EditorServices } from '../../types';
import scrollIntoView from 'scroll-into-view';
import { observer } from 'mobx-react-lite';
import { ComponentNode } from './ComponentNode';
import { DropComponentWrapper } from './DropComponentWrapper';
import ErrorBoundary from '../ErrorBoundary';
import { useStructureTreeState } from './useStructureTreeState';
import { ComponentSearch } from './ComponentSearch';
import { EditorServices } from '../../types';
type Props = {
services: EditorServices;
@ -27,6 +27,7 @@ export const StructureTree: React.FC<Props> = observer(props => {
setDraggingId,
} = useStructureTreeState(editorStore);
// auto expand and scroll node into view when the node is selected
useEffect(() => {
expandNode(selectedComponentId);
@ -36,6 +37,7 @@ export const StructureTree: React.FC<Props> = observer(props => {
const wrapperRect = scrollWrapper.current?.getBoundingClientRect();
const eleRect = selectedElement?.getBoundingClientRect();
// check whether selected node is outside of view
if (
selectedElement &&
eleRect &&
@ -43,8 +45,7 @@ export const StructureTree: React.FC<Props> = observer(props => {
(eleRect.top < wrapperRect.top ||
eleRect.top > wrapperRect.top + wrapperRect?.height)
) {
// check selected element is outside of view
scrollIntoView(selectedElement, { time: 300, align: { lockX: true } });
scrollIntoView(selectedElement, { align: { lockX: true } });
}
});
}, [expandNode, selectedComponentId]);
@ -52,6 +53,10 @@ export const StructureTree: React.FC<Props> = observer(props => {
const componentEles = shouldRenderNodes.map((node, i) => {
const prevNode = i > 0 ? shouldRenderNodes[i - 1] : null;
let shouldShowSlot = false;
// Conditions in which a component should show the slot name it belongs
// 1. It is in a slot and the slot is not 'content'.
// 2. And its previous node is its parent(has the same parent).
// 3. Or its previous node is its sibling and is in different slot.
if (node.slot && node.slot !== 'content' && prevNode) {
const prevNodeIsParent = prevNode.id === node.parentId;
const prevNodeInDifferentSlot =
@ -59,7 +64,7 @@ export const StructureTree: React.FC<Props> = observer(props => {
shouldShowSlot = prevNodeIsParent || prevNodeInDifferentSlot;
}
return (
<ComponentTreeWrapper
<ComponentNode
id={node.id}
key={node.id}
component={node.component}
@ -73,7 +78,7 @@ export const StructureTree: React.FC<Props> = observer(props => {
isExpanded={!!expandedMap[node.id]}
onToggleExpand={onToggleExpand}
shouldShowSelfSlotName={shouldShowSlot}
hasChildrenSlots={node.hasChildrenSlots}
notEmptySlots={node.notEmptySlots}
onDragStart={id => setDraggingId(id)}
onDragEnd={() => setDraggingId('')}
/>

View File

@ -7,7 +7,7 @@ export type ComponentNode = {
parentId: string | null;
slot: string | null;
depth: number;
hasChildrenSlots: string[];
notEmptySlots: string[];
};
// These fields need computed with UI State

View File

@ -1,5 +1,4 @@
import { ComponentSchema } from '@sunmao-ui/core';
import { CoreComponentName, CORE_VERSION } from '@sunmao-ui/shared';
import { useCallback, useMemo, useState } from 'react';
import { EditorStore } from '../../services/EditorStore';
import {
@ -12,30 +11,22 @@ export function useStructureTreeState(editorStore: EditorStore) {
const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>({});
const [draggingId, setDraggingId] = useState('');
const { nodes, nodesMap, childrenMap } = useMemo(() => {
// format components schema to ComponentNode
const { nodes, nodesMapCache, childrenMap } = useMemo(() => {
const nodes: ComponentNode[] = [];
const nodesMap: Record<string, ComponentNode> = {};
const uiComponents = editorStore.components.filter(
c => c.type !== `${CORE_VERSION}/${CoreComponentName.Dummy}`
);
const nodesMapCache: Record<string, ComponentNode> = {};
const depthMap: Record<string, number> = {};
// const parentMap: Record<string, string | null> = {};
uiComponents.forEach(c => {
depthMap[c.id] = 0;
});
const resolvedComponents = resolveApplicationComponents(uiComponents);
const resolvedComponents = resolveApplicationComponents(editorStore.uiComponents);
const { topLevelComponents, childrenMap } = resolvedComponents;
topLevelComponents.forEach(c => {
const cb = (params: Required<TraverseParams>) => {
depthMap[params.root.id] = params.depth;
// parentMap[params.root.id] = params.parentId;
const hasChildrenSlots = [];
const notEmptySlots = [];
const slots = childrenMap.get(params.root.id);
if (slots) {
for (const slot of slots.keys()) {
if (slots.get(slot)?.length) {
hasChildrenSlots.push(slot);
notEmptySlots.push(slot);
}
}
}
@ -45,9 +36,10 @@ export function useStructureTreeState(editorStore: EditorStore) {
depth: params.depth,
parentId: params.parentId,
slot: params.slot,
hasChildrenSlots,
notEmptySlots,
};
nodesMap[params.root.id] = node;
nodesMapCache[params.root.id] = node;
depthMap[params.root.id] = params.depth;
nodes.push(node);
};
@ -61,7 +53,7 @@ export function useStructureTreeState(editorStore: EditorStore) {
});
});
return { nodes, nodesMap, childrenMap };
return { nodes, nodesMapCache, childrenMap };
}, [editorStore.components]);
const onToggleExpand = useCallback(
@ -71,48 +63,49 @@ export function useStructureTreeState(editorStore: EditorStore) {
if (nextExpanded) {
return { ...prevMap, [id]: nextExpanded };
}
// if close, close all its children
// if collapse, collapse all its children
const newExpandedMap = { ...prevMap };
traverse({
childrenMap,
root: nodesMap[id].component,
root: nodesMapCache[id].component,
cb: params => delete newExpandedMap[params.root.id],
});
return newExpandedMap;
});
},
[childrenMap, nodesMap]
[childrenMap, nodesMapCache]
);
// expand all the ancestors of a node
const expandNode = useCallback(
(id: string) => {
setExpandedMap(prevMap => {
if (prevMap[id]) return prevMap;
const newExpandedMap = { ...prevMap };
let curr: string = nodesMap[id]?.parentId || '';
// don't expand its self
let curr: string = nodesMapCache[id]?.parentId || '';
while (curr) {
newExpandedMap[curr] = true;
curr = nodesMap[curr]?.parentId || '';
curr = nodesMapCache[curr]?.parentId || '';
}
return newExpandedMap;
});
},
[nodesMap]
[nodesMapCache]
);
const shouldRender = useCallback(
(node: ComponentNode) => {
if (!node.parentId) return true;
if (expandedMap[node.parentId]) return true;
return false;
},
[expandedMap]
);
// nodes whose parent is expanded
const shouldRenderNodes = useMemo(
() => nodes.filter(shouldRender),
[nodes, shouldRender]
() =>
nodes.filter((node: ComponentNode) => {
if (!node.parentId) return true;
if (expandedMap[node.parentId]) return true;
return false;
}),
[expandedMap, nodes]
);
// nodes that is being dragged or its ancestor is being dragged
const undroppableMap = useMemo(() => {
const map: Record<string, boolean> = {};
@ -120,13 +113,13 @@ export function useStructureTreeState(editorStore: EditorStore) {
traverse({
childrenMap,
root: nodesMap[draggingId].component,
root: nodesMapCache[draggingId].component,
cb: params => {
map[params.root.id] = true;
},
});
return map;
}, [childrenMap, draggingId, nodesMap]);
}, [childrenMap, draggingId, nodesMapCache]);
return {
nodes,

View File

@ -1,44 +0,0 @@
import { CORE_VERSION, CoreTraitName } from '@sunmao-ui/shared';
export enum DataSourceType {
API = 'API',
STATE = 'State',
LOCALSTORAGE = 'LocalStorage',
TRANSFORMER = 'Transformer',
}
export const DATASOURCE_NAME_MAP = {
[DataSourceType.API]: 'api',
[DataSourceType.STATE]: 'state',
[DataSourceType.LOCALSTORAGE]: 'localStorage',
[DataSourceType.TRANSFORMER]: 'transformer',
};
export const DATASOURCE_TRAIT_TYPE_MAP = {
[DataSourceType.API]: `${CORE_VERSION}/${CoreTraitName.Fetch}`,
[DataSourceType.STATE]: `${CORE_VERSION}/${CoreTraitName.State}`,
[DataSourceType.LOCALSTORAGE]: `${CORE_VERSION}/${CoreTraitName.LocalStorage}`,
[DataSourceType.TRANSFORMER]: `${CORE_VERSION}/${CoreTraitName.Transformer}`,
};
export const DATA_DATASOURCES = [
{
type: DataSourceType.STATE,
traitType: DATASOURCE_TRAIT_TYPE_MAP[DataSourceType.STATE],
filterPlaceholder: 'filter the states',
emptyPlaceholder: 'No States.',
},
{
type: DataSourceType.LOCALSTORAGE,
traitType: DATASOURCE_TRAIT_TYPE_MAP[DataSourceType.LOCALSTORAGE],
filterPlaceholder: 'filter the localStorages',
emptyPlaceholder: 'No LocalStorages.',
},
{
type: DataSourceType.TRANSFORMER,
traitType: DATASOURCE_TRAIT_TYPE_MAP[DataSourceType.TRANSFORMER],
filterPlaceholder: 'filter the transformers',
emptyPlaceholder: 'No Transformers.',
},
];

View File

@ -7,14 +7,12 @@ export const unremovableTraits = [`${CORE_VERSION}/${CoreTraitName.Slot}`];
export const hideCreateTraitsList = [
`${CORE_VERSION}/${CoreTraitName.Event}`,
`${CORE_VERSION}/${CoreTraitName.Style}`,
`${CORE_VERSION}/${CoreTraitName.Fetch}`,
`${CORE_VERSION}/${CoreTraitName.Slot}`,
];
export const hasSpecialFormTraitList = [
`${CORE_VERSION}/${CoreTraitName.Event}`,
`${CORE_VERSION}/${CoreTraitName.Style}`,
`${CORE_VERSION}/${CoreTraitName.Fetch}`,
];
export const RootId = '__root__';

View File

@ -92,10 +92,7 @@ export function initSunmaoUIEditor(props: SunmaoUIEditorProps = {}) {
editorStore.eleMap = ui.eleMap;
const services = {
App,
registry: ui.registry,
apiService: ui.apiService,
stateManager,
...ui,
appModelManager,
widgetManager,
eventBus,

View File

@ -2,7 +2,6 @@ import { AppModel } from '../../../AppModel/AppModel';
import { BaseBranchOperation } from '../../type';
import { CreateComponentBranchOperation } from '../index';
import { CreateTraitLeafOperation } from '../../leaf';
import { DataSourceType, DATASOURCE_TRAIT_TYPE_MAP } from '../../../constants/dataSource';
import {
generateDefaultValueFromSpec,
CORE_VERSION,
@ -12,18 +11,17 @@ import { JSONSchema7Object } from 'json-schema';
export type CreateDataSourceBranchOperationContext = {
id: string;
type: DataSourceType;
defaultProperties: Record<string, any>;
type: string;
defaultProperties?: Record<string, any>;
};
export class CreateDataSourceBranchOperation extends BaseBranchOperation<CreateDataSourceBranchOperationContext> {
do(prev: AppModel): AppModel {
const { id, type, defaultProperties = {} } = this.context;
const traitType = DATASOURCE_TRAIT_TYPE_MAP[type];
const traitSpec = this.registry.getTraitByType(traitType).spec;
const initProperties = generateDefaultValueFromSpec(
traitSpec.properties
) as JSONSchema7Object;
const { id, type, defaultProperties } = this.context;
const traitSpec = this.registry.getTraitByType(type).spec;
const initProperties = generateDefaultValueFromSpec(traitSpec.properties, {
genArrayItemDefaults: true,
}) as JSONSchema7Object;
this.operationStack.insert(
new CreateComponentBranchOperation(this.registry, {
@ -34,14 +32,8 @@ export class CreateDataSourceBranchOperation extends BaseBranchOperation<CreateD
this.operationStack.insert(
new CreateTraitLeafOperation(this.registry, {
componentId: id,
traitType,
properties:
type === DataSourceType.API
? {
...initProperties,
method: 'get',
}
: { ...initProperties, ...defaultProperties },
traitType: type,
properties: defaultProperties || initProperties,
})
);

View File

@ -1,4 +1,3 @@
import { AppModel } from '../../../AppModel/AppModel';
import { ComponentId, TraitId, TraitType } from '../../../AppModel/IAppModel';
import { BaseLeafOperation } from '../../type';
@ -15,11 +14,14 @@ export class CreateTraitLeafOperation extends BaseLeafOperation<CreateTraitLeafO
do(prev: AppModel): AppModel {
const component = prev.getComponentById(this.context.componentId as ComponentId);
if (!component) {
return prev
return prev;
}
const trait = component.addTrait(this.context.traitType as TraitType, this.context.properties);
const trait = component.addTrait(
this.context.traitType as TraitType,
this.context.properties
);
this.traitId = trait.id;
return prev
return prev;
}
redo(prev: AppModel): AppModel {
@ -29,9 +31,9 @@ export class CreateTraitLeafOperation extends BaseLeafOperation<CreateTraitLeafO
undo(prev: AppModel): AppModel {
const component = prev.getComponentById(this.context.componentId as ComponentId);
if (!component) {
return prev
return prev;
}
component.removeTrait(this.traitId);
return prev
return prev;
}
}

View File

@ -5,15 +5,8 @@ import { RegistryInterface, StateManagerInterface } from '@sunmao-ui/runtime';
import { EventBusType } from './eventBus';
import { AppStorage } from './AppStorage';
import type { SchemaValidator, ValidateErrorResult } from '../validator';
import {
DataSourceType,
DATASOURCE_NAME_MAP,
DATASOURCE_TRAIT_TYPE_MAP,
} from '../constants/dataSource';
import { genOperation } from '../operations';
import { ExplorerMenuTabs, ToolMenuTabs } from '../constants/enum';
import { CORE_VERSION, CoreComponentName } from '@sunmao-ui/shared';
import { isEqual } from 'lodash';
import { AppModelManager } from '../operations/AppModelManager';
import type { Metadata } from '@sunmao-ui/core';
@ -52,8 +45,7 @@ export class EditorStore {
lastSavedComponentsVersion = 0;
schemaValidator?: SchemaValidator;
// data source
activeDataSourceId: string | null = null;
private isDataSourceTypeCache: Record<string, boolean> = {};
constructor(
private eventBus: EventBusType,
@ -118,7 +110,6 @@ export class EditorStore {
() => {
if (this.selectedComponentId) {
this.setToolMenuTab(ToolMenuTabs.INSPECT);
this.setActiveDataSourceId(null);
}
}
);
@ -157,6 +148,11 @@ export class EditorStore {
return this._selectedComponentId;
}
get selectedComponentIsDataSource() {
if (!this.selectedComponent) return false;
return !!this.isDataSourceTypeCache[this.selectedComponent.type];
}
get dragOverComponentId() {
return this._dragOverComponentId;
}
@ -182,47 +178,28 @@ export class EditorStore {
}
}
get dataSources(): Record<string, ComponentSchema[]> {
const dataSources: Record<string, ComponentSchema[]> = {};
this.components.forEach(component => {
if (component.type === `${CORE_VERSION}/${CoreComponentName.Dummy}`) {
component.traits.forEach(trait => {
Object.entries(DATASOURCE_TRAIT_TYPE_MAP).forEach(
([dataSourceType, traitType]) => {
if (trait.type === traitType) {
dataSources[dataSourceType] = (dataSources[dataSourceType] || []).concat(
component
);
}
}
);
});
get uiComponents(): ComponentSchema[] {
return this.components.filter(component => {
if (this.isDataSourceTypeCache[component.type]) return false;
const spec = this.registry.getComponentByType(component.type);
if (spec.metadata.isDataSource) {
this.isDataSourceTypeCache[component.type] = true;
return false;
}
return true;
});
return dataSources;
}
get activeDataSource(): ComponentSchema | null {
return (
this.components.find(component => component.id === this.activeDataSourceId) || null
);
}
get activeDataSourceType(): DataSourceType | null {
for (const trait of this.activeDataSource?.traits || []) {
const [dataSourceType] =
Object.entries(DATASOURCE_TRAIT_TYPE_MAP).find(
([, traitType]) => trait.type === traitType
) || [];
if (dataSourceType) {
return dataSourceType as DataSourceType;
get dataSources(): ComponentSchema[] {
return this.components.filter(component => {
if (this.isDataSourceTypeCache[component.type]) return true;
const spec = this.registry.getComponentByType(component.type);
if (spec.metadata.isDataSource) {
this.isDataSourceTypeCache[component.type] = true;
return true;
}
}
return null;
return false;
});
}
clearSunmaoGlobalState() {
@ -287,82 +264,10 @@ export class EditorStore {
this.lastSavedComponentsVersion = val;
};
setActiveDataSourceId = (dataSourceId: string | null) => {
this.activeDataSourceId = dataSourceId;
};
setValidateResult = (validateResult: ValidateErrorResult[]) => {
this.validateResult = validateResult;
};
createDataSource = (
type: DataSourceType,
defaultProperties: Record<string, any> = {}
) => {
const getCount = (
dataSources: ComponentSchema[] = [],
dataSourceName = ''
): number => {
let count = dataSources.length;
let id = `${dataSourceName}${count}`;
const ids = dataSources.map(({ id }) => id);
while (ids.includes(id)) {
id = `${dataSourceName}${++count}`;
}
return count;
};
const id = `${DATASOURCE_NAME_MAP[type]}${getCount(
this.dataSources[type],
DATASOURCE_NAME_MAP[type]
)}`;
this.eventBus.send(
'operation',
genOperation(this.registry, 'createDataSource', {
id,
type,
defaultProperties,
})
);
const component = this.components.find(({ id: componentId }) => id === componentId);
this.setActiveDataSourceId(component!.id);
if (type === DataSourceType.STATE || type === DataSourceType.LOCALSTORAGE) {
this.setToolMenuTab(ToolMenuTabs.INSPECT);
}
};
removeDataSource = (dataSource: ComponentSchema) => {
this.eventBus.send(
'operation',
genOperation(this.registry, 'removeComponent', {
componentId: dataSource.id,
})
);
if (this.activeDataSource?.id === dataSource.id) {
this.setActiveDataSourceId(null);
}
};
changeDataSourceName = (dataSource: ComponentSchema, name: string) => {
this.eventBus.send(
'operation',
genOperation(this.registry, 'modifyComponentId', {
componentId: dataSource.id,
newId: name,
})
);
const component = this.components.find(({ id: componentId }) => componentId === name);
this.setActiveDataSourceId(component!.id);
};
setExplorerMenuTab = (val: ExplorerMenuTabs) => {
this.explorerMenuTab = val;
};

View File

@ -1,24 +1,14 @@
import { Application, Module } from '@sunmao-ui/core';
import {
initSunmaoUI,
RegistryInterface,
StateManagerInterface,
} from '@sunmao-ui/runtime';
import { UIServices } from '@sunmao-ui/runtime';
import { WidgetManager } from '@sunmao-ui/editor-sdk';
import { EditorStore } from './services/EditorStore';
import { EventBusType } from './services/eventBus';
import { AppModelManager } from './operations/AppModelManager';
import { EventBusType } from './services/eventBus';
type ReturnOfInit = ReturnType<typeof initSunmaoUI>;
export type EditorServices = {
App: ReturnOfInit['App'];
registry: RegistryInterface;
apiService: ReturnOfInit['apiService'];
stateManager: StateManagerInterface;
export type EditorServices = UIServices & {
eventBus: EventBusType;
appModelManager: AppModelManager;
widgetManager: WidgetManager;
eventBus: EventBusType;
editorStore: EditorStore;
};

View File

@ -12,6 +12,7 @@ export default implementRuntimeComponent({
annotations: {
category: 'Advance',
},
isDataSource: true,
},
spec: {
properties: Type.Object({}),

View File

@ -61,6 +61,7 @@ export function initSunmaoUI(props: SunmaoUIRuntimeProps = {}) {
globalHandlerMap,
apiService,
eleMap,
slotReceiver,
};
}

View File

@ -10,47 +10,62 @@ import {
import { runEventHandler } from '../../utils/runEventHandler';
import { implementRuntimeTrait } from '../../utils/buildKit';
export const FetchTraitPropertiesSpec = Type.Object({
url: Type.String({ title: 'URL' }), // {format:uri}?;
method: Type.KeyOf(
Type.Object({
get: Type.String(),
post: Type.String(),
put: Type.String(),
delete: Type.String(),
patch: Type.String(),
export const FetchTraitPropertiesSpec = Type.Object(
{
url: Type.String({ title: 'URL' }), // {format:uri}?;
method: Type.KeyOf(
Type.Object({
get: Type.String(),
post: Type.String(),
put: Type.String(),
delete: Type.String(),
patch: Type.String(),
}),
{ title: 'Method' }
), // {pattern: /^(get|post|put|delete)$/i}
lazy: Type.Boolean({ title: 'Lazy' }),
disabled: Type.Boolean({ title: 'Disabled' }),
headers: Type.Record(Type.String(), Type.String(), {
title: 'Headers',
}),
{ title: 'Method' }
), // {pattern: /^(get|post|put|delete)$/i}
lazy: Type.Boolean({ title: 'Lazy' }),
disabled: Type.Boolean({ title: 'Disabled' }),
headers: Type.Record(Type.String(), Type.String(), {
title: 'Headers',
}),
body: Type.Record(Type.String(), Type.Any(), {
title: 'Body',
widget: `${CORE_VERSION}/${CoreWidgetName.RecordField}`,
}),
bodyType: Type.KeyOf(
Type.Object({
json: Type.String(),
formData: Type.String(),
raw: Type.String(),
body: Type.Record(Type.String(), Type.Any(), {
title: 'Body',
widget: `${CORE_VERSION}/${CoreWidgetName.RecordField}`,
}),
{ title: 'Body Type' }
),
onComplete: Type.Array(EventCallBackHandlerSpec),
onError: Type.Array(EventCallBackHandlerSpec),
});
bodyType: Type.KeyOf(
Type.Object({
json: Type.String(),
formData: Type.String(),
raw: Type.String(),
}),
{ title: 'Body Type' }
),
onComplete: Type.Array(EventCallBackHandlerSpec, {
widgetOptions: { appendToParent: true },
}),
onError: Type.Array(EventCallBackHandlerSpec, {
widgetOptions: { appendToParent: true },
}),
},
{
widget: 'core/v1/fetch',
widgetOptions: {
isDisplayLabel: false,
},
}
);
export default implementRuntimeTrait({
version: CORE_VERSION,
metadata: {
name: CoreTraitName.Fetch,
description: 'fetch data to store',
isDataSource: true,
},
spec: {
properties: FetchTraitPropertiesSpec,
properties: Type.Object({
config: FetchTraitPropertiesSpec,
}),
state: Type.Object({
fetch: Type.Object({
loading: Type.Boolean(),
@ -71,22 +86,26 @@ export default implementRuntimeTrait({
},
})(() => {
return ({
config,
trait,
url,
method,
lazy: _lazy,
headers: _headers,
body,
bodyType,
onComplete,
onError,
mergeState,
services,
subscribeMethods,
componentId,
disabled,
slotKey,
}) => {
const {
url,
method,
lazy: _lazy,
headers: _headers,
body,
bodyType,
onComplete,
onError,
disabled,
} = config;
const rawConfig = trait.properties.config;
const lazy = _lazy === undefined ? true : _lazy;
const fetchData = () => {
@ -155,7 +174,8 @@ export default implementRuntimeTrait({
error: undefined,
},
});
const rawOnComplete = trait.properties.onComplete;
const rawOnComplete =
typeof rawConfig === 'string' ? [] : rawConfig.onComplete;
onComplete?.forEach((_, index) => {
runEventHandler(
@ -178,7 +198,7 @@ export default implementRuntimeTrait({
error,
},
});
const rawOnError = trait.properties.onError;
const rawOnError = typeof rawConfig === 'string' ? [] : rawConfig.onError;
onError?.forEach((_, index) => {
runEventHandler(onError[index], rawOnError, index, services, slotKey)();
@ -197,7 +217,7 @@ export default implementRuntimeTrait({
error: error.toString(),
},
});
const rawOnError = trait.properties.onError;
const rawOnError = typeof rawConfig === 'string' ? [] : rawConfig.onError;
onError?.forEach((_, index) => {
runEventHandler(onError[index], rawOnError, index, services, slotKey)();

View File

@ -28,6 +28,7 @@ export default implementRuntimeTrait({
metadata: {
name: CoreTraitName.LocalStorage,
description: 'localStorage trait',
isDataSource: true,
},
spec: {
properties: LocalStorageTraitPropertiesSpec,

View File

@ -18,6 +18,7 @@ export default implementRuntimeTrait({
metadata: {
name: CoreTraitName.State,
description: 'add state to component',
isDataSource: true,
},
spec: {
properties: StateTraitPropertiesSpec,

View File

@ -16,6 +16,7 @@ export default implementRuntimeTrait({
metadata: {
name: CoreTraitName.Transformer,
description: 'transform the value',
isDataSource: true,
},
spec: {
properties: TransformerTraitPropertiesSpec,