Compare commits

...

44 Commits

Author SHA1 Message Date
Wells
cf31ef19f4 chore(release): publish
- @music163/tango-context@1.1.1
 - @music163/tango-core@1.2.0
 - @music163/tango-designer@1.2.0
 - @music163/tango-helpers@1.1.1
 - @music163/tango-sandbox@1.0.4
 - @music163/tango-setting-form@1.2.1
 - @music163/tango-ui@1.2.0
2024-05-21 15:06:42 +08:00
864907600cc
fe31246483 feat: add select parent node of selected node (#158)
* feat: add select parent node of selected node

* feat: move select parent node to SelectSource

* fix: change icon & order

---------

Co-authored-by: ccloli <8115912+ccloli@users.noreply.github.com>
2024-05-20 20:15:28 +08:00
BoBoooooo
9daf3872e7 feat: event-setter & expression-setter use popover (#159) 2024-05-20 16:57:37 +08:00
BoBoooooo
f17ccbb7f6 fix: support add components with popover (#155)
* feat: add drag panel & popover component

* feat: update web ide version to 1.3.11

* feat: add components-popover

* fix: update sidebar default width

* feat: add global components popover

* fix: _menuData default value

* feat: add addComponent event

* revert: remove isCollapsed prop

* fix: update local icon font url
2024-05-20 16:13:34 +08:00
BoBoooooo
f854f75b8a feat: supoort set default active view (#157) 2024-05-20 15:27:32 +08:00
BoBoooooo
df3021f75e feat: add history forward & back shortcut key (#154) 2024-05-20 15:26:46 +08:00
BoBoooooo
8bf53a76f8 fix: prototype2code & go back history error (#156)
* fix: snippet import bug

* fix: history could back logic bug

* fix: editor add left border when in dual mode

* fix: setting-form title overflow bug
2024-05-20 15:26:07 +08:00
Wells
791fbb162a fix: enhance code value validate in SettingForm (#152)
* fix: update register setter strategy & add getSetter

* fix: handle invalid code to prop value

* fix: add validate logic to code setters

* fix: update code validate

* fix: update ui of settingForm header
2024-05-17 18:20:08 +08:00
Wells
704277b8f0 chore(release): publish
- @music163/tango-context@1.1.0
 - @music163/tango-core@1.1.0
 - @music163/tango-designer@1.1.0
 - @music163/tango-helpers@1.1.0
 - @music163/tango-sandbox@1.0.3
 - @music163/tango-setting-form@1.2.0
 - @music163/tango-ui@1.1.0
2024-05-17 11:13:19 +08:00
Wells
1fc22a4535 fix: support quick set tid in SettingForm (#151)
* fix: support quick set tid

* fix: shape return type of parseDndId
2024-05-17 10:53:18 +08:00
Wells
ffaa276b5c feat: refactor parse attribute value (#149)
* refactor: update attribute value parsing logic

* fix: refactor toggle setter logic

* fix: enhance basic setters

* fix: move basic value setter to setting-form package

* fix: add setter type

* fix: update

* feat: support toggle formObject to codeSetter

* fix: update invalid setter notice

* fix: update node2value

* fix: trigger onChange for formObject

* docs: update

* chore: up

* feat: improve code2value function and fix related issues

* Revert "chore: up"

This reverts commit a87d8fcce4.

* chore: update npm dependencies to latest versions
2024-05-16 13:03:41 +08:00
Wells
56d0877507 fix: parse module with module alias (#147) 2024-05-11 14:25:45 +08:00
Wells
897fe0a5a1 chore(release): publish
- @music163/tango-context@1.0.2
 - @music163/tango-core@1.0.2
 - @music163/tango-designer@1.0.3
 - @music163/tango-helpers@1.0.0
 - @music163/tango-sandbox@1.0.2
 - @music163/tango-setting-form@1.1.0
 - @music163/tango-ui@1.0.3
2024-05-09 10:54:49 +08:00
Wells
b52bbad547 fix: disable toggle variable for expSetter & add isValid to workspace (#146)
* fix: add isValid to test if valid project

* fix: disable toggle variable for expSetter
2024-05-08 20:11:22 +08:00
864907600cc
f7c837a8a9 feat: support validate on formItem (#145)
Co-authored-by: ccloli <8115912+ccloli@users.noreply.github.com>
2024-05-08 19:57:32 +08:00
Wells
f64d178ab8 chore: up 2024-05-06 15:32:10 +08:00
maxwls
f2dcc942ed chore: support start playground in different os (#139)
Co-authored-by: 王龙生 <wanglongsheng@pansoft.com>
2024-05-06 15:31:09 +08:00
Wells
ac48a8880e fix: update setting form styles & sort deprecated props to the bottom (#142)
* fix: sort the deprecated props to the bottom

* fix: merge undefined props

* fix: update form label color
2024-05-06 15:30:29 +08:00
Wells
0f1711868d chore(release): publish
- @music163/tango-context@1.0.1
 - @music163/tango-core@1.0.1
 - @music163/tango-designer@1.0.2
 - @music163/tango-helpers@1.0.0-alpha.7
 - @music163/tango-sandbox@1.0.1
 - @music163/tango-setting-form@1.0.2
 - @music163/tango-ui@1.0.2
2024-04-22 11:36:47 +08:00
Wells
06cd3b3a18 chore: generate setting-form docs 2024-04-22 11:34:20 +08:00
Wells
2664613ab2 fix: allow reload state tree & display pageStore in variable tree (#135)
* fix: update state tree display

* fix: allow reload state tree

* fix: update action props

* fix: display pageStore in variable tree
2024-04-19 19:55:40 +08:00
Wells
e39a913a87 chore(release): publish
- @music163/tango-designer@1.0.1
 - @music163/tango-setting-form@1.0.1
 - @music163/tango-ui@1.0.1
2024-04-16 15:29:34 +08:00
Wells
52864b5400 fix: variable tree remove button event handling (#134)
* docs: add StrictMode

* fix: variable tree remove button event handling
2024-04-16 15:25:09 +08:00
BoBoooooo
27166ec949 fix: render-setter expression value bug (#133)
* Create sync.yml

* Update sync.yml

* fix: render-setter expression value bug

* ci: remove sync.yml
2024-04-16 10:33:54 +08:00
Wells
1a8d15c9da chore: up 2024-04-15 14:47:52 +08:00
Wells
43b99de01c chore(release): publish
- @music163/tango-context@1.0.0
 - @music163/tango-core@1.0.0
 - @music163/tango-designer@1.0.0
 - @music163/tango-sandbox@1.0.0
 - @music163/tango-setting-form@1.0.0
 - @music163/tango-ui@1.0.0
2024-04-15 14:41:25 +08:00
BoBoooooo
1c1ad9e5eb feat: support traverse native dom (#132)
* Create sync.yml

* Update sync.yml

* feat: support nativeDom

* ci: remove sync.yml

* fix: update test case

* fix: update isValidComponentName logic
2024-04-15 14:22:34 +08:00
BoBoooooo
776837079a feat: update ui & add dual screen mode (#131)
* fix: change view source icon

* fix: setting-form collapsed panel default collapsed

* fix: selectAction font size larger

* feat: add dual screen mode

* fix: trigger bug
2024-04-11 10:20:07 +08:00
Wells
14375284e4 chore(release): publish
- @music163/tango-context@1.0.0-alpha.10
 - @music163/tango-core@1.0.0-alpha.10
 - @music163/tango-designer@1.0.0-alpha.18
 - @music163/tango-helpers@1.0.0-alpha.6
 - @music163/tango-sandbox@1.0.0-alpha.10
 - @music163/tango-setting-form@1.0.0-alpha.14
 - @music163/tango-ui@1.0.0-alpha.12
2024-04-09 14:37:56 +08:00
Wells
cb6aabf6ce fix: check is in design mode 2024-04-08 18:02:43 +08:00
BoBoooooo
3f42516816 fix: update SettingForm style (#125)
* fix: auto focus expression panel

* fix: form control group level 1 sticky head

* fix: expression-setter add showOptionsDropDown

* fix: update icon

* fix: revert toggleIsVariable

* fix: revert comment
2024-04-03 12:41:10 +08:00
Wells
93608d1037 fix: update setters and use tabOptions to filter props (#129)
* docs: update

* fix: update actionSelect

* fix: update ToggleButton text shape style

* fix: update InputCode minHeight and placeholder

* fix: show value in renderSetter

* docs: update

* fix: update groupOptions

* fix: update types

* chore: update
2024-04-02 20:08:54 +08:00
Wells
41a61cce4e fix: render invalid icon in inserted dropdown (#128) 2024-04-02 14:01:36 +08:00
Wells
47364e4219 chore(release): publish
- @music163/tango-context@1.0.0-alpha.9
 - @music163/tango-core@1.0.0-alpha.9
 - @music163/tango-designer@1.0.0-alpha.17
 - @music163/tango-helpers@1.0.0-alpha.5
 - @music163/tango-sandbox@1.0.0-alpha.9
 - @music163/tango-setting-form@1.0.0-alpha.13
 - @music163/tango-ui@1.0.0-alpha.11
2024-03-26 19:43:53 +08:00
Wells
9ed6a7d1a4 feat: quick add sibling nodes (#127)
* docs: add mail example

* fix: update SettingForm style

* fix: add tooltip to quick add action

* feat: quick add sibling nodes

* fix: update view
2024-03-26 19:36:28 +08:00
Wells
6f2c8f239a chore(release): publish
- @music163/tango-designer@1.0.0-alpha.16
2024-03-21 16:06:40 +08:00
Wells
6215ed96f2 fix: copy node path in variable tree 2024-03-21 15:33:23 +08:00
Wells
190aa1d8ee chore(release): publish
- @music163/tango-designer@1.0.0-alpha.15
2024-03-20 17:42:17 +08:00
Wells
bceb7a5c9c fix: update selection helper style 2024-03-20 17:40:12 +08:00
Wells
d64042922b fix: quick insert child 2024-03-20 17:10:52 +08:00
Wells
9ada3fecb3 chore(release): publish
- @music163/tango-designer@1.0.0-alpha.14
 - @music163/tango-setting-form@1.0.0-alpha.12
 - @music163/tango-ui@1.0.0-alpha.10
2024-03-19 19:19:42 +08:00
Wells
07febb385f fix: select outline node & render dependency list (#118)
* fix: select outline tree

* fix: update selection tip

* fix: refactor ColorTag
2024-03-19 19:16:05 +08:00
Wells
ddbda0e6fb chore(release): publish
- @music163/tango-context@1.0.0-alpha.8
 - @music163/tango-core@1.0.0-alpha.8
 - @music163/tango-designer@1.0.0-alpha.13
 - @music163/tango-helpers@1.0.0-alpha.4
 - @music163/tango-sandbox@1.0.0-alpha.8
 - @music163/tango-setting-form@1.0.0-alpha.11
 - @music163/tango-ui@1.0.0-alpha.9
2024-03-18 16:54:59 +08:00
Wells
9f7441c134 fix: add deprecated and data group to ComponentPropType (#116)
* fix: add deprecated and data group to ComponentPropType

* fix: render choiceSetter with icon
2024-03-18 16:49:41 +08:00
116 changed files with 7780 additions and 25675 deletions

View File

@@ -7,6 +7,7 @@ const resolvePackageIndex = (relativeEntry: string) =>
export default defineConfig({
routes: [
{ path: '/', component: 'index' },
{ path: '/mail', component: 'mail' },
{ path: '/docs', component: 'docs' },
],
npmClient: 'yarn',

View File

@@ -4,7 +4,7 @@
"private": true,
"author": "Wells <ww.sun@outlook.com>",
"scripts": {
"dev": "HOST=local.netease.com PORT=8001 umi dev",
"dev": "cross-env HOST=local.netease.com PORT=8001 umi dev",
"build": "umi build",
"postinstall": "umi setup",
"setup": "umi setup",
@@ -12,13 +12,9 @@
},
"dependencies": {
"@ant-design/icons": "^4.8.0",
"@music163/antd": "^0.2.4",
"antd": "^4.24.2",
"coral-system": "^1.0.5",
"umi": "^4.0.89"
},
"devDependencies": {
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"typescript": "^5.0.3"
"umi": "^4.2.3"
}
}

View File

@@ -3,7 +3,7 @@ import { Box, Group } from 'coral-system';
import { Avatar, Space, Switch } from 'antd';
import { BranchesOutlined, MenuOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { registerSetter } from '@music163/tango-designer';
import type { ComponentPrototypeType, IVariableTreeNode } from '@music163/tango-helpers';
import type { IVariableTreeNode } from '@music163/tango-helpers';
import { FooSetter } from '../components';
export * from './mock-files';
@@ -37,113 +37,6 @@ export const bootHelperVariables: IVariableTreeNode[] = [
},
];
// folder-name
// 物料列表定义
const bizToggleButtonPrototype: ComponentPrototypeType = {
name: 'CtPcToggleButton',
exportType: 'defaultExport',
title: '示例业务组件',
icon: 'icon-tupian',
type: 'element',
docs: 'https://redstone.fn.netease.com/mt/fe-comp/w8bq8px7n5/toggle-button',
hasChildren: false,
props: [
{
name: 'checked',
title: '是否选中',
setter: 'boolSetter',
defaultValue: false,
},
{
name: 'children',
title: '文本',
setter: 'textSetter',
initValue: '按钮',
},
],
package: '@music/ct-pc-toggle-button',
};
const sampleBlockCode = `
<Section>
<Result
status="success"
title="Successfully Purchased Cloud Server ECS!"
subTitle="Order number: 2017182818828182881 Cloud server configuration takes 1-5 minutes, please wait."
extra={[
<Button type="primary" key="console">
Go Console
</Button>,
<Button key="buy">Buy Again</Button>,
]}
/>
</Section>
`;
const SnippetSuccessResult: ComponentPrototypeType = {
name: 'SnippetSuccessResult',
title: '成功结果',
icon: 'icon-tupian',
type: 'snippet',
package: '@music163/antd',
initChildren: sampleBlockCode,
relatedImports: ['Section', 'Result', 'Button'],
};
const Snippet2ColumnLayout: ComponentPrototypeType = {
name: 'Snippet2ColumnLayout',
title: '两列布局',
icon: 'icon-columns',
type: 'snippet',
package: '@music163/antd',
initChildren: `
<Columns columns={12}>
<Column colSpan={6}></Column>
<Column colSpan={6}></Column>
</Columns>
`,
relatedImports: ['Columns', 'Column'],
};
const Snippet3ColumnLayout: ComponentPrototypeType = {
name: 'Snippet3ColumnLayout',
title: '三列布局',
icon: 'icon-column3',
type: 'snippet',
package: '@music163/antd',
initChildren: `
<Columns columns={12}>
<Column colSpan={4}></Column>
<Column colSpan={4}></Column>
<Column colSpan={4}></Column>
</Columns>
`,
relatedImports: ['Columns', 'Column'],
};
const SnippetButtonGroup: ComponentPrototypeType = {
name: 'SnippetButtonGroup',
title: '按钮组',
icon: 'icon-anniuzu',
type: 'snippet',
package: '@music163/antd',
initChildren: `
<Space>
<Button type="primary">按钮1</Button>
<Button>按钮2</Button>
</Space>
`,
relatedImports: ['Space', 'Button'],
};
export const prototypes = {
CtPcToggleButton: bizToggleButtonPrototype,
SnippetSuccessResult,
Snippet2ColumnLayout,
Snippet3ColumnLayout,
SnippetButtonGroup,
};
// 注册自定义 setter
registerSetter({
name: 'fooSetter',

View File

@@ -0,0 +1,180 @@
const packageJson = {
name: 'demo',
private: true,
dependencies: {
'@music163/tango-mail': '0.1.1',
'@music163/tango-boot': '0.2.5',
react: '17.0.2',
'react-dom': '17.0.2',
'prop-types': '15.7.2',
tslib: '2.5.0',
},
};
const tangoConfigJson = {
designerConfig: {
autoGenerateComponentId: true,
},
packages: {
react: {
version: '17.0.2',
library: 'React',
type: 'dependency',
resources: ['https://unpkg.com/react@{{version}}/umd/react.development.js'],
},
'react-dom': {
version: '17.0.2',
library: 'ReactDOM',
type: 'dependency',
resources: ['https://unpkg.com/react-dom@{{version}}/umd/react-dom.development.js'],
},
'react-is': {
version: '16.13.1',
library: 'ReactIs',
type: 'dependency',
resources: ['https://unpkg.com/react-is@{{version}}/umd/react-is.production.min.js'],
},
'@music163/tango-boot': {
description: '云音乐低代码运行时框架',
version: '0.2.5',
library: 'TangoBoot',
type: 'baseDependency',
resources: ['https://unpkg.com/@music163/tango-boot@{{version}}/dist/boot.js'],
// resources: ['http://localhost:9001/boot.js'],
},
'@music163/tango-mail': {
description: 'TangoMail 基础物料',
version: '0.1.1',
library: 'TangoMail',
type: 'baseDependency',
resources: ['https://unpkg.com/@music163/tango-mail@{{version}}/dist/index.js'],
designerResources: ['https://unpkg.com/@music163/tango-mail@{{version}}/dist/designer.js'],
},
},
};
const routesCode = `
import Mail from "./pages/mail";
const routes = [
{
path: '/',
exact: true,
component: Mail,
},
];
export default routes;
`;
const entryCode = `
import { runApp } from '@music163/tango-boot';
import routes from './routes';
runApp({
boot: {
mountElement: document.querySelector('#root'),
qiankun: false,
},
router: {
type: 'browser',
config: routes,
},
});
`;
const viewHomeCode = `
import React from 'react';
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Text,
} from '@music163/tango-mail';
const WelcomeEmail = () => (
<Html>
<Head />
<Preview>The sales intelligence platform that helps you uncover qualified leads.</Preview>
<Body style={main}>
<Container style={container}>
<Text style={paragraph}>Hi wells,</Text>
<Text style={paragraph}>
Welcome to Koala, the sales intelligence platform that helps you uncover qualified leads
and close deals faster.
</Text>
<Section style={btnContainer}>
<Button style={button} href="https://getkoala.com">
Get started
</Button>
</Section>
<Text style={paragraph}>
Best,
<br />
The Koala team
</Text>
<Hr style={hr} />
<Text style={footer}>470 Noor Ave STE B #1148, South San Francisco, CA 94080</Text>
</Container>
</Body>
</Html>
);
const main = {
backgroundColor: '#ffffff',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: '0 auto',
padding: '20px 0 48px',
};
const paragraph = {
fontSize: '16px',
lineHeight: '26px',
};
const btnContainer = {
textAlign: 'center',
};
const button = {
backgroundColor: '#5F51E8',
borderRadius: '3px',
color: '#fff',
fontSize: '16px',
textDecoration: 'none',
textAlign: 'center',
display: 'block',
padding: '12px',
};
const hr = {
borderColor: '#cccccc',
margin: '20px 0',
};
const footer = {
color: '#8898aa',
fontSize: '12px',
};
export default WelcomeEmail;
`;
export const mailFiles = [
{ filename: '/package.json', code: JSON.stringify(packageJson) },
{ filename: '/tango.config.json', code: JSON.stringify(tangoConfigJson) },
{ filename: '/README.md', code: '# readme' },
{ filename: '/src/index.js', code: entryCode },
{ filename: '/src/pages/mail.js', code: viewHomeCode },
{ filename: '/src/routes.js', code: routesCode },
];

View File

@@ -2,7 +2,7 @@ const packageJson = {
name: 'demo',
private: true,
dependencies: {
'@music163/antd': '0.2.1',
'@music163/antd': '0.2.5',
'@music163/tango-boot': '0.2.5',
react: '17.0.2',
'react-dom': '17.0.2',
@@ -56,11 +56,12 @@ const tangoConfigJson = {
},
'@music163/antd': {
description: '云音乐低代码中后台应用基础物料',
version: '0.2.1',
version: '0.2.5',
library: 'TangoAntd',
type: 'baseDependency',
resources: [
'https://unpkg.com/@music163/antd@{{version}}/dist/index.js',
// 'http://localhost:9002/designer.js',
'https://unpkg.com/antd@4.24.13/dist/antd.css',
],
designerResources: [
@@ -85,6 +86,7 @@ export function registerComponentPrototype(proto) {
const routesCode = `
import Index from "./pages/list";
import Detail from "./pages/detail";
const routes = [
{
@@ -92,6 +94,11 @@ const routes = [
exact: true,
component: Index
},
{
path: '/detail',
exact: true,
component: Detail
},
];
export default routes;
@@ -149,28 +156,117 @@ import {
Button,
Input,
FormilyForm,
FormilyFormItem,
Table,
} from "@music163/antd";
import { Space } from '@music163/antd';
import { LocalButton } from '../components';
import { Space } from "@music163/antd";
import { LocalButton } from "../components";
class App extends React.Component {
render() {
return (
<Page title={tango.stores.app.title}>
<Section tid="section1" title="Section Title">
your input: <Input tid="input1" defaultValue="hello" />
copy input: <Input value={tango.page.input1?.value} />
</Section>
<Section tid="section2">
<Space tid="space1">
<LocalButton />
<Button tid="button1">button</Button>
<Page title={tango.stores.app.title} subTitle={111}>
<Section tid="section0" />
<Section tid="section1" title="Section Title">
your input: <Input tid="input1" defaultValue="hello" />
copy input: <Input value={tango.page.input1?.value} />
<Table
columns={[
{ title: "姓名", dataIndex: "name", key: "name" },
{ title: "年龄", dataIndex: "age", key: "age" },
{ title: "住址", dataIndex: "address", key: "address" },
]}
tid="table1"
/>
</Section>
<Section tid="section2">
<Space tid="space1">
<LocalButton />
<Button tid="button1">button</Button>
</Space>
</Section>
<Section title="区块标题" tid="section3">
<FormilyForm tid="formilyForm1">
<FormilyFormItem name="input1" component="Input" label="表单项" />
<FormilyFormItem name="select1" component="Select" label="表单项" />
</FormilyForm>
</Section>
<Section title="原生 DOM" tid="section4">
<h1 style={{ ...{ color: "red" }, fontSize: 64 }}>
hello world
</h1>
<div
style={{
border: "1px solid #ccc",
borderRadius: "5px",
backgroundColor: "#f4f4f4",
}}
>
<form onSubmit={tango.stores.app.submitForm} style={{
padding: "10px",
}}>
<div>
<label>Username:</label>
<input
type="text"
id="username"
name="username"
style={{ margin: "5px" }}
/>
</div>
<div>
<label>Password:</label>
<input
type="password"
id="password"
name="password"
style={{ margin: "5px" }}
/>
</div>
<div>
<label>Role:</label>
<select id="role" name="role" style={{ margin: "5px" }}>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
</div>
<div>
<button
type="submit"
style={{
marginTop: '5px',
padding: "3px 10px",
backgroundColor: "#007bff",
color: "#fff",
border: "none",
borderRadius: "5px",
}}
>
Submit
</button>
</div>
</form>
</div>
</Section>
</Page>
);
}
}
export default definePage(App);
`;
export const emptyPageCode = `
import React from "react";
import { definePage } from "@music163/tango-boot";
import {
Page,
Section,
} from "@music163/antd";
function App() {
return (<Page title="Detail Page">
<Section></Section>
</Page>)
}
export default definePage(App);
`;
@@ -222,38 +318,21 @@ const storeApp = `
import { defineStore } from '@music163/tango-boot';
export default defineStore({
title: 'Page Title',
array: [1, 2, 3],
test: function() {
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
console.log('test');
},
submitForm: function(event) {
event.preventDefault();
const formData = new FormData(event.target);
const username = formData.get("username");
const password = formData.get("password");
const role = formData.get("role");
console.log("Submitted Data:", { username, password, role });
}
}, 'app');
`;
@@ -331,6 +410,7 @@ export const sampleFiles = [
{ filename: '/src/style.css', code: cssCode },
{ filename: '/src/index.js', code: entryCode },
{ filename: '/src/pages/list.js', code: viewHomeCode },
{ filename: '/src/pages/detail.js', code: emptyPageCode },
{ filename: '/src/components/button.js', code: componentsButtonCode },
{ filename: '/src/components/input.js', code: componentsInputCode },
{ filename: '/src/components/index.js', code: componentsEntryCode },

View File

@@ -0,0 +1,239 @@
import * as basePrototypes from '@music163/antd/prototypes';
import type { IComponentPrototype, Dict } from '@music163/tango-helpers';
const sampleBlockCode = `
<Section>
<Result
status="success"
title="Successfully Purchased Cloud Server ECS!"
subTitle="Order number: 2017182818828182881 Cloud server configuration takes 1-5 minutes, please wait."
extra={[
<Button type="primary" key="console">
Go Console
</Button>,
<Button key="buy">Buy Again</Button>,
]}
/>
</Section>
`;
const SnippetSuccessResult: IComponentPrototype = {
name: 'SnippetSuccessResult',
title: '成功结果',
icon: 'icon-tupian',
type: 'snippet',
package: '@music163/antd',
initChildren: sampleBlockCode,
relatedImports: ['Section', 'Result', 'Button'],
};
const Snippet2ColumnLayout: IComponentPrototype = {
name: 'Snippet2ColumnLayout',
title: '两列布局',
icon: 'icon-columns',
type: 'snippet',
package: '@music163/antd',
initChildren: `
<Columns columns={12}>
<Column colSpan={6}></Column>
<Column colSpan={6}></Column>
</Columns>
`,
relatedImports: ['Columns', 'Column'],
};
const Snippet3ColumnLayout: IComponentPrototype = {
name: 'Snippet3ColumnLayout',
title: '三列布局',
icon: 'icon-column-3',
type: 'snippet',
package: '@music163/antd',
// FIXME: Column 组件 需要更新为 defineComponent
initChildren: `
<Columns columns={12}>
<Column colSpan={4}>
</Column>
<Column colSpan={4}>
</Column>
<Column colSpan={4}>
</Column>
</Columns>
`,
relatedImports: ['Columns', 'Column'],
};
const SnippetButtonGroup: IComponentPrototype = {
name: 'SnippetButtonGroup',
title: '按钮组',
icon: 'icon-anniuzu',
type: 'snippet',
package: '@music163/antd',
initChildren: `
<Space>
<Button type="primary">按钮1</Button>
<Button>按钮2</Button>
</Space>
`,
relatedImports: ['Space', 'Button'],
};
// hack some prototypes
basePrototypes['Section'].siblingNames = [
'SnippetButtonGroup',
'Snippet2ColumnLayout',
'Snippet3ColumnLayout',
'SnippetSuccessResult',
];
export const nativeDomPrototypes = () => {
const doms = [
'div',
'span',
'h1',
'h2',
'p',
'a',
'img',
'ul',
'ol',
'li',
'input',
'button',
'form',
'table',
'tr',
'td',
'header',
'footer',
'nav',
'section',
'article',
'aside',
'main',
'video',
'audio',
'label',
'select',
'option',
'textarea',
'iframe',
];
const componentPrototypes: Dict<IComponentPrototype> = doms.reduce(
(acc: Dict<IComponentPrototype>, tag) => {
acc[tag] = {
name: tag,
title: tag,
hasChildren: true,
package: '',
type: 'element',
props: [
{
name: 'style',
title: '样式',
group: 'style',
setter: 'expressionSetter',
},
{
name: 'className',
title: '类名',
setter: 'textSetter',
},
{
name: 'id',
title: 'ID',
setter: 'textSetter',
},
{
name: 'onClick',
title: '点击事件',
setter: 'actionSetter',
group: 'event',
},
],
};
return acc;
},
{},
);
return componentPrototypes;
};
// iconfont: https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=2891794
const prototypes: Dict<IComponentPrototype> = {
...nativeDomPrototypes(),
...(basePrototypes as any),
SnippetSuccessResult,
Snippet2ColumnLayout,
Snippet3ColumnLayout,
SnippetButtonGroup,
Box: {
name: 'Box',
title: '盒子',
icon: 'icon-mianban',
type: 'container',
package: '@music163/antd',
hasChildren: true,
siblingNames: ['Box'],
props: [
{
name: 'aaa',
title: 'aaa',
setter: 'textSetter',
},
{
name: 'bbb',
title: 'bbb',
setter: 'textSetter',
deprecated: true,
},
{
name: 'ccc',
title: 'ccc',
setter: 'textSetter',
deprecated: true,
},
{
name: 'd',
title: 'd',
setter: 'textSetter',
},
],
},
Columns: {
name: 'Columns',
type: 'container',
icon: 'icon-column-4',
package: '@music163/antd',
hasChildren: true,
childrenNames: ['Column'],
},
Column: {
name: 'Column',
type: 'container',
icon: 'icon-juxing',
package: '@music163/antd',
hasChildren: true,
siblingNames: ['Column'],
},
Text: {
name: 'Text',
type: 'element',
icon: 'icon-wenzi',
package: '@music163/antd',
initChildren: '文本内容',
},
Placeholder: {
name: 'Placeholder',
type: 'element',
package: '@music163/antd',
},
ButtonGroup: {
name: 'ButtonGroup',
type: 'element',
package: '@music163/antd',
hasChildren: true,
childrenNames: ['Button'],
},
};
export default prototypes;

View File

@@ -3,9 +3,5 @@ import { Outlet } from 'umi';
import './index.less';
export default function Layout() {
return (
<div>
<Outlet />
</div>
);
return <Outlet />;
}

View File

@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { Box } from 'coral-system';
import { Button, Space } from 'antd';
import { Button, Form, Input, Modal, Space } from 'antd';
import {
Designer,
DesignerPanel,
@@ -15,25 +14,69 @@ import {
themeLight,
} from '@music163/tango-designer';
import { createEngine, Workspace } from '@music163/tango-core';
import { Logo, ProjectDetail, bootHelperVariables, sampleFiles } from '../helpers';
import prototypes from '../helpers/prototypes';
import { Logo, ProjectDetail, bootHelperVariables, emptyPageCode, sampleFiles } from '../helpers';
import {
ApiOutlined,
AppstoreAddOutlined,
BuildOutlined,
ClusterOutlined,
FunctionOutlined,
PlusOutlined,
createFromIconfontCN,
} from '@ant-design/icons';
import { Action } from '@music163/tango-ui';
import { useState } from 'react';
const menuData = {
common: [
{
title: '常用',
items: [
'Button',
'Section',
'Columns',
'Column',
'Box',
'Text',
'Space',
'Typography',
'Title',
'Paragraph',
'Table',
'Each',
],
},
{
title: '输入',
items: ['Input', 'InputNumber', 'Select'],
},
{
title: 'Formily表单',
items: ['FormilyForm', 'FormilyFormItem', 'FormilySubmit', 'FormilyReset'],
},
{
title: '数据展示',
items: ['Comment'],
},
],
};
// 1. 实例化工作区
const workspace = new Workspace({
entry: '/src/index.js',
files: sampleFiles,
prototypes,
});
// inject workspace to window for debug
(window as any).__workspace__ = workspace;
// 2. 引擎初始化
const engine = createEngine({
workspace,
menuData,
defaultActiveView: 'design', // dual code design
});
// @ts-ignore
@@ -46,15 +89,15 @@ const sandboxQuery = new DndQuery({
// 4. 图标库初始化(物料面板和组件树使用了 iconfont 里的图标)
createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_2891794_lzc7rtwuzf.js',
scriptUrl: '//at.alicdn.com/t/c/font_2891794_151xsllxqd7.js',
});
/**
* 5. 平台初始化,访问 https://local.netease.com:6006/
*/
export default function App() {
const [menuLoading, setMenuLoading] = useState(true);
const [menuData, setMenuData] = useState(false);
const [showNewPageModal, setShowNewPageModal] = useState(false);
const [form] = Form.useForm();
return (
<Designer
theme={themeLight}
@@ -72,6 +115,14 @@ export default function App() {
<Box px="l">
<Toolbar>
<Toolbar.Item key="routeSwitch" placement="left" />
<Toolbar.Item key="addPage" placement="left">
<Action
tooltip="添加页面"
shape="outline"
icon={<PlusOutlined />}
onClick={() => setShowNewPageModal(true)}
/>
</Toolbar.Item>
<Toolbar.Item key="history" placement="left" />
<Toolbar.Item key="preview" placement="left" />
<Toolbar.Item key="modeSwitch" placement="right" />
@@ -83,6 +134,30 @@ export default function App() {
</Space>
</Toolbar.Item>
</Toolbar>
<Modal
title="添加新页面"
open={showNewPageModal}
onCancel={() => setShowNewPageModal(false)}
footer={null}
>
<Form
form={form}
onFinish={(values) => {
workspace.addViewFile(values.name, emptyPageCode);
setShowNewPageModal(false);
}}
layout="vertical"
>
<Form.Item label="文件名" name="name" required rules={[{ required: true }]}>
<Input placeholder="请输入文件名" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
</Modal>
</Box>
}
>
@@ -92,8 +167,7 @@ export default function App() {
label="组件"
icon={<AppstoreAddOutlined />}
widgetProps={{
menuData: menuData as any,
loading: menuLoading,
menuData,
}}
/>
<Sidebar.Item key="outline" label="结构" icon={<BuildOutlined />} />
@@ -119,18 +193,17 @@ export default function App() {
onMessage={(e) => {
if (e.type === 'done') {
const sandboxWindow: any = sandboxQuery.window;
if (sandboxWindow.TangoAntd) {
if (sandboxWindow.TangoAntd.menuData) {
setMenuData(sandboxWindow.TangoAntd.menuData);
}
if (sandboxWindow.TangoAntd.prototypes) {
workspace.setComponentPrototypes(sandboxWindow.TangoAntd.prototypes);
}
}
// if (sandboxWindow.TangoAntd) {
// if (sandboxWindow.TangoAntd.menuData) {
// setMenuData(sandboxWindow.TangoAntd.menuData);
// }
// if (sandboxWindow.TangoAntd.prototypes) {
// workspace.setComponentPrototypes(sandboxWindow.TangoAntd.prototypes);
// }
// }
if (sandboxWindow.localTangoComponentPrototypes) {
workspace.setComponentPrototypes(sandboxWindow.localTangoComponentPrototypes);
}
setMenuLoading(false);
}
}}
navigatorExtra={<Button size="small">hello world</Button>}

View File

@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { Box } from 'coral-system';
import { Button, Space } from 'antd';
import {
Designer,
DesignerPanel,
SettingPanel,
Sidebar,
Toolbar,
WorkspacePanel,
WorkspaceView,
CodeEditor,
Sandbox,
DndQuery,
themeLight,
} from '@music163/tango-designer';
import { createEngine, Workspace } from '@music163/tango-core';
import { Logo, ProjectDetail, bootHelperVariables } from '@/helpers';
import {
AppstoreAddOutlined,
BuildOutlined,
ClusterOutlined,
createFromIconfontCN,
} from '@ant-design/icons';
import { mailFiles } from '@/helpers/mail-files';
// 1. 实例化工作区
const workspace = new Workspace({
entry: '/src/index.js',
files: mailFiles,
});
// 2. 引擎初始化
const engine = createEngine({
workspace,
});
// @ts-ignore
window.__mailWorkspace__ = workspace;
// 3. 沙箱初始化
const sandboxQuery = new DndQuery({
context: 'iframe',
});
// 4. 图标库初始化(物料面板和组件树使用了 iconfont 里的图标)
createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_2891794_151xsllxqd7.js',
});
/**
* 5. 平台初始化,访问 https://local.netease.com:6006/
*/
export default function App() {
const [menuLoading, setMenuLoading] = useState(true);
const [menuData, setMenuData] = useState(false);
return (
<Designer
theme={themeLight}
engine={engine}
config={{
customActionVariables: bootHelperVariables,
customExpressionVariables: bootHelperVariables,
}}
sandboxQuery={sandboxQuery}
>
<DesignerPanel
logo={<Logo />}
description={<ProjectDetail />}
actions={
<Box px="l">
<Toolbar>
<Toolbar.Item key="history" placement="left" />
<Toolbar.Item key="preview" placement="left" />
<Toolbar.Item key="modeSwitch" placement="right" />
<Toolbar.Item key="togglePanel" placement="right" />
<Toolbar.Separator />
<Toolbar.Item placement="right">
<Space>
<Button type="primary"></Button>
</Space>
</Toolbar.Item>
</Toolbar>
</Box>
}
>
<Sidebar>
<Sidebar.Item
key="components"
label="组件"
icon={<AppstoreAddOutlined />}
widgetProps={{
menuData: menuData as any,
loading: menuLoading,
}}
/>
<Sidebar.Item key="outline" label="结构" icon={<BuildOutlined />} />
<Sidebar.Item
key="dependency"
label="依赖"
icon={<ClusterOutlined />}
isFloat
width={800}
/>
</Sidebar>
<WorkspacePanel>
<WorkspaceView mode="design">
<Sandbox
onMessage={(e) => {
if (e.type === 'done') {
const sandboxWindow: any = sandboxQuery.window;
if (sandboxWindow.TangoMail) {
if (sandboxWindow.TangoMail.menuData) {
setMenuData(sandboxWindow.TangoMail.menuData);
engine.designer.setMenuData(sandboxWindow.TangoMail.menuData);
}
if (sandboxWindow.TangoMail.prototypes) {
workspace.setComponentPrototypes(sandboxWindow.TangoMail.prototypes);
}
}
setMenuLoading(false);
}
}}
navigatorExtra={<Button size="small">hello world</Button>}
/>
</WorkspaceView>
<WorkspaceView mode="code">
<CodeEditor />
</WorkspaceView>
</WorkspacePanel>
<SettingPanel />
</DesignerPanel>
</Designer>
);
}

View File

@@ -16,8 +16,8 @@
"dependencies": {
"@music163/tango-setting-form": "*",
"@music163/tango-ui": "*",
"mobx": "6.12.0",
"mobx-react-lite": "4.0.5"
"mobx": "6.12.3",
"mobx-react-lite": "4.0.7"
},
"devDependencies": {
"@ant-design/icons": "^4.8.0",

View File

@@ -1,158 +1,403 @@
import React from 'react';
import { FormModel, SettingForm, register } from '@music163/tango-setting-form';
import { ComponentPrototypeType } from '@music163/tango-helpers';
import { BorderSetter } from '@music163/tango-designer/src/setters/style-setter';
import { IComponentPrototype } from '@music163/tango-helpers';
import { BUILT_IN_SETTERS } from '@music163/tango-designer/src/setters';
import { Box } from 'coral-system';
import { JsonView } from '@music163/tango-ui';
import { toJS } from 'mobx';
import { observer } from 'mobx-react-lite';
import { Card } from 'antd';
import { createFromIconfontCN } from '@ant-design/icons';
register({
name: 'borderSetter',
component: BorderSetter,
const BLACK_LIST = ['codeSetter', 'eventSetter', 'modelSetter', 'routerSetter'];
BUILT_IN_SETTERS.filter((setter) => !BLACK_LIST.includes(setter.name)).forEach(register);
createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_2891794_151xsllxqd7.js',
});
export default {
title: 'SettingForm',
};
const prototype: ComponentPrototypeType = {
name: 'Test',
exportType: 'namedExport',
title: '测试',
icon: 'icon-test',
/**
* 表单值预览
*/
const FormValuePreview = observer(({ model }: { model: FormModel }) => {
const data = toJS(model.values);
return <JsonView src={data} />;
});
interface SettingFormDemoProps {
initValues?: object;
prototype?: IComponentPrototype;
}
function SettingFormDemo({ initValues, prototype }: SettingFormDemoProps) {
const model = new FormModel(initValues, { onChange: console.log });
return (
<Box display="flex">
<Box flex="0 0 320px" overflow="hidden">
<SettingForm
model={model}
prototype={prototype}
showIdentifier={{
identifierKey: 'tid',
getIdentifier: () => `time${Date.now()}`,
}}
/>
</Box>
<Box position="relative">
<Card title="表单状态预览" style={{ position: 'sticky', top: 0 }}>
<FormValuePreview model={model} />
</Card>
</Box>
</Box>
);
}
const prototypeHasBasicProps: IComponentPrototype = {
name: 'Sample',
title: '演示组件',
package: 'sample-pkg',
type: 'element',
category: 'basic',
package: '@music163/antd',
hasChildren: false,
docs: 'https://4x-ant-design.antgroup.com/components/slider-cn',
props: [
{
name: 'code',
title: 'codeSetter',
setter: 'codeSetter',
},
{
name: 'text',
title: 'textSetter',
setter: 'textSetter',
},
{
name: 'border',
title: 'borderSetter',
setter: 'borderSetter',
name: 'text2',
title: 'textAreaSetter',
setter: 'textAreaSetter',
},
{
name: 'router',
title: 'routerSetter',
setter: 'routerSetter',
name: 'number',
title: 'numberSetter',
setter: 'numberSetter',
},
{
name: 'object',
title: '对象属性',
tip: '一个嵌套的对象',
props: [
{
name: 'name',
title: 'Name',
setter: 'textSetter',
name: 'number2',
title: 'sliderSetter',
setter: 'sliderSetter',
},
{
name: 'bool',
title: 'boolSetter',
setter: 'boolSetter',
},
{
name: 'enum',
title: 'enumSetter',
setter: 'enumSetter',
},
{
name: 'list',
title: 'listSetter',
setter: 'listSetter',
},
],
};
export function Basic() {
return (
<SettingFormDemo
initValues={{
bool: true,
enum: {
aaa: 'aaa',
bbb: 'bbb',
ccc: 'ccc',
},
{
name: 'age',
title: 'Age',
setter: 'numberSetter',
},
{
name: 'address',
title: 'Address',
props: [
{
name: 'city',
title: 'City',
setter: 'textSetter',
list: [{ key: 1 }, { key: 2 }],
}}
prototype={prototypeHasBasicProps}
/>
);
}
export function DeprecatedProp() {
return (
<SettingFormDemo
prototype={{
name: 'Deprecated',
package: 'sample-pkg',
type: 'element',
props: [
{
name: 'number',
title: 'numberSetter',
setter: 'numberSetter',
tip: '这是一个文本属性',
docs: 'https://4x-ant-design.antgroup.com/components/slider-cn',
deprecated: '使用 text2 替代',
},
{
name: 'number1',
title: 'sliderSetter',
setter: 'sliderSetter',
},
],
}}
/>
);
}
export function Validate() {
return (
<SettingFormDemo
prototype={{
name: 'Validate',
package: 'sample-pkg',
type: 'element',
props: [
{
name: 'number',
title: 'numberSetter',
setter: 'numberSetter',
validate: (value) => {
if (!value && value !== 0) {
return '必填';
}
if (value < 0) {
return '必须大于 0';
}
if (value > 10) {
return '必须小于等于 10';
}
},
],
},
],
}}
/>
);
}
export function ObjectSetter() {
return (
<SettingFormDemo
prototype={{
name: 'Object',
package: 'sample-pkg',
type: 'element',
props: [
{
name: 'text',
title: 'textSetter',
setter: 'textSetter',
},
{
name: 'object',
title: 'objectSetter',
setter: 'objectSetter',
props: [
{
name: 'text',
title: 'textSetter',
setter: 'textSetter',
},
{
name: 'number',
title: 'numberSetter',
setter: 'numberSetter',
},
],
},
],
}}
/>
);
}
export function InitValues() {
return (
<SettingFormDemo
initValues={{
bool: true,
bool1: '{{true}}',
style: {
background: 'red',
},
object: {
text: 'text',
number: 10,
},
object1: {
text: 'text',
number: '{{tango.stores.user?.age}}',
},
// 只会有一种情况传下来的是字符串,就是用户代码里存在 rest operator这时候不需要额外处理提示用户就使用代码模式
object2: '{{{ text: "text22", number: 22, ...{ extra: "some" } }}}',
list: [{ key: 'aaa' }, { key: 'bbb' }], // list object
list1: "{{[{ key: 'aaa' }, { key: 'bbb' }]}}", // raw code
}}
prototype={{
name: 'InitValues',
package: 'sample-pkg',
type: 'element',
props: [
{
name: 'bool',
title: 'value初始化',
setter: 'boolSetter',
},
{
name: 'bool1',
title: 'value初始化',
setter: 'boolSetter',
},
{
name: 'bool2',
title: '无初值',
setter: 'boolSetter',
},
{
name: 'style',
title: 'codeSetter',
setter: 'codeSetter',
},
{
name: 'object',
props: [
{
name: 'text',
title: 'text',
setter: 'textSetter',
},
{
name: 'number',
title: 'number',
setter: 'numberSetter',
},
],
},
{
name: 'object1',
props: [
{
name: 'text',
title: 'text',
setter: 'textSetter',
},
{
name: 'number',
title: 'number',
setter: 'numberSetter',
},
],
},
{
name: 'object2',
props: [
{
name: 'text',
title: 'text',
setter: 'textSetter',
},
{
name: 'number',
title: 'number',
setter: 'numberSetter',
},
],
},
{
name: 'list',
title: 'listSetter',
setter: 'listSetter',
},
{
name: 'list1',
title: 'listSetter',
setter: 'listSetter',
},
],
}}
/>
);
}
export function Lite() {
return (
<Box width={320} border="solid" borderColor="line2">
<SettingForm
showSearch={false}
showGroups={false}
showItemSubtitle={false}
prototype={prototypeHasBasicProps}
disableSwitchExpressionSetter
/>
</Box>
);
}
export function HideToggleCode() {
const model = new FormModel({});
return (
<Box width={320} border="solid" borderColor="line2">
<SettingForm model={model} prototype={prototypeHasBasicProps} disableSwitchExpressionSetter />
</Box>
);
}
const prototypeHasExtraProps: IComponentPrototype = {
name: 'ExtraProps',
type: 'element',
package: '@music163/antd',
props: [
{
name: 'choice',
title: 'choiceSetter',
setter: 'choiceSetter',
options: [
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
{ label: '选项3', value: '3' },
],
getVisible: (form) => {
return form.getValue('text') !== 'test';
},
},
{
name: 'expression',
title: 'expressionSetter',
setter: 'expressionSetter',
name: 'picker',
title: 'pickerSetter',
setter: 'pickerSetter',
options: [
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
{ label: '选项3', value: '3' },
],
},
{
name: 'model',
title: 'modelSetter',
setter: 'modelSetter',
name: 'actionList',
title: 'actionListSetter',
setter: 'actionListSetter',
},
{
name: 'image',
title: 'imageSetter',
setter: 'imageSetter',
name: 'list',
title: 'listSetter',
setter: 'listSetter',
},
{
name: 'options',
title: 'optionSetter',
setter: 'optionSetter',
},
{
name: 'columns',
title: 'tableColumnsSetter',
setter: 'tableColumnsSetter',
},
{
name: 'css',
title: 'cssSetter',
setter: 'cssSetter',
},
{
name: 'extra',
title: 'jsxSetter',
setter: 'jsxSetter',
},
{
name: 'icon',
title: 'iconSetter',
setter: 'iconSetter',
},
{
name: 'iconType',
title: 'iconTypeSetter',
setter: 'iconTypeSetter',
},
{
name: 'onClick',
title: 'eventSetter',
tip: '当点击按钮时',
setter: 'eventSetter',
group: 'event',
},
{
name: 'onClick2',
title: 'actionSetter',
tip: '当点击按钮时',
setter: 'actionSetter',
group: 'event',
},
{
name: 'disabled',
title: 'boolSetter',
tip: 'disabled 是否禁用',
defaultValue: false,
setter: 'boolSetter',
},
{
name: 'size',
title: 'choiceSetter',
tip: 'size 按钮的尺寸',
defaultValue: 'medium',
setter: 'choiceSetter',
setterProps: {
options: [
{ label: '小', value: 'small' },
{ label: '中', value: 'medium' },
{ label: '大', value: 'large' },
],
},
},
{
name: 'color',
title: 'colorSetter',
setter: 'colorSetter',
},
{
name: 'columns',
title: 'columnSetter',
setter: 'columnSetter',
},
{
name: 'date',
title: 'dateSetter',
@@ -163,127 +408,137 @@ const prototype: ComponentPrototypeType = {
title: 'dateRangeSetter',
setter: 'dateRangeSetter',
},
{
name: 'enum',
title: 'enumSetter',
setter: 'enumSetter',
setterProps: {},
},
{
name: 'time',
title: 'timeSetter',
setter: 'timeSetter',
},
{
name: 'timeRange',
name: 'time',
title: 'timeRangeSetter',
setter: 'timeRangeSetter',
},
{
name: 'dataSource',
title: 'jsonSetter 不推荐',
name: 'enum',
title: 'enumSetter',
setter: 'enumSetter',
},
{
name: 'event',
title: 'eventSetter',
setter: 'eventSetter',
},
{
name: 'json',
title: 'jsonSetter',
setter: 'jsonSetter',
},
{
name: 'listSetter',
title: 'listSetter',
setter: 'listSetter',
name: 'jsx',
title: 'jsxSetter',
setter: 'jsxSetter',
},
{
name: 'count',
title: 'numberSetter',
setter: 'numberSetter',
name: 'render',
title: 'renderPropsSetter',
setter: 'renderPropsSetter',
},
{
name: 'options',
title: 'optionSetter',
setter: 'optionSetter',
name: 'cell',
title: 'tableCellSetter',
setter: 'tableCellSetter',
},
{
name: 'type',
title: 'pickerSetter',
defaultValue: 'solid',
setter: 'pickerSetter',
setterProps: {
options: [
{ label: '文本型', value: 'text' },
{ label: '实体型', value: 'solid' },
{ label: '幽灵', value: 'ghost' },
],
},
name: 'expandable',
title: 'tableExpandableSetter',
setter: 'tableExpandableSetter',
},
{
name: 'title',
title: 'textSetter',
setter: 'textSetter',
},
{
name: 'invalid',
title: 'invalidSetter',
setter: 'invalidSetter',
name: 'router',
title: 'routerSetter',
setter: 'routerSetter',
},
],
};
/**
* 表单值预览
*/
const FormValuePreview = observer(({ model }: { model: FormModel }) => {
const data = toJS(model.values);
return <JsonView src={data} />;
});
export function Basic() {
const model = new FormModel(
{
router: 'www.163.com',
expression: `{ foo: 'foo' }`,
object: {
name: 'Alice',
},
image:
'https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/13270238619/2cc5/0782/1d6e/009b96bf90c557b9bbde09b1687a2c80.png',
},
{ onChange: console.log },
);
export function ExtraSetters() {
return (
<Box display="flex">
<SettingForm
model={model}
prototype={prototype}
showIdentifier={{
identifierKey: 'tid',
}}
/>
<Box position="relative">
<Card title="表单状态预览" style={{ position: 'sticky', top: 0 }}>
<FormValuePreview model={model} />
</Card>
</Box>
</Box>
<SettingFormDemo
initValues={{
router: 'www.163.com',
expression: `{ foo: 'foo' }`,
object: {
name: 'Alice',
},
image:
'https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/13270238619/2cc5/0782/1d6e/009b96bf90c557b9bbde09b1687a2c80.png',
}}
prototype={prototypeHasExtraProps}
/>
);
}
export function Lite() {
export function StyleProps() {
return (
<Box width={320} border="solid">
<SettingForm
showSearch={false}
showGroups={false}
showItemSubtitle={false}
prototype={prototype}
disableSwitchExpressionSetter
/>
</Box>
);
}
export function NoExpressionSwitch() {
const model = new FormModel({});
return (
<Box>
<SettingForm model={model} prototype={prototype} disableSwitchExpressionSetter />
</Box>
<SettingFormDemo
prototype={{
name: 'Box',
title: 'Box',
type: 'element',
package: '@music163/antd',
props: [
{
name: 'style',
title: 'styleSetter',
setter: 'styleSetter',
},
{
name: 'display',
title: 'displaySetter',
setter: 'displaySetter',
},
{
name: 'flexDirection',
title: 'flexDirectionSetter',
setter: 'flexDirectionSetter',
},
{
name: 'flexGap',
title: 'flexGapSetter',
setter: 'flexGapSetter',
},
{
name: 'flexJustifyContent',
title: 'flexJustifyContentSetter',
setter: 'flexJustifyContentSetter',
},
{
name: 'flexAlignItems',
title: 'flexAlignItemsSetter',
setter: 'flexAlignItemsSetter',
},
{
name: 'spacing',
title: 'spacingSetter',
setter: 'spacingSetter',
},
{
name: 'color',
title: 'colorSetter',
setter: 'colorSetter',
},
{
name: 'bg',
title: 'bgSetter',
setter: 'bgSetter',
},
{
name: 'border',
title: 'borderSetter',
setter: 'borderSetter',
},
],
}}
/>
);
}

View File

@@ -40,6 +40,7 @@
"@typescript-eslint/parser": "^6.13.2",
"conventional-changelog-cli": "^4.1.0",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^8.55.0",
"eslint-config-ali": "^14.0.2",
"eslint-config-prettier": "^9.1.0",
@@ -51,9 +52,9 @@
"husky": "^8.0.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lerna": "^8.0.0",
"lint-staged": "^15.2.0",
"prettier": "^3.1.0",
"lerna": "^8.1.2",
"lint-staged": "^15.2.2",
"prettier": "^3.2.5",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"styled-components": "^5.3.6",

View File

@@ -3,6 +3,34 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.1.1](https://github.com/netease/tango/compare/@music163/tango-context@1.1.0...@music163/tango-context@1.1.1) (2024-05-21)
**Note:** Version bump only for package @music163/tango-context
# [1.1.0](https://github.com/netease/tango/compare/@music163/tango-context@1.0.2...@music163/tango-context@1.1.0) (2024-05-17)
### Features
- refactor parse attribute value ([#149](https://github.com/netease/tango/issues/149)) ([ffaa276](https://github.com/netease/tango/commit/ffaa276b5c205ed962d37e2fdb358a703f8fad01))
## [1.0.1](https://github.com/netease/tango/compare/@music163/tango-context@1.0.0...@music163/tango-context@1.0.1) (2024-04-22)
### Bug Fixes
- allow reload state tree & display pageStore in variable tree ([#135](https://github.com/netease/tango/issues/135)) ([2664613](https://github.com/netease/tango/commit/2664613ab263aead2a5239fa012454fc7fd5ff99))
# [1.0.0-alpha.10](https://github.com/netease/tango/compare/@music163/tango-context@1.0.0-alpha.9...@music163/tango-context@1.0.0-alpha.10) (2024-04-09)
**Note:** Version bump only for package @music163/tango-context
# [1.0.0-alpha.9](https://github.com/netease/tango/compare/@music163/tango-context@1.0.0-alpha.8...@music163/tango-context@1.0.0-alpha.9) (2024-03-26)
**Note:** Version bump only for package @music163/tango-context
# [1.0.0-alpha.8](https://github.com/netease/tango/compare/@music163/tango-context@1.0.0-alpha.7...@music163/tango-context@1.0.0-alpha.8) (2024-03-18)
**Note:** Version bump only for package @music163/tango-context
# [1.0.0-alpha.7](https://github.com/netease/tango/compare/@music163/tango-context@1.0.0-alpha.6...@music163/tango-context@1.0.0-alpha.7) (2024-03-18)
**Note:** Version bump only for package @music163/tango-context

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-context",
"version": "1.0.0-alpha.7",
"version": "1.1.1",
"description": "react context for tango-apps",
"keywords": [
"react",
@@ -31,9 +31,9 @@
"react": ">= 16.8"
},
"dependencies": {
"@music163/tango-core": "^1.0.0-alpha.7",
"@music163/tango-helpers": "^1.0.0-alpha.3",
"mobx-react-lite": "4.0.5"
"@music163/tango-core": "^1.2.0",
"@music163/tango-helpers": "^1.1.1",
"mobx-react-lite": "4.0.7"
},
"publishConfig": {
"access": "public",

View File

@@ -35,8 +35,14 @@ export const useWorkspaceData = () => {
const modelVariables: IVariableTreeNode[] = []; // 绑定变量列表
const storeActionVariables: IVariableTreeNode[] = []; // 模型中的所有 actions
const storeVariables: IVariableTreeNode[] = []; // 模型中的所有变量
const pageStoreVariables: IVariableTreeNode[] = []; // 页面中的组件实例变量
const serviceVariables: IVariableTreeNode[] = []; // 服务中的所有变量
pageStoreVariables.push({
title: 'pageStore',
key: 'pageStore',
});
Object.values(workspace.storeModules).forEach((file) => {
const prefix = `stores.${file.name}`;
const states: IVariableTreeNode[] = file.states.map((item) => ({
@@ -109,6 +115,7 @@ export const useWorkspaceData = () => {
}
let expressionVariables: IVariableTreeNode[] = [
buildVariableOptions('当前页面组件实例', '$pageStore', pageStoreVariables),
buildVariableOptions('数据模型', '$stores', storeVariables),
buildVariableOptions('服务函数', '$services', serviceVariables),
];

View File

@@ -3,6 +3,49 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.2.0](https://github.com/netease/tango/compare/@music163/tango-core@1.1.0...@music163/tango-core@1.2.0) (2024-05-21)
### Bug Fixes
- enhance code value validate in SettingForm ([#152](https://github.com/netease/tango/issues/152)) ([791fbb1](https://github.com/netease/tango/commit/791fbb162a7147243924e01f54e9c0b586f14438))
- prototype2code & go back history error ([#156](https://github.com/netease/tango/issues/156)) ([8bf53a7](https://github.com/netease/tango/commit/8bf53a76f8a71eaf261ea68b9ee44e5bf19893aa))
- support add components with popover ([#155](https://github.com/netease/tango/issues/155)) ([f17ccbb](https://github.com/netease/tango/commit/f17ccbb7f645f8047ecd96d9f3f2185048a3b726))
### Features
- add select parent node of selected node ([#158](https://github.com/netease/tango/issues/158)) ([fe31246](https://github.com/netease/tango/commit/fe3124648325e72abfc58da8b2f8ff83301d40b8))
- supoort set default active view ([#157](https://github.com/netease/tango/issues/157)) ([f854f75](https://github.com/netease/tango/commit/f854f75b8a8c25384d290bd76d6b8bb6fb69f22d))
# [1.1.0](https://github.com/netease/tango/compare/@music163/tango-core@1.0.2...@music163/tango-core@1.1.0) (2024-05-17)
### Bug Fixes
- parse module with module alias ([#147](https://github.com/netease/tango/issues/147)) ([56d0877](https://github.com/netease/tango/commit/56d0877507b0138877eac6f36db288147b43c7d9))
### Features
- refactor parse attribute value ([#149](https://github.com/netease/tango/issues/149)) ([ffaa276](https://github.com/netease/tango/commit/ffaa276b5c205ed962d37e2fdb358a703f8fad01))
## [1.0.1](https://github.com/netease/tango/compare/@music163/tango-core@1.0.0...@music163/tango-core@1.0.1) (2024-04-22)
**Note:** Version bump only for package @music163/tango-core
# [1.0.0-alpha.10](https://github.com/netease/tango/compare/@music163/tango-core@1.0.0-alpha.9...@music163/tango-core@1.0.0-alpha.10) (2024-04-09)
### Bug Fixes
- update setters and use tabOptions to filter props ([#129](https://github.com/netease/tango/issues/129)) ([93608d1](https://github.com/netease/tango/commit/93608d1037327afa4f755976b86427b6128ae3d0))
# [1.0.0-alpha.9](https://github.com/netease/tango/compare/@music163/tango-core@1.0.0-alpha.8...@music163/tango-core@1.0.0-alpha.9) (2024-03-26)
### Features
- quick add sibling nodes ([#127](https://github.com/netease/tango/issues/127)) ([9ed6a7d](https://github.com/netease/tango/commit/9ed6a7d1a4944d69d96e034f243b61531862e317))
# [1.0.0-alpha.8](https://github.com/netease/tango/compare/@music163/tango-core@1.0.0-alpha.7...@music163/tango-core@1.0.0-alpha.8) (2024-03-18)
**Note:** Version bump only for package @music163/tango-core
# [1.0.0-alpha.7](https://github.com/netease/tango/compare/@music163/tango-core@1.0.0-alpha.6...@music163/tango-core@1.0.0-alpha.7) (2024-03-18)
### Features

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-core",
"version": "1.0.0-alpha.7",
"version": "1.2.0",
"description": "tango core",
"author": "wwsun <ww.sun@outlook.com>",
"homepage": "",
@@ -28,10 +28,10 @@
"@babel/parser": "^7.23.5",
"@babel/traverse": "^7.23.5",
"@babel/types": "^7.23.5",
"@music163/tango-helpers": "^1.0.0-alpha.3",
"@music163/tango-helpers": "^1.1.1",
"@types/babel__generator": "^7.6.7",
"@types/babel__traverse": "^7.20.4",
"mobx": "6.12.0",
"mobx": "6.12.3",
"path-browserify": "^1.0.1"
},
"peerDependencies": {

View File

@@ -1,4 +1,5 @@
import { Designer, Engine, SimulatorNameType } from './models';
import { MenuDataType } from '@music163/tango-helpers';
import { Designer, DesignerViewType, Engine, SimulatorNameType } from './models';
import { IWorkspace } from './models/interfaces';
interface ICreateEngineOptions {
@@ -6,6 +7,10 @@ interface ICreateEngineOptions {
* 自定义工作区
*/
workspace?: IWorkspace;
/**
* 菜单信息
*/
menuData?: MenuDataType;
/**
* 默认的模拟器模式
*/
@@ -14,6 +19,10 @@ interface ICreateEngineOptions {
* 默认激活的侧边栏
*/
defaultActiveSidebarPanel?: string;
/**
* 默认激活的视图
*/
defaultActiveView?: DesignerViewType;
}
/**
@@ -23,15 +32,19 @@ interface ICreateEngineOptions {
*/
export function createEngine({
workspace,
defaultActiveView = 'design',
defaultSimulatorMode = 'desktop',
defaultActiveSidebarPanel = '',
menuData,
}: ICreateEngineOptions) {
const engine = new Engine({
workspace,
designer: new Designer({
workspace,
simulator: defaultSimulatorMode,
activeView: defaultActiveView,
activeSidebarPanel: defaultActiveSidebarPanel,
menuData,
}),
});

View File

@@ -40,6 +40,7 @@ const templatePattern = /^{(.+)}$/s;
/**
* 判断给定字符串是否被表达式容器`{expCode}`包裹
* @param code
* @deprecated 新版改为 {{code}} 作为容器,使用 isWrappedCode 代替
*/
export function isWrappedByExpressionContainer(code: string, isStrict = true) {
if (isStrict && isValidExpressionCode(code)) {

View File

@@ -3,7 +3,7 @@
*/
import generator, { GeneratorOptions } from '@babel/generator';
import * as t from '@babel/types';
import { logger } from '@music163/tango-helpers';
import { logger, wrapCode } from '@music163/tango-helpers';
import { formatCode } from '../string';
const defaultGeneratorOptions: GeneratorOptions = {
@@ -156,10 +156,10 @@ export function node2code(node: t.Node) {
/**
* 将 t.Node 生成为 js 值
* @param node ast node
* @param hasExpressionWrapper 是否包裹表达式
* @param isWrapCode 是否包裹代码,例如 code -> {{code}}
* @returns a plain javascript value
*/
export function node2value(node: t.Node, hasExpressionWrapper = true): any {
export function node2value(node: t.Node, isWrapCode = true): any {
let ret;
switch (node.type) {
case 'StringLiteral':
@@ -171,39 +171,50 @@ export function node2value(node: t.Node, hasExpressionWrapper = true): any {
case 'NullLiteral':
ret = null;
break;
case 'Identifier': // {data}
case 'MemberExpression': // {this.props.data}
case 'OptionalMemberExpression': // {a?.b}
case 'UnaryExpression': // {!false}
case 'ArrowFunctionExpression': // {() => {}}
case 'TemplateLiteral': // {`hello ${text}`}
case 'ConditionalExpression': // {a ? 'foo' : 'bar'}
case 'LogicalExpression': // { a || b}
case 'BinaryExpression': // { a + b}
case 'TaggedTemplateExpression': // {css``}
case 'CallExpression': // {[1,2,3].map(fn)}
case 'JSXElement': // {<Box>hello</Box>}
case 'JSXFragment': // <><Box /></>
case 'Identifier': // {{data}}
case 'MemberExpression': // {{this.props.data}}
case 'OptionalMemberExpression': // {{a?.b}}
case 'UnaryExpression': // {{!false}}
case 'ArrowFunctionExpression': // {{() => {}}}
case 'TemplateLiteral': // {{`hello ${text}`}}
case 'ConditionalExpression': // {{a ? 'foo' : 'bar'}}
case 'LogicalExpression': // {{ a || b}}
case 'BinaryExpression': // {{ a + b}}
case 'TaggedTemplateExpression': // {{css``}}
case 'CallExpression': // {{[1,2,3].map(fn)}}
case 'JSXElement': // {{<Box>hello</Box>}}
case 'JSXFragment': // {{<><Box /></>}}
ret = expression2code(node);
if (hasExpressionWrapper) {
ret = `{${ret}}`;
if (isWrapCode) {
ret = wrapCode(ret);
}
break;
case 'ObjectExpression': {
ret = node.properties.reduce((prev, propertyNode) => {
if (propertyNode.type === 'ObjectProperty') {
const key = keyNode2value(propertyNode.key);
const value = node2value(propertyNode.value, hasExpressionWrapper);
// key 可能是字符串,也可能是数字
prev[key] = value;
const isSimpleObject = node.properties.every(
(propertyNode) => propertyNode.type === 'ObjectProperty',
);
if (isSimpleObject) {
// simple object: { key1, key2, key3 }
ret = node.properties.reduce((prev, propertyNode) => {
if (propertyNode.type === 'ObjectProperty') {
const key = keyNode2value(propertyNode.key);
const value = node2value(propertyNode.value, isWrapCode);
prev[key] = value; // key 可能是字符串,也可能是数字
}
return prev;
}, {});
} else {
// mixed object, object property maybe SpreadElement or ObjectMethod, e.g. { key1, fn() {}, ...obj1 }
ret = expression2code(node);
if (wrapCode) {
ret = wrapCode(ret);
}
// FIXME: property is a SpreadElement
return prev;
}, {});
}
break;
}
case 'ArrayExpression': {
ret = node.elements.map((elementNode) => node2value(elementNode, hasExpressionWrapper));
// FIXME: 有可能会解析失败
ret = node.elements.map((elementNode) => node2value(elementNode, isWrapCode));
break;
}
default:
@@ -214,7 +225,7 @@ export function node2value(node: t.Node, hasExpressionWrapper = true): any {
}
/**
* jsx 属性值节点转为 js value
* jsx prop value 节点转为 js value
*/
export function jsxAttributeValueNode2value(node: t.Node): any {
// e.g. <Checkbox checked /> 此时没有 value node
@@ -232,9 +243,16 @@ export function jsxAttributeValueNode2value(node: t.Node): any {
// <Foo bar={[]}>
ret = jsxAttributeValueNode2value(node.expression);
break;
default:
case 'ArrayExpression': {
// 数组统一处理为 code
ret = expression2code(node);
ret = wrapCode(ret);
break;
}
default: {
ret = node2value(node);
break;
}
}
return ret;

View File

@@ -5,12 +5,12 @@ import { parse, parseExpression, ParserOptions } from '@babel/parser';
import * as t from '@babel/types';
import {
logger,
isValidObjectString,
getVariableContent,
isPlainObject,
Dict,
isWrappedCode,
getCodeOfWrappedCode,
} from '@music163/tango-helpers';
import { isWrappedByExpressionContainer } from '../assert';
// @see https://babeljs.io/docs/en/babel-parser#pluginss
const babelParserConfig: ParserOptions = {
@@ -97,8 +97,7 @@ export function code2expression(code: string) {
try {
expNode = t.cloneNode(parseExpression(code, babelParserConfig), false, true);
} catch (err) {
logger.error('invalid code', err);
// expNode = t.identifier('undefined');
logger.error('code2expression failed, invalid code:', code);
}
return expNode;
}
@@ -109,9 +108,6 @@ export function code2expression(code: string) {
* @returns File
*/
export function expressionCode2ast(code: string) {
if (isWrappedByExpressionContainer(code)) {
code = getVariableContent(code);
}
const node = code2expression(code);
return t.file(t.program([t.blockStatement([t.expressionStatement(node)])]));
}
@@ -132,18 +128,19 @@ export function value2node(
| t.Expression {
let ret;
switch (typeof value) {
case 'number':
ret = t.numericLiteral(value);
break;
case 'string':
if (isWrappedByExpressionContainer(value)) {
// 再检查是否是表达式容器,例如 {this.foo}, {1}
const innerString = getVariableContent(value);
ret = code2expression(innerString);
if (isWrappedCode(value)) {
// 再检查是否是代码 {{code}},例如 {{this.foo}}, {{1}}
const innerCode = getCodeOfWrappedCode(value);
ret = code2expression(innerCode);
} else {
// 否则当成字符串处理
ret = t.stringLiteral(value);
}
break;
case 'number':
ret = t.numericLiteral(value);
break;
case 'boolean':
ret = t.booleanLiteral(value);
break;
@@ -167,7 +164,7 @@ export function value2node(
ret = t.identifier('undefined');
break;
default: {
logger.error(`value2node: unsupport value <${value}>`);
logger.error(`value2node: value <${value}> transform failed!`);
break;
}
}
@@ -196,21 +193,23 @@ export function code2jsxAttributeValueNode(code: string) {
return t.jsxExpressionContainer(code2expression(code));
}
/**
* FIXME: 统一处理为 code2jsxAttributeValueNode
* 将 js value 转为 JSXAttributeValueNode
* @param value js value, or wrapped code
* @returns 返回 JSXAttributeValueNode转换失败返回Node为 {undefined}
*/
export function value2jsxAttributeValueNode(value: any) {
let ret;
switch (typeof value) {
// FIXME: 重构这个逻辑,是不是统一当成 code 处理
case 'string': {
if (value.length > 1) {
value = value.trim();
}
if (isValidObjectString(value)) {
// 先检查是否是对象字符串
ret = t.jsxExpressionContainer(code2expression(value));
} else if (isWrappedByExpressionContainer(value)) {
// 再检查是否是表达式容器,例如 {this.foo}, {1}
const innerString = getVariableContent(value);
ret = t.jsxExpressionContainer(code2expression(innerString));
if (isWrappedCode(value)) {
const innerCode = getCodeOfWrappedCode(value);
const node = code2expression(innerCode);
ret = t.jsxExpressionContainer(node || t.identifier('undefined'));
} else {
ret = t.stringLiteral(value);
}
@@ -223,11 +222,12 @@ export function value2jsxAttributeValueNode(value: any) {
return ret;
}
// TODO: 待校验
export function value2jsxChildrenValueNode(value: any) {
let ret: t.JSXElement | t.JSXFragment | t.JSXExpressionContainer | t.JSXSpreadChild | t.JSXText;
switch (typeof value) {
case 'string':
if (isWrappedByExpressionContainer(value)) {
if (isValidExpressionCode(value)) {
const innerString = getVariableContent(value);
ret = t.jsxExpressionContainer(code2expression(innerString));
} else {

View File

@@ -1,27 +1,8 @@
import { getVariableContent } from '@music163/tango-helpers';
import { value2node, expression2code, isValidExpressionCode } from './ast';
import { isWrappedByExpressionContainer } from './assert';
import { getCodeOfWrappedCode, isWrappedCode } from '@music163/tango-helpers';
import { value2node, expression2code, code2expression, node2value } from './ast';
/**
* js value 转为代码字符串
*/
export function value2code(value: any) {
const node = value2node(value);
const code = expression2code(node);
return code;
}
/**
* 是否是字符串代码
* @param code
* @returns
*/
function isStringCode(code: string) {
return /^".*"$/.test(code?.trim());
}
/**
* js value 转为表达式代码
* js value 转为代码字符串
* @example 1 => 1
* @example hello => "hello"
* @example { foo: bar } => {{ foo: bar }}
@@ -30,34 +11,56 @@ function isStringCode(code: string) {
* @param val js value
* @returns 表达式代码
*/
export function value2expressionCode(val: any) {
if (!val) return '';
export function value2code(val: any) {
if (val === undefined) {
return '';
}
if (val === null) {
return 'null';
}
let ret;
switch (typeof val) {
case 'string': {
if (isValidExpressionCode(val)) {
ret = val;
} else if (isWrappedByExpressionContainer(val, false)) {
ret = getVariableContent(val);
} else if (isStringCode(val)) {
ret = val;
if (isWrappedCode(val)) {
ret = getCodeOfWrappedCode(val);
} else {
ret = `"${val}"`;
}
break;
}
case 'boolean':
case 'function':
case 'number':
ret = String(val);
break;
case 'object':
ret = value2code(val);
break;
default:
ret = '';
default: {
// other cases, including array, object, null, undefined
const node = value2node(val);
ret = expression2code(node);
break;
}
}
return ret;
}
export const value2expressionCode = value2code;
/**
* 代码字符串转为具体的 js value
* @example `() => {}` 返回 undefined
*
* @param rawCode 代码字符串
* @returns 返回解析后的 js value包括string, number, boolean, simpleObject, simpleArray
*/
export function code2value(rawCode: string) {
const node = code2expression(rawCode);
const value = node2value(node);
if (isWrappedCode(value)) {
// 能转的就转,转不能的就返回空
return;
}
return value;
}

View File

@@ -1,22 +1,26 @@
import * as t from '@babel/types';
import {
ComponentPropType,
ComponentPrototypeType,
IComponentProp,
IComponentPrototype,
Dict,
isNil,
logger,
uuid,
isWrappedCode,
getCodeOfWrappedCode,
wrapCodeWithJSXExpressionContainer,
} from '@music163/tango-helpers';
import { getRelativePath, isFilepath } from './string';
import type { IImportDeclarationPayload, IImportSpecifierData } from '../types';
import { code2expression } from './ast';
import { isWrappedByExpressionContainer } from './assert';
import { value2code } from './code-helpers';
export function prototype2importDeclarationData(
prototype: ComponentPrototypeType,
prototype: IComponentPrototype,
relativeFilepath?: string,
): { source: string; specifiers: IImportSpecifierData[] } {
let source = prototype.package;
const isSnippet = prototype.type === 'snippet';
if (relativeFilepath && isFilepath(source)) {
source = getRelativePath(relativeFilepath, source);
}
@@ -32,12 +36,15 @@ export function prototype2importDeclarationData(
type: 'ImportDefaultSpecifier',
});
} else {
[prototype.name, ...(prototype.relatedImports || [])].forEach((item) => {
specifiers.push({
localName: item,
type: 'ImportSpecifier',
});
});
// 忽略代码片段 name
[...(isSnippet ? [] : [prototype.name]), ...(prototype.relatedImports || [])].forEach(
(item) => {
specifiers.push({
localName: item,
type: 'ImportSpecifier',
});
},
);
}
return {
@@ -51,7 +58,7 @@ export function prototype2importDeclarationData(
* @deprecated
*/
export function getImportDeclarationPayloadByPrototype(
prototype: ComponentPrototypeType,
prototype: IComponentPrototype,
relativeFilepath?: string,
): IImportDeclarationPayload {
let defaultSpecifier;
@@ -84,48 +91,56 @@ export function getImportDeclarationPayloadByPrototype(
/**
* 基于 key-value 生成 prop={value} 字符串
* @param key
* @param value
* @example { name: 'foo', initValue: false } >> name={false}
* @example { name: 'foo', initValue: 1 } >> name={1}
* @example { name: 'foo', initValue: () => {} } >> name={()=>{}}
* @example { name: 'foo', initValue: { foo: 'bar' } } >> name={{ foo: 'bar' }}
* @example { name: 'foo', initValue: [{ foo: 'bar' }] } >> name={[{ foo: 'bar' }]}
* @example { name: 'foo', initValue: 'bar' } >> name="bar"
* @example { name: 'foo', initValue: '{() => {}}' } >> name={()=>{}}
* @example { name: 'foo', initValue: '{{() => {}}}' } >> name={() => {}}
* @example { name: 'foo', initValue: '{bar}' } >> name={bar}
* @returns
*/
function getPropKeyValuePair(item: ComponentPropType, generateValue: (...args: any[]) => string) {
export function propDataToKeyValueString(
item: IComponentProp,
generateValue?: (...args: any[]) => string,
) {
const key = item.name;
let value = item.initValue;
if (!value && item.autoInitValue) {
value = generateValue(3);
value = generateValue?.(3) || uuid(key, 3);
}
if (isNil(value)) {
if (value === undefined) {
return;
}
switch (typeof value) {
case 'number':
case 'boolean': {
value = `{${value}}`;
case 'boolean':
case 'function': {
value = wrapCodeWithJSXExpressionContainer(String(value));
break;
}
case 'object': {
// TIP: bugfix 如果 object 里有 jsx 或者 function 会失败
try {
value = `{${JSON.stringify(value)}}`;
value = wrapCodeWithJSXExpressionContainer(value2code(value));
} catch (err) {
logger.error(err);
}
break;
}
case 'function': {
value = `{${(value as object).toString()}}`;
break;
}
case 'string': {
if (!isWrappedByExpressionContainer(value)) {
// 不是变量字符串
value = `"${value}"`;
if (isWrappedCode(value)) {
const innerCode = getCodeOfWrappedCode(value);
value = wrapCodeWithJSXExpressionContainer(innerCode);
} else if (isWrappedByExpressionContainer(value)) {
// TIP: 兼容旧版逻辑,如果是变量字符串,无需处理
} else {
// 如果是变量字符串,无需处理
value = `"${value}"`;
}
break;
}
@@ -141,25 +156,25 @@ function getPropKeyValuePair(item: ComponentPropType, generateValue: (...args: a
* @param prototype
* @param extraProps
*/
export function prototype2code(prototype: ComponentPrototypeType, extraProps?: Dict) {
export function prototype2code(prototype: IComponentPrototype, extraProps?: Dict) {
let code;
switch (prototype.type) {
case 'snippet':
code = prototype.initChildren || prototype.defaultChildren;
break;
default: {
const propList: ComponentPropType[] = extraProps
const propList: IComponentProp[] = extraProps
? Object.keys(extraProps).map((key) => ({
name: key,
initValue: extraProps[key],
}))
: [];
// merge extraProps to props
const props = [...prototype.props, ...propList];
const props = [...(prototype.props || []), ...propList];
const keys =
props.reduce((acc, item) => {
const pair = getPropKeyValuePair(item, (fractionDigits: number) =>
const pair = propDataToKeyValueString(item, (fractionDigits: number) =>
uuid(prototype.name, fractionDigits),
);
return pair ? ` ${acc} ${pair}` : acc;
@@ -186,7 +201,7 @@ export function prototype2code(prototype: ComponentPrototypeType, extraProps?: D
* @param prototype 组件的配置信息
* @param props 额外的属性集
*/
export function prototype2jsxElement(prototype: ComponentPrototypeType, props?: Dict) {
export function prototype2jsxElement(prototype: IComponentPrototype, props?: Dict) {
const code = prototype2code(prototype, props);
return code2expression(code) as t.JSXElement;
}

View File

@@ -75,15 +75,20 @@ export function inferFileType(filename: string): FileType {
/**
* 判断组件名是否合法
* @example Button -> valid
* @example div -> invalid
* @example isStrict ? div -> invalid : div -> valid
* @param name
* @param isStrict 是否严格模式(严格模式不匹配原生标签)
* @returns
*/
export function isValidComponentName(name: string) {
export function isValidComponentName(name: string, isStrict = false) {
if (!name) {
return false;
}
if (!isStrict) {
return true;
}
const firstChar = name.charAt(0);
return firstChar === firstChar.toUpperCase();
}

View File

@@ -1,9 +1,10 @@
import { action, computed, makeObservable, observable, toJS } from 'mobx';
import { IWorkspace } from './interfaces';
import { MenuDataType } from '@music163/tango-helpers';
export type SimulatorNameType = 'desktop' | 'phone';
export type DesignerViewType = 'design' | 'code';
export type DesignerViewType = 'design' | 'code' | 'dual';
interface ISimulatorType {
name: SimulatorNameType;
@@ -19,7 +20,15 @@ interface IViewportBounding {
interface IDesignerOptions {
workspace: IWorkspace;
simulator?: SimulatorNameType | ISimulatorType;
/**
* 菜单配置
*/
menuData: MenuDataType;
activeSidebarPanel?: string;
/**
* 默认激活的视图模式
*/
activeView?: DesignerViewType;
}
const ISimulatorTypes: Record<string, ISimulatorType> = {
@@ -64,6 +73,16 @@ export class Designer {
*/
_showSmartWizard = false;
/**
* 是否显示添加组件面板
*/
_showAddComponentPopover = false;
/**
* 添加组件面板的位置
*/
_addComponentPopoverPosition = { clientX: 0, clientY: 0 };
/**
* 是否显示右侧面板
*/
@@ -75,9 +94,9 @@ export class Designer {
_isPreview = false;
/**
* 默认展开的侧边栏
* 菜单列表
*/
defaultActiveSidebarPanel?: string;
_menuData?: MenuDataType = null;
private readonly workspace: IWorkspace;
@@ -109,10 +128,31 @@ export class Designer {
return this._showRightPanel;
}
get showAddComponentPopover() {
return this._showAddComponentPopover;
}
get addComponentPopoverPosition() {
return this._addComponentPopoverPosition;
}
get menuData() {
return this._menuData ?? ([] as MenuDataType);
}
constructor(options: IDesignerOptions) {
this.workspace = options.workspace;
const { simulator, activeSidebarPanel: defaultActiveSidebarPanel } = options;
const {
simulator,
menuData,
activeSidebarPanel: defaultActiveSidebarPanel,
activeView: defaultActiveView,
} = options;
if (menuData) {
this.setMenuData(menuData);
}
// 默认设计器模式
if (simulator) {
@@ -124,6 +164,11 @@ export class Designer {
this.setActiveSidebarPanel(defaultActiveSidebarPanel);
}
// 默认激活的视图
if (defaultActiveView) {
this.setActiveView(defaultActiveView);
}
makeObservable(this, {
_simulator: observable,
_viewport: observable,
@@ -131,6 +176,9 @@ export class Designer {
_activeSidebarPanel: observable,
_showSmartWizard: observable,
_showRightPanel: observable,
_showAddComponentPopover: observable,
_addComponentPopoverPosition: observable,
_menuData: observable,
_isPreview: observable,
simulator: computed,
viewport: computed,
@@ -139,6 +187,9 @@ export class Designer {
isPreview: computed,
showRightPanel: computed,
showSmartWizard: computed,
showAddComponentPopover: computed,
addComponentPopoverPosition: computed,
menuData: computed,
setSimulator: action,
setViewport: action,
setActiveView: action,
@@ -147,6 +198,7 @@ export class Designer {
toggleRightPanel: action,
toggleSmartWizard: action,
toggleIsPreview: action,
toggleAddComponentPopover: action,
});
}
@@ -173,6 +225,11 @@ export class Designer {
this._activeSidebarPanel = '';
}
}
setMenuData(menuData: MenuDataType) {
this._menuData = menuData;
}
closeSidebarPanel() {
this._activeSidebarPanel = '';
}
@@ -185,6 +242,22 @@ export class Designer {
this._showRightPanel = value ?? !this._showRightPanel;
}
/**
* 显示添加组件面板
* @param value 是否显示
* @param position 坐标
*/
toggleAddComponentPopover(
value: boolean,
position: {
clientX: number;
clientY: number;
} = this.addComponentPopoverPosition,
) {
this._showAddComponentPopover = value;
this._addComponentPopoverPosition = position;
}
toggleIsPreview(value: boolean) {
this._isPreview = value ?? !this._isPreview;
if (value) {

View File

@@ -11,6 +11,8 @@ export enum HistoryMessage {
ReplaceNode = 'replaceNode',
CloneNode = 'cloneNode',
InsertNode = 'insertNode',
InsertBeforeNode = 'insertBeforeNode',
InsertAfterNode = 'insertAfterNode',
DropNode = 'dropNode',
UpdateAttribute = 'updateAttribute',
UpdateCode = 'updateCode',
@@ -56,7 +58,7 @@ export class TangoHistory {
}
get couldBack() {
return this._records.length > 0 && this._index > -1;
return this._records.length > 0 && this._index > 0;
}
get couldForward() {

View File

@@ -1,4 +1,4 @@
import { ComponentPrototypeType, Dict, ITangoConfigJson } from '@music163/tango-helpers';
import { IComponentPrototype, Dict, ITangoConfigJson } from '@music163/tango-helpers';
import { TangoHistory } from './history';
import { SelectSource } from './select-source';
import { DragSource } from './drag-source';
@@ -141,7 +141,7 @@ export interface IWorkspace {
dragSource: DragSource;
files: Map<string, TangoFile>;
componentPrototypes: Map<string, ComponentPrototypeType>;
componentPrototypes: Map<string, IComponentPrototype>;
entry: string;
activeFile: string;
@@ -171,8 +171,8 @@ export interface IWorkspace {
setActiveRoute: (path: string) => void;
setActiveFile: (filename: string) => void;
setComponentPrototypes: (prototypes: Record<string, ComponentPrototypeType>) => void;
getPrototype: (name: string | ComponentPrototypeType) => ComponentPrototypeType;
setComponentPrototypes: (prototypes: Record<string, IComponentPrototype>) => void;
getPrototype: (name: string | IComponentPrototype) => IComponentPrototype;
// ----------------- 文件操作 -----------------
addFiles: (files: IFileConfig[]) => void;
@@ -204,16 +204,12 @@ export interface IWorkspace {
cloneSelectedNode: () => void;
copySelectedNode: () => void;
pasteSelectedNode: () => void;
insertToSelectedNode: (childNameOrPrototype: string | ComponentPrototypeType) => void;
insertToSelectedNode: (childNameOrPrototype: string | IComponentPrototype) => void;
insertBeforeSelectedNode: (sourceNameOrPrototype: string | IComponentPrototype) => void;
insertAfterSelectedNode: (sourceNameOrPrototype: string | IComponentPrototype) => void;
dropNode: () => void;
insertToNode: (
targetNodeId: string,
sourceNameOrPrototype: string | ComponentPrototypeType,
) => void;
replaceNode: (
targetNodeId: string,
sourceNameOrPrototype: string | ComponentPrototypeType,
) => void;
insertToNode: (targetNodeId: string, sourceNameOrPrototype: string | IComponentPrototype) => void;
replaceNode: (targetNodeId: string, sourceNameOrPrototype: string | IComponentPrototype) => void;
updateSelectedNodeAttributes: (
attributes: Record<string, any>,
relatedImports?: string[],
@@ -289,9 +285,16 @@ export interface IWorkspace {
* 解析后的项目配置信息
*/
get projectConfig(): ITangoConfigJson;
/**
* 当前活动的视图文件
*/
get activeViewModule(): IViewFile;
get pages(): any[];
get bizComps(): string[];
get baseComps(): string[];
get localComps(): string[];
/**
* 是否是合法的项目
*/
get isValid(): boolean;
}

View File

@@ -109,6 +109,20 @@ export class SelectSource {
this._start = null;
}
/**
* 选中当前选中节点的父节点
*/
selectParent() {
const parents = this.first?.parents || [];
if (parents.length) {
const [parent, ...rest] = parents;
this.select({
...parent,
parents: rest,
});
}
}
setStart(data: StartDataType) {
this._start = data;
}

View File

@@ -1,7 +1,7 @@
import { action, computed, makeObservable, observable } from 'mobx';
import { JSXElement } from '@babel/types';
import {
ComponentPrototypeType,
IComponentPrototype,
Dict,
ITangoConfigJson,
hasFileExtension,
@@ -52,7 +52,7 @@ export interface IWorkspaceOptions {
/**
* 组件描述列表
*/
prototypes?: Record<string, ComponentPrototypeType>;
prototypes?: Record<string, IComponentPrototype>;
/**
* 工作区文件变更事件
*/
@@ -75,7 +75,7 @@ export class Workspace extends EventTarget implements IWorkspace {
/**
* 组件配置
*/
componentPrototypes: Map<string, ComponentPrototypeType>;
componentPrototypes: Map<string, IComponentPrototype>;
/**
* 入口文件
@@ -144,6 +144,13 @@ export class Workspace extends EventTarget implements IWorkspace {
*/
private copyTempNodes: TangoNode[];
/**
* 是否是合法的项目
*/
get isValid() {
return !!this.tangoConfigJson && !!this.activeViewModule;
}
/**
* 项目配置,返回解析后的 tango.config.json 文件
*/
@@ -249,11 +256,11 @@ export class Workspace extends EventTarget implements IWorkspace {
});
}
getPrototype(name: string | ComponentPrototypeType) {
getPrototype(name: string | IComponentPrototype) {
if (isString(name)) {
return this.componentPrototypes.get(name);
}
return name as ComponentPrototypeType;
return name as IComponentPrototype;
}
/**
@@ -299,7 +306,7 @@ export class Workspace extends EventTarget implements IWorkspace {
}
}
setComponentPrototypes(prototypes: Record<string, ComponentPrototypeType>) {
setComponentPrototypes(prototypes: Record<string, IComponentPrototype>) {
Object.keys(prototypes).forEach((name) => {
this.componentPrototypes.set(name, prototypes[name]);
});
@@ -954,7 +961,7 @@ export class Workspace extends EventTarget implements IWorkspace {
* @param sourceName 插入的组件名称
* @returns
*/
insertToNode(targetNodeId: string, sourceName: string | ComponentPrototypeType) {
insertToNode(targetNodeId: string, sourceName: string | IComponentPrototype) {
if (!targetNodeId || !sourceName) {
return;
}
@@ -978,7 +985,7 @@ export class Workspace extends EventTarget implements IWorkspace {
/**
* 替换目标节点
*/
replaceNode(targetNodeId: string, sourceName: string | ComponentPrototypeType) {
replaceNode(targetNodeId: string, sourceName: string | IComponentPrototype) {
if (!targetNodeId || !sourceName) {
return;
}
@@ -1000,7 +1007,7 @@ export class Workspace extends EventTarget implements IWorkspace {
* 在选中节点中插入子节点
* @param childName 节点名
*/
insertToSelectedNode(childName: string | ComponentPrototypeType) {
insertToSelectedNode(childName: string | IComponentPrototype) {
const insertedPrototype = this.getPrototype(childName);
if (insertedPrototype) {
const newNode = prototype2jsxElement(insertedPrototype);
@@ -1022,6 +1029,46 @@ export class Workspace extends EventTarget implements IWorkspace {
}
}
insertBeforeSelectedNode(sourceName: string | IComponentPrototype) {
if (!sourceName) {
return;
}
const targetNodeId = this.selectSource.first.id;
const sourcePrototype = this.getPrototype(sourceName);
const newNode = prototype2jsxElement(sourcePrototype);
const file = this.getNode(targetNodeId).file;
const { source, specifiers } = prototype2importDeclarationData(sourcePrototype, file.filename);
file.insertBefore(targetNodeId, newNode).addImportSpecifiers(source, specifiers).update();
this.history.push({
message: HistoryMessage.InsertBeforeNode,
data: {
[file.filename]: file.code,
},
});
}
insertAfterSelectedNode(sourceName: string | IComponentPrototype) {
if (!sourceName) {
return;
}
const targetNodeId = this.selectSource.first.id;
const sourcePrototype = this.getPrototype(sourceName);
const newNode = prototype2jsxElement(sourcePrototype);
const file = this.getNode(targetNodeId).file;
const { source, specifiers } = prototype2importDeclarationData(sourcePrototype, file.filename);
file.insertAfter(targetNodeId, newNode).addImportSpecifiers(source, specifiers).update();
this.history.push({
message: HistoryMessage.InsertAfterNode,
data: {
[file.filename]: file.code,
},
});
}
updateSelectedNodeAttributes(
attributes: Record<string, any> = {},
relatedImports: string[] = [],
@@ -1186,8 +1233,17 @@ export class Workspace extends EventTarget implements IWorkspace {
let filename: string;
this.routeModule?.routes.forEach((route) => {
if (isPathnameMatchRoute(routePath, route.path) && route.importPath) {
const absolutePath = route.importPath.replace('.', '/src');
filename = this.getRealViewFilePath(absolutePath);
if (route.importPath.startsWith('@/')) {
filename = route.importPath;
const alias = this.tangoConfigJson.getValue('sandbox.alias') || {};
if (alias['@']) {
filename = filename.replace('@', alias['@']);
}
filename = this.getRealViewFilePath(filename);
} else {
const absolutePath = route.importPath.replace('.', '/src');
filename = this.getRealViewFilePath(absolutePath);
}
}
});
return filename;

View File

@@ -1,4 +1,4 @@
import { isTangoVariable, isWrappedByExpressionContainer } from '../src/helpers';
import { isTangoVariable } from '../src/helpers';
describe('assert', () => {
it('isTangoVariable', () => {
@@ -7,19 +7,4 @@ describe('assert', () => {
expect(isTangoVariable('tango.stores.app?.name')).toBeTruthy();
// expect(isTangoVariable('tango.copyToClipboard')).toBeTruthy();
});
it('isWrappedByExpressionContainer', () => {
expect(isWrappedByExpressionContainer('{this.foo}')).toBeTruthy();
expect(isWrappedByExpressionContainer('{!false}')).toBeTruthy();
expect(isWrappedByExpressionContainer('{[]}')).toBeTruthy();
expect(isWrappedByExpressionContainer('{{ foo: "bar" }}')).toBeTruthy();
expect(isWrappedByExpressionContainer('{[{ foo: "bar" }]}')).toBeTruthy();
expect(isWrappedByExpressionContainer('{123}')).toBeTruthy();
expect(isWrappedByExpressionContainer('{"hello"}')).toBeTruthy();
expect(isWrappedByExpressionContainer('{ foo: "bar" }')).toBeFalsy();
expect(isWrappedByExpressionContainer('{ type: tango.stores?.homePage?.tabKey }')).toBeFalsy();
expect(isWrappedByExpressionContainer('{ type: tango.stores.homePage.tabKey }')).toBeFalsy();
expect(isWrappedByExpressionContainer('{ foo: "bar" }')).toBeFalsy();
expect(isWrappedByExpressionContainer('{ color: tango.stores.app.color }')).toBeFalsy();
});
});

View File

@@ -1,19 +1,28 @@
import { Identifier } from '@babel/types';
import {
object2node,
serviceConfig2Node,
isValidCode,
isValidExpressionCode,
code2expression,
value2jsxAttributeValueNode,
} from '../src/helpers';
describe('ast helpers', () => {
it('isValidCode', () => {
expect(isValidCode('')).toBeTruthy();
expect(isValidCode('1')).toBeTruthy();
expect(isValidCode('"hello"')).toBeTruthy();
expect(isValidCode('<div>hello</div>')).toBeTruthy();
expect(isValidCode('function fn() {}')).toBeTruthy();
// invalid function body
expect(isValidCode('() => { hello world }')).toBeFalsy();
// invalid function
expect(isValidCode('function() {}')).toBeFalsy();
});
it('isValidExpression', () => {
expect(isValidExpressionCode('() => { }')).toBeTruthy();
it('isValidExpressionCode', () => {
expect(isValidExpressionCode('1')).toBeTruthy();
expect(isValidExpressionCode('a = 1')).toBeTruthy();
expect(isValidExpressionCode('1 + 1')).toBeTruthy();
@@ -22,8 +31,11 @@ describe('ast helpers', () => {
expect(isValidExpressionCode('{ bizId: "vip", type: "category" }')).toBeTruthy();
expect(isValidExpressionCode('[1,2,3]')).toBeTruthy();
expect(isValidExpressionCode('<div>hello</div>')).toBeTruthy();
expect(isValidExpressionCode('<div>hello</div>')).toBeTruthy();
expect(isValidExpressionCode('tango.stores.app.title')).toBeTruthy();
expect(isValidExpressionCode('"hello" + window.location.path')).toBeTruthy();
expect(isValidExpressionCode('() => { }')).toBeTruthy();
expect(isValidExpressionCode('')).toBeFalsy();
expect(isValidExpressionCode('{1}')).toBeFalsy();
expect(isValidExpressionCode('{"1"}')).toBeFalsy();
expect(isValidExpressionCode('{ 1+1 }')).toBeFalsy();
@@ -54,8 +66,7 @@ describe('ast helpers', () => {
it('code2expression', () => {
expect(code2expression('')).toBeUndefined();
expect(code2expression('{tango.stores.app}')).toBeUndefined();
expect(code2expression('tango.stores.app').type).toBe('MemberExpression');
expect(code2expression('{ type: window.bar }').type).toEqual('ObjectExpression');
expect(code2expression('() => {};').type).toEqual('ArrowFunctionExpression');
expect(code2expression('<Button>hello</Button>').type).toBe('JSXElement');
@@ -69,4 +80,16 @@ describe('ast helpers', () => {
`;
expect(code2expression(arrayCode).type).toEqual('ArrayExpression');
});
it('value2jsxAttributeValueNode', () => {
expect(value2jsxAttributeValueNode('hello').type).toBe('StringLiteral');
expect(value2jsxAttributeValueNode(true).type).toBe('JSXExpressionContainer');
expect(value2jsxAttributeValueNode(1).type).toBe('JSXExpressionContainer');
expect(value2jsxAttributeValueNode('{{{ foo: "foo"}}}').type).toBe('JSXExpressionContainer');
// invalid code will return undefined
const node: any = value2jsxAttributeValueNode('{{tango.xx+}}');
expect(node.type).toBe('JSXExpressionContainer');
expect((node.expression as Identifier).name).toBe('undefined');
});
});

View File

@@ -17,6 +17,7 @@ import {
getJSXElementAttributes,
inferFileType,
deepCloneNode,
code2value,
} from '../src/helpers';
import { FileType } from '../src/types';
@@ -27,15 +28,18 @@ describe('helpers', () => {
it('parse jsxElement attributes', () => {
const node = code2expression(
"<XColumn dataIndex='col' enumMap={{ 1: '已解决', 2: '未解决' }} />",
"<Foo id={tango.user.id} num={1} str='col' enumMap={{ 1: '已解决', 2: '未解决' }} list={[{ key: 1 }, { key: 2 }]} />",
);
const attributes = getJSXElementAttributes(node as JSXElement);
expect(attributes).toEqual({
dataIndex: 'col',
id: '{{tango.user.id}}',
num: 1,
str: 'col',
enumMap: {
1: '已解决',
2: '未解决',
},
list: '{{[{ key: 1 }, { key: 2 }]}}',
});
});
@@ -80,15 +84,22 @@ describe('helpers', () => {
it('expressionCode2ast', () => {
expect(expressionCode2ast('<Button>hello</Button>').type).toEqual('File');
expect(expressionCode2ast('{<Button>hello</Button>}').type).toEqual('File');
expect(expressionCode2ast('() => <Button>hello</Button>').type).toEqual('File');
expect(expressionCode2ast('{() => <Button>hello</Button>}').type).toEqual('File');
});
});
describe('string helpers', () => {
it('value2code: empty array', () => {
expect(value2code([])).toEqual('[]');
expect(value2code({})).toEqual('{}');
expect(value2code(() => {})).toEqual('() => {}');
expect(value2code(true)).toEqual('true');
expect(value2code(false)).toEqual('false');
expect(value2code(1)).toEqual('1');
expect(value2code('hello')).toEqual('"hello"');
expect(value2code('{{window.tango}}')).toEqual('window.tango');
expect(value2code('{{() => {}}}')).toEqual('() => {}');
expect(value2code('{{1111}}')).toEqual('1111');
});
it('value2code: array', () => {
@@ -161,6 +172,10 @@ describe('string helpers', () => {
it('isValidComponentName', () => {
expect(isValidComponentName('Button')).toBeTruthy();
expect(isValidComponentName('Button.Group')).toBeTruthy();
expect(isValidComponentName('div')).toBeTruthy();
expect(isValidComponentName('')).toBeFalsy();
expect(isValidComponentName(null)).toBeFalsy();
expect(isValidComponentName(undefined)).toBeFalsy();
});
it('inferFileType', () => {
@@ -197,10 +212,26 @@ describe('schema helpers', () => {
},
],
};
const cloned = deepCloneNode(schema);
expect(cloned.props.id).toBe(schema.props.id);
expect(cloned.children[0].props.id).toBe(schema.children[0].props.id);
expect(cloned.id).not.toBe(schema.id);
expect(cloned.children[0].id).not.toBe(schema.children[0].id);
it('deepCloneNode', () => {
const cloned = deepCloneNode(schema);
expect(cloned.props.id).toBe(schema.props.id);
expect(cloned.children[0].props.id).toBe(schema.children[0].props.id);
expect(cloned.id).not.toBe(schema.id);
expect(cloned.children[0].id).not.toBe(schema.children[0].id);
});
});
describe('code helper', () => {
it('code2value', () => {
expect(code2value(`1`)).toEqual(1);
expect(code2value(`false`)).toEqual(false);
expect(code2value(`"foo"`)).toEqual('foo');
expect(code2value(`{ foo: "foo" }`)).toEqual({ foo: 'foo' });
expect(code2value(`[{ foo: "foo" }]`)).toEqual([{ foo: 'foo' }]);
expect(code2value(`{ foo: "foo", ...{ bar: "bar"} }`)).toBe(undefined);
expect(code2value(`() => {}`)).toBe(undefined);
expect(code2value(`tango.stores.app.name`)).toBe(undefined);
expect(code2value(`window`)).toBe(undefined);
expect(code2value(`<div>hello</div>`)).toBe(undefined);
});
});

View File

@@ -0,0 +1,45 @@
import { propDataToKeyValueString } from '../src/helpers';
describe('prototype helpers', () => {
it('propDataToKeyValueString', () => {
// basic
expect(propDataToKeyValueString({ name: 'foo', initValue: 'bar' })).toEqual('foo="bar"');
expect(propDataToKeyValueString({ name: 'foo', initValue: 1 })).toEqual('foo={1}');
expect(propDataToKeyValueString({ name: 'foo', initValue: false })).toEqual('foo={false}');
expect(propDataToKeyValueString({ name: 'foo', initValue: null })).toEqual('foo={null}');
expect(propDataToKeyValueString({ name: 'foo', initValue: [] })).toEqual('foo={[]}');
expect(propDataToKeyValueString({ name: 'foo', initValue: {} })).toEqual('foo={{}}');
expect(propDataToKeyValueString({ name: 'foo', initValue: () => {} })).toEqual(
'foo={() => {}}',
);
expect(propDataToKeyValueString({ name: 'foo', initValue: { foo: 'bar' } })).toEqual(
'foo={{ foo: "bar" }}',
);
expect(propDataToKeyValueString({ name: 'foo', initValue: [{ foo: 'bar' }] })).toEqual(
'foo={[{ foo: "bar" }]}',
);
// wrapped code
expect(
propDataToKeyValueString({ name: 'foo', initValue: '{{<Placeholder text="放置替换" />}}' }),
).toEqual('foo={<Placeholder text="放置替换" />}');
expect(propDataToKeyValueString({ name: 'foo', initValue: '{{tango}}' })).toEqual(
'foo={tango}',
);
expect(propDataToKeyValueString({ name: 'foo', initValue: '{{"bar"}}' })).toEqual(
'foo={"bar"}',
);
expect(propDataToKeyValueString({ name: 'foo', initValue: '{{() => {}}}' })).toEqual(
'foo={() => {}}',
);
// compatible with old version
expect(propDataToKeyValueString({ name: 'foo', initValue: '{() => {}}' })).toEqual(
'foo={() => {}}',
);
expect(
propDataToKeyValueString({ name: 'foo', initValue: '{<Placeholder text="放置替换" />}' }),
).toEqual('foo={<Placeholder text="放置替换" />}');
// expect(propDataToKeyValueString({ name: 'foo', initValue: '{tango}' })).toEqual('foo={tango}');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,82 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.2.0](https://github.com/netease/tango/compare/@music163/tango-designer@1.1.0...@music163/tango-designer@1.2.0) (2024-05-21)
### Bug Fixes
- enhance code value validate in SettingForm ([#152](https://github.com/netease/tango/issues/152)) ([791fbb1](https://github.com/netease/tango/commit/791fbb162a7147243924e01f54e9c0b586f14438))
- prototype2code & go back history error ([#156](https://github.com/netease/tango/issues/156)) ([8bf53a7](https://github.com/netease/tango/commit/8bf53a76f8a71eaf261ea68b9ee44e5bf19893aa))
- support add components with popover ([#155](https://github.com/netease/tango/issues/155)) ([f17ccbb](https://github.com/netease/tango/commit/f17ccbb7f645f8047ecd96d9f3f2185048a3b726))
### Features
- add history forward & back shortcut key ([#154](https://github.com/netease/tango/issues/154)) ([df3021f](https://github.com/netease/tango/commit/df3021f75e057e229756b429321c14d181223698))
- add select parent node of selected node ([#158](https://github.com/netease/tango/issues/158)) ([fe31246](https://github.com/netease/tango/commit/fe3124648325e72abfc58da8b2f8ff83301d40b8))
- event-setter & expression-setter use popover ([#159](https://github.com/netease/tango/issues/159)) ([9daf387](https://github.com/netease/tango/commit/9daf3872e743fcf706184877020bcbf1f75ffd25))
# [1.1.0](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.3...@music163/tango-designer@1.1.0) (2024-05-17)
### Bug Fixes
- support quick set tid in SettingForm ([#151](https://github.com/netease/tango/issues/151)) ([1fc22a4](https://github.com/netease/tango/commit/1fc22a4535a8bdc12a018a41ebd5ab908fc46817))
### Features
- refactor parse attribute value ([#149](https://github.com/netease/tango/issues/149)) ([ffaa276](https://github.com/netease/tango/commit/ffaa276b5c205ed962d37e2fdb358a703f8fad01))
## [1.0.2](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.1...@music163/tango-designer@1.0.2) (2024-04-22)
### Bug Fixes
- allow reload state tree & display pageStore in variable tree ([#135](https://github.com/netease/tango/issues/135)) ([2664613](https://github.com/netease/tango/commit/2664613ab263aead2a5239fa012454fc7fd5ff99))
## [1.0.1](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0...@music163/tango-designer@1.0.1) (2024-04-16)
### Bug Fixes
- render-setter expression value bug ([#133](https://github.com/netease/tango/issues/133)) ([27166ec](https://github.com/netease/tango/commit/27166ec94976979cb59130788399d90977cf0aec))
- variable tree remove button event handling ([#134](https://github.com/netease/tango/issues/134)) ([52864b5](https://github.com/netease/tango/commit/52864b540095025232ae237134d6be5701d20549))
# [1.0.0-alpha.18](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.17...@music163/tango-designer@1.0.0-alpha.18) (2024-04-09)
### Bug Fixes
- render invalid icon in inserted dropdown ([#128](https://github.com/netease/tango/issues/128)) ([41a61cc](https://github.com/netease/tango/commit/41a61cce4e6a1f9d42c7a3f4e357c5acdcd6db5f))
- update setters and use tabOptions to filter props ([#129](https://github.com/netease/tango/issues/129)) ([93608d1](https://github.com/netease/tango/commit/93608d1037327afa4f755976b86427b6128ae3d0))
- update SettingForm style ([#125](https://github.com/netease/tango/issues/125)) ([3f42516](https://github.com/netease/tango/commit/3f42516816968888554f719332d3cef7d8576aa8))
# [1.0.0-alpha.17](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.16...@music163/tango-designer@1.0.0-alpha.17) (2024-03-26)
### Features
- quick add sibling nodes ([#127](https://github.com/netease/tango/issues/127)) ([9ed6a7d](https://github.com/netease/tango/commit/9ed6a7d1a4944d69d96e034f243b61531862e317))
# [1.0.0-alpha.16](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.15...@music163/tango-designer@1.0.0-alpha.16) (2024-03-21)
### Bug Fixes
- copy node path in variable tree ([6215ed9](https://github.com/netease/tango/commit/6215ed96f241adf6b67aa3019745bf7d40751787))
# [1.0.0-alpha.15](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.14...@music163/tango-designer@1.0.0-alpha.15) (2024-03-20)
### Bug Fixes
- quick insert child ([d640429](https://github.com/netease/tango/commit/d64042922bc21723025b1a59586ef23f37f17ffc))
- update selection helper style ([bceb7a5](https://github.com/netease/tango/commit/bceb7a5c9cea9dc2acb091bf7967e5d3b7ba5210))
# [1.0.0-alpha.14](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.13...@music163/tango-designer@1.0.0-alpha.14) (2024-03-19)
### Bug Fixes
- select outline node & render dependency list ([#118](https://github.com/netease/tango/issues/118)) ([07febb3](https://github.com/netease/tango/commit/07febb385f710f9a0bc43639ce22787bb05e97ed))
# [1.0.0-alpha.13](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.12...@music163/tango-designer@1.0.0-alpha.13) (2024-03-18)
### Bug Fixes
- add deprecated and data group to ComponentPropType ([#116](https://github.com/netease/tango/issues/116)) ([9f7441c](https://github.com/netease/tango/commit/9f7441c13400dba55c58b7dd6ce9429013edbc45))
# [1.0.0-alpha.12](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.11...@music163/tango-designer@1.0.0-alpha.12) (2024-03-18)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-designer",
"version": "1.0.0-alpha.12",
"version": "1.2.0",
"description": "lowcode designer",
"keywords": [
"react"
@@ -32,25 +32,25 @@
},
"dependencies": {
"@ant-design/icons": "^4.8.0",
"@music163/request": "^0.1.2",
"@music163/tango-context": "^1.0.0-alpha.7",
"@music163/tango-core": "^1.0.0-alpha.7",
"@music163/tango-helpers": "^1.0.0-alpha.3",
"@music163/tango-sandbox": "^1.0.0-alpha.7",
"@music163/tango-setting-form": "^1.0.0-alpha.10",
"@music163/tango-ui": "^1.0.0-alpha.8",
"@music163/request": "^0.2.0",
"@music163/tango-context": "^1.1.1",
"@music163/tango-core": "^1.2.0",
"@music163/tango-helpers": "^1.1.1",
"@music163/tango-sandbox": "^1.0.4",
"@music163/tango-setting-form": "^1.2.1",
"@music163/tango-ui": "^1.2.0",
"antd": "^4.24.2",
"cash-dom": "^8.1.2",
"classnames": "^2.3.2",
"classnames": "^2.5.1",
"color": "^4.2.3",
"coral-system": "^1.0.5",
"cssjson": "^2.1.3",
"date-fns": "^2.29.2",
"lodash-es": "^4.17.21",
"moment": "^2.29.4",
"moment": "^2.30.1",
"react-color": "^2.19.3",
"react-resizable": "^3.0.5",
"semver": "^7.3.8"
"semver": "^7.6.2"
},
"devDependencies": {
"@types/color": "^3.0.5",

View File

@@ -0,0 +1,148 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Box } from 'coral-system';
import { observer, useDesigner, useWorkspace } from '@music163/tango-context';
import { IconFont, DragPanel } from '@music163/tango-ui';
import { ComponentsPanel, ComponentsPanelProps } from '../sidebar';
import { IComponentPrototype } from '@music163/tango-helpers';
interface ComponentsPopoverProps {
// 添加组件位置
type?: 'inner' | 'before' | 'after';
// 弹出方式 手动触发/DOM 触发
isControlled?: boolean;
title?: string;
prototype?: IComponentPrototype;
children?: React.ReactNode;
}
export const ComponentsPopover = observer(
({
type = 'inner',
title = '添加组件',
isControlled = false,
children,
...popoverProps
}: ComponentsPopoverProps) => {
const [layout, setLayout] = useState<ComponentsPanelProps['layout']>('grid');
const workspace = useWorkspace();
const designer = useDesigner();
const { addComponentPopoverPosition, showAddComponentPopover } = designer;
const selectedNode = workspace.selectSource.selected?.[0];
const selectedNodeId = selectedNode?.codeId ?? '未选中';
const prototype =
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
workspace.componentPrototypes.get(selectedNode?.name) ?? ({} as IComponentPrototype);
// 推荐使用的子组件
const insertedList = useMemo(
() =>
Array.isArray(prototype?.childrenName)
? prototype?.childrenName
: [prototype?.childrenName].filter(Boolean),
[prototype?.childrenName],
);
// 推荐使用的代码片段
const siblingList = useMemo(() => prototype?.siblingNames ?? [], [prototype.siblingNames]);
const tipsTextMap = useMemo(
() => ({
before: `点击,在 ${selectedNodeId} 的前方添加节点`,
after: `点击,在 ${selectedNodeId} 的后方添加节点`,
inner: `点击,在 ${selectedNodeId} 内部添加节点`,
}),
[selectedNodeId],
);
const handleSelect = useCallback(
(name: string) => {
switch (type) {
case 'before':
workspace.insertBeforeSelectedNode(name);
break;
case 'after':
workspace.insertAfterSelectedNode(name);
break;
case 'inner':
workspace.insertToSelectedNode(name);
break;
default:
break;
}
},
[type, workspace],
);
const changeLayout = useCallback(() => {
setLayout(layout === 'grid' ? 'line' : 'grid');
}, [layout]);
const menuData = useMemo(() => {
const menuList = JSON.parse(JSON.stringify(designer.menuData));
const commonList = menuList['common'] ?? [];
if (commonList?.length && siblingList?.length) {
commonList.unshift({
title: '代码片段',
items: siblingList,
});
}
if (commonList?.length && insertedList?.length) {
commonList.unshift({
title: '推荐使用',
items: insertedList,
});
}
return menuList;
}, [insertedList, siblingList, designer.menuData]);
const innerTypeProps =
// 手动触发 适用于 点击添加组件
type === 'inner' && isControlled
? {
open: showAddComponentPopover,
onOpenChange: (open: boolean) => designer.toggleAddComponentPopover(open),
left: addComponentPopoverPosition.clientX,
top: addComponentPopoverPosition.clientY,
}
: {};
return (
<DragPanel
{...innerTypeProps}
title={title}
extra={
<Box fontSize="12px">
{layout === 'grid' ? (
<IconFont type="icon-liebiaoitem" onClick={changeLayout} />
) : (
<IconFont type="icon-grid1" onClick={changeLayout} />
)}
</Box>
}
footer={tipsTextMap[type]}
width="330px"
maskClosable
body={
<ComponentsPanel
isScope
showBizComps={false}
menuData={menuData}
layout={layout}
onItemSelect={handleSelect}
style={{
maxHeight: '400px',
overflow: 'auto',
}}
/>
}
{...popoverProps}
>
{children}
</DragPanel>
);
},
);

View File

@@ -2,3 +2,4 @@ export * from './drag-box';
export * from './input-kv';
export * from './variable-tree';
export * from './variable-tree-modal';
export * from './components-popover';

View File

@@ -184,6 +184,12 @@ export function VariableTree(props: VariableTreeProps) {
if (isLeaf) {
const showView = node.showViewButton ?? showViewButton;
let nodePath = node.key;
if (/^(stores|services)\./.test(nodePath)) {
nodePath = `tango.${node.key.replaceAll('.', '?.').replace('?.', '.')}`;
}
return (
<Box display="flex" justifyContent="space-between" alignItems="center">
<Text flex="1" truncated>
@@ -198,7 +204,8 @@ export function VariableTree(props: VariableTreeProps) {
{node.showRemoveButton && (
<Popconfirm
title={`确认删除吗 ${node.title}?该操作会导致引用此模型的代码报错,请谨慎操作!`}
onConfirm={() => {
onConfirm={(e) => {
e.stopPropagation();
if (isStoreVariablePath(node.key)) {
onRemoveStoreVariable(node.key);
} else {
@@ -216,7 +223,7 @@ export function VariableTree(props: VariableTreeProps) {
/>
</Popconfirm>
)}
<CopyClipboard text={`tango.${node.key.replaceAll('.', '?.')}`}>
<CopyClipboard text={nodePath}>
{({ copied, onClick }) => {
const label = copied ? '已复制' : '复制变量路径';
return (

View File

@@ -3,7 +3,8 @@ import { Box } from 'coral-system';
import { Button, Empty } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons';
import { InputCode, Panel, JsonView } from '@music163/tango-ui';
import { isNil, logger, code2object, getValue } from '@music163/tango-helpers';
import { isNil, logger, getValue } from '@music163/tango-helpers';
import { code2value } from '@music163/tango-core';
export interface ServicePreviewProps {
appContext?: any;
@@ -22,7 +23,7 @@ export function ServicePreview({ appContext, functionKey }: ServicePreviewProps)
editable
showLineNumbers
onChange={(value: string) => {
const obj = code2object(value);
const obj = code2value(value); // 转为 object 对象
setPayload(obj);
}}
/>

View File

@@ -58,6 +58,15 @@ export function useDnd({
'command+v,ctrl+v': () => {
workspace.pasteSelectedNode();
},
'command+arrowup,ctrl+arrowup': () => {
workspace.selectSource.selectParent();
},
'command+z,ctrl+z': () => {
workspace.history.back();
},
'command+shift+z,ctrl+shift+z': () => {
workspace.history.forward();
},
});
}, [workspace]);
@@ -84,10 +93,10 @@ export function useDnd({
const onMouseMove = (e: React.MouseEvent) => {
const point = sandboxQuery.getRelativePoint({ x: e.clientX, y: e.clientY });
setElementStyle('.SelectionMask', {
width: Math.abs(selectSource.start?.point.x - point.x) + 'px',
height: Math.abs(selectSource.start?.point.y - point.y) + 'px',
left: Math.min(selectSource.start?.point.x, point.x) + 'px',
top: Math.min(selectSource.start?.point.y, point.y) + 'px',
width: `${Math.abs(selectSource.start?.point.x - point.x)}px`,
height: `${Math.abs(selectSource.start?.point.y - point.y)}px`,
left: `${Math.min(selectSource.start?.point.x, point.x)}px`,
top: `${Math.min(selectSource.start?.point.y, point.y)}px`,
});
};
@@ -406,6 +415,13 @@ export function useDnd({
// 打开智能向导弹窗
designer.toggleSmartWizard(true);
break;
case 'addComponent':
// 打开添加组件面板
designer.toggleAddComponentPopover(true, {
clientX: (e.detail.meta as any).clientX + 40,
clientY: (e.detail.meta as any).clientY + 110,
});
break;
default:
break;
}

View File

@@ -1,7 +1,7 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { Box } from 'coral-system';
import { MultiEditor, MultiEditorProps } from '@music163/tango-ui';
import { observer, useWorkspace } from '@music163/tango-context';
import { observer, useDesigner, useWorkspace } from '@music163/tango-context';
import { isValidCode } from '@music163/tango-core';
import { Modal } from 'antd';
@@ -32,6 +32,7 @@ export const CodeEditor = observer(
({ autoRemoveUnusedImports = true, ...rest }: CodeEditorProps) => {
const editorRef = useRef(null);
const workspace = useWorkspace();
const designer = useDesigner();
const files = workspace.listFiles();
const activeFile = workspace.activeFile;
@@ -101,8 +102,15 @@ export const CodeEditor = observer(
[workspace],
);
const borderStyle =
designer.activeView === 'dual'
? {
borderLeft: 'solid 1px var(--tango-colors-line2)',
}
: {};
return (
<Box display="flex" flexDirection="row" height="100%" bg="white">
<Box display="flex" flexDirection="row" height="100%" bg="white" {...borderStyle}>
<MultiEditor
ref={editorRef}
options={{

View File

@@ -75,7 +75,7 @@ export function getElementData(
const display = getElementCSSDisplay(element);
return {
id: dnd.id,
codeId: dnd.index,
codeId: dnd.codeId,
name: dnd.component || element.tagName.toLowerCase(),
filename: dnd.filename,
bounding,

View File

@@ -1,3 +1,4 @@
export * from './copy-node';
export * from './delete-node';
export * from './select-parent-node';
export * from './view-source';

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { useWorkspace, observer } from '@music163/tango-context';
import { IconFont, SelectAction } from '@music163/tango-ui';
export const SelectParentNodeAction = observer(() => {
const workspace = useWorkspace();
return (
<SelectAction
tooltip="选中父节点"
onClick={() => {
workspace.selectSource.selectParent();
}}
>
<IconFont type="icon-huiche1" rotate={90} />
</SelectAction>
);
});

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { observer, useDesigner } from '@music163/tango-context';
import { SelectAction } from '@music163/tango-ui';
import { AimOutlined } from '@ant-design/icons';
import { SelectAction, CodeOutlined } from '@music163/tango-ui';
export const ViewSourceAction = observer(() => {
const designer = useDesigner();
@@ -12,7 +11,7 @@ export const ViewSourceAction = observer(() => {
designer.setActiveView('code');
}}
>
<AimOutlined />
<CodeOutlined />
</SelectAction>
);
});

View File

@@ -1,10 +1,10 @@
import React from 'react';
import React, { useCallback } from 'react';
import { Radio, RadioProps, Tooltip } from 'antd';
import type { OptionType } from '@music163/tango-helpers';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { IconFont } from '@music163/tango-ui';
interface ChoiceSetterProps {
mode?: 'text' | 'icon';
options?: OptionType[];
}
@@ -13,6 +13,18 @@ export function ChoiceSetter({
onChange,
...props
}: FormItemComponentProps<string> & ChoiceSetterProps) {
const renderIcon = useCallback((icon: string) => {
if (!icon) {
return null;
}
if (icon.startsWith('icon-')) {
return <IconFont type={icon} />;
}
return null;
}, []);
return (
<Radio.Group
optionType="button"
@@ -24,6 +36,7 @@ export function ChoiceSetter({
>
{options.map((item) => (
<RadioButton tip={item.tip} key={item.value} value={item.value}>
{renderIcon(item.icon)}
{item.label}
</RadioButton>
))}

View File

@@ -2,9 +2,8 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Box } from 'coral-system';
// @ts-ignore
import { toJSON } from 'cssjson';
import { InputNumber, Space } from 'antd';
import { InputNumber, Slider, Space } from 'antd';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { SliderSetter } from './number-setter';
import {
BgSetter,
BorderSetter,
@@ -16,10 +15,11 @@ import {
FlexJustifyContentSetter,
SpacingSetter,
} from './style-setter';
import { wrapCode } from '@music163/tango-helpers';
const getRawCssValue = (value: string) => {
if (value && value.startsWith('{css`')) {
return value.split('').slice(5, -2).join('').trim();
if (value && value.startsWith('{{css`')) {
return value.split('').slice(6, -3).join('').trim();
}
return value;
};
@@ -51,7 +51,8 @@ const cssPattern = (string: string, name: string): any => {
};
/**
* 废弃
* coral-system css prop
* @example 提供 css-in-js 代码支持,例如 css`background: red;`
* @deprecated 使用嵌套属性代替
*/
export function CssSetter(props: FormItemComponentProps<string>) {
@@ -82,16 +83,14 @@ export function CssSetter(props: FormItemComponentProps<string>) {
useEffect(() => {
if (contentValue || contentValue === '') {
onChange(`{css\`${contentValue}\`}`, {
// relatedImports: ['']
});
onChange(wrapCode(`css\`${contentValue}\``));
}
}, [contentValue]);
const isFlex = ['flex', 'inline-flex'].includes(display);
return (
<Box>
<Box bg="fill1">
<ItemGroup title="布局方式">
<DisplaySetter
value={toJSON(contentValue).attributes?.display}
@@ -204,12 +203,12 @@ export function CssSetter(props: FormItemComponentProps<string>) {
/>
</ItemGroup>
<ItemGroup title="透明度">
<SliderSetter
<Slider
max={1}
min={0}
step={0.1}
value={toJSON(contentValue).attributes?.opacity || 1}
onChange={(v) => {
onChange={(v: number) => {
changeStyle(v, 'opacity');
}}
/>

View File

@@ -25,13 +25,18 @@ const style = {
width: '100%',
};
export function DateSetter({ value, onChange, format = 'YYYY-MM-DD', ...rest }: FormItemComponentProps<string>) {
export function DateSetter({
value,
onChange,
format = 'YYYY-MM-DD',
...rest
}: FormItemComponentProps<string>) {
return (
<DatePicker
{...rest}
format={format}
style={style}
value={toMoment(value, format)}
value={value ? toMoment(value, format) : undefined}
onChange={(val, str) => {
onChange && onChange(str);
}}
@@ -46,7 +51,12 @@ function toMoments(value: string[], format: string): moment.Moment[] {
return [];
}
export function DateRangeSetter({ value, onChange, format = 'YYYY-MM-DD', ...rest }: FormItemComponentProps<string[]>) {
export function DateRangeSetter({
value,
onChange,
format = 'YYYY-MM-DD',
...rest
}: FormItemComponentProps<string[]>) {
return (
<DatePicker.RangePicker
{...rest}
@@ -60,7 +70,12 @@ export function DateRangeSetter({ value, onChange, format = 'YYYY-MM-DD', ...res
);
}
export function TimeSetter({ value, onChange, format = 'HH:mm:ss', ...rest }: FormItemComponentProps<string>) {
export function TimeSetter({
value,
onChange,
format = 'HH:mm:ss',
...rest
}: FormItemComponentProps<string>) {
return (
<TimePicker
{...rest}
@@ -74,7 +89,12 @@ export function TimeSetter({ value, onChange, format = 'HH:mm:ss', ...rest }: Fo
);
}
export function TimeRangeSetter({ value, onChange, format = 'HH:mm:ss', ...rest }: FormItemComponentProps<string[]>) {
export function TimeRangeSetter({
value,
onChange,
format = 'HH:mm:ss',
...rest
}: FormItemComponentProps<string[]>) {
return (
<TimePicker.RangePicker
{...rest}

View File

@@ -1,13 +1,16 @@
import React, { useCallback, useState } from 'react';
import { css, Box } from 'coral-system';
import React, { useCallback, useMemo, useState } from 'react';
import { css, Box, Text } from 'coral-system';
import { AutoComplete } from 'antd';
import { ActionSelect } from '@music163/tango-ui';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { useWorkspace, useWorkspaceData } from '@music163/tango-context';
import { ExpressionModal, getWrappedExpressionCode } from './expression-setter';
import { wrapCode } from '@music163/tango-helpers';
import { ExpressionPopover } from './expression-setter';
import { value2code } from '@music163/tango-core';
enum EventAction {
NoAction = 'noAction',
ConsoleLog = 'consoleLog',
BindExpression = 'bindExpression',
OpenModal = 'openModal',
CloseModal = 'closeModal',
@@ -22,14 +25,6 @@ const wrapperStyle = css`
}
`;
const options = [
{ label: '无动作', value: EventAction.NoAction },
{ label: '绑定 JS 表达式', value: EventAction.BindExpression },
{ label: '打开页面', value: EventAction.NavigateTo },
{ label: '打开弹窗', value: EventAction.OpenModal },
{ label: '关闭弹窗', value: EventAction.CloseModal },
];
export type EventSetterProps = FormItemComponentProps<string>;
/**
@@ -39,20 +34,47 @@ export function EventSetter(props: EventSetterProps) {
const { value, onChange, modalTitle } = props;
const [type, setType] = useState<EventAction>(); // 事件类型
const [temp, setTemp] = useState(''); // 二级暂存值
const [expModalVisible, setExpModalVisible] = useState(false); // 弹窗是否显示
const { actionVariables, routeOptions } = useWorkspaceData();
const workspace = useWorkspace();
const modalOptions = workspace.activeViewModule.listModals() || [];
const code = value2code(value);
const handleChange = useCallback<FormItemComponentProps['onChange']>(
(nextValue: any, ...args) => {
const ret = getWrappedExpressionCode(nextValue);
if (ret !== value) {
onChange(ret, ...args);
if (!nextValue) {
onChange(undefined);
}
if (nextValue !== code) {
onChange(wrapCode(nextValue), ...args);
}
},
[onChange, value],
[onChange, code],
);
const options = useMemo(
() => [
{ label: '无动作', value: EventAction.NoAction },
{ label: '打印事件', value: EventAction.ConsoleLog },
{
label: (
<ExpressionPopover
title={modalTitle}
value={value}
onOk={(nextValue) => {
handleChange(nextValue);
}}
dataSource={actionVariables}
>
<Text> JS </Text>
</ExpressionPopover>
),
value: EventAction.BindExpression,
},
{ label: '打开页面', value: EventAction.NavigateTo },
{ label: '打开弹窗', value: EventAction.OpenModal },
{ label: '关闭弹窗', value: EventAction.CloseModal },
],
[modalTitle, value, actionVariables, handleChange],
);
const onAction = (key: string) => {
@@ -60,8 +82,8 @@ export function EventSetter(props: EventSetterProps) {
setTemp(''); // 重置二级选项值
switch (key) {
case EventAction.BindExpression:
setExpModalVisible(true);
case EventAction.ConsoleLog:
handleChange('(...args) => console.log(...args)');
break;
case EventAction.NoAction:
handleChange(undefined);
@@ -71,23 +93,11 @@ export function EventSetter(props: EventSetterProps) {
}
};
const actionText = getActionText(type, temp, value);
const inputValue = value;
const actionText = getActionText(type, temp, code);
return (
<Box css={wrapperStyle}>
<ActionSelect options={options} onSelect={onAction} text={actionText} />
<ExpressionModal
title={modalTitle}
value={inputValue}
visible={expModalVisible}
onCancel={() => setExpModalVisible(false)}
onOk={(nextValue) => {
setExpModalVisible(false);
handleChange(nextValue);
}}
dataSource={actionVariables}
/>
{type === EventAction.NavigateTo && (
<AutoComplete
placeholder="选择或输入页面路由"
@@ -137,12 +147,12 @@ const handlerMap = {
[EventAction.NavigateTo]: 'navigateTo',
};
function getActionText(type: EventAction, temp: string, value: any) {
function getActionText(type: EventAction, temp: string, fallbackCode: string) {
let text;
if (handlerMap[type]) {
text = getExpressionValue(type, temp);
} else if (value) {
text = value;
} else if (fallbackCode) {
text = fallbackCode;
}
text = text || '请选择';
return text;
@@ -151,6 +161,6 @@ function getActionText(type: EventAction, temp: string, value: any) {
function getExpressionValue(type: EventAction, value = '') {
const handler = handlerMap[type];
if (handler) {
return `{() => tango.${handler}("${value}")}`;
return `() => tango.${handler}("${value}")`;
}
}

View File

@@ -1,26 +0,0 @@
import React from 'react';
import { SingleMonacoEditor } from '@music163/tango-ui';
import { Box } from 'coral-system';
import { FormItemComponentProps } from '@music163/tango-setting-form/src/form-item';
export function ExpressionSetter({ value, onChange }: FormItemComponentProps) {
return (
<Box height="120px" border="solid" borderColor="line.normal" borderRadius="s">
<SingleMonacoEditor
defaultValue={value}
onBlur={(newValue) => {
if (newValue !== value) {
onChange(`{${newValue}}`);
}
}}
language="javascript"
options={{
lineNumbers: 'off',
minimap: {
enabled: false,
},
}}
/>
</Box>
);
}

View File

@@ -1,20 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, css } from 'coral-system';
import { Modal } from 'antd';
import {
isValidExpressionCode,
isWrappedByExpressionContainer,
value2expressionCode,
} from '@music163/tango-core';
import {
getVariableContent,
noop,
useBoolean,
getValue,
IVariableTreeNode,
} from '@music163/tango-helpers';
import { CloseCircleFilled, ExpandAltOutlined } from '@ant-design/icons';
import { Panel, InputCode, Action } from '@music163/tango-ui';
import { Dropdown, Button } from 'antd';
import { isValidExpressionCode } from '@music163/tango-core';
import { getValue, IVariableTreeNode, noop } from '@music163/tango-helpers';
import { CloseCircleFilled, ExpandAltOutlined, MenuOutlined } from '@ant-design/icons';
import { Panel, InputCode, Action, DragPanel } from '@music163/tango-ui';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { useWorkspace, useWorkspaceData } from '@music163/tango-context';
import { VariableTree } from '../components';
@@ -23,43 +13,22 @@ import { CODE_TEMPLATES } from '../helpers';
import { shapeServiceValues } from '../sidebar/datasource-panel/interface-config';
export const expressionValueValidate = (value: string) => {
if (isWrappedByExpressionContainer(value)) {
const exp = getVariableContent(value);
if (!isValidExpressionCode(exp)) {
return '表达式存在语法错误!';
}
if (!isValidExpressionCode(value)) {
return '表达式存在语法错误!';
}
};
export const jsonValueValidate = (value: string) => {
if (isWrappedByExpressionContainer(value)) {
const jsonStr = getVariableContent(value);
try {
JSON.parse(jsonStr);
} catch (e) {
return '不是合法的 JSON 语法!';
}
try {
JSON.parse(value);
} catch (e) {
return '不是合法的 JSON 语法!';
}
};
/**
* 返回 `{}` 包裹后的表达式代码
* @param code 原始代码
* @returns
*/
export function getWrappedExpressionCode(code: string) {
let ret;
if (!code) {
// do nothing
} else if (isWrappedByExpressionContainer(code)) {
ret = code;
} else {
ret = `{${code}}`;
}
return ret;
}
const suffixStyle = css`
display: flex;
align-items: center;
.anticon-close-circle {
color: rgba(0, 0, 0, 0.25);
&:hover {
@@ -73,6 +42,7 @@ export interface ExpressionSetterProps extends FormItemComponentProps<string> {
modalTip?: string;
autoCompleteOptions?: string[];
allowClear?: boolean;
showOptionsDropDown?: boolean;
}
export function ExpressionSetter(props: ExpressionSetterProps) {
@@ -81,22 +51,19 @@ export function ExpressionSetter(props: ExpressionSetterProps) {
modalTitle,
modalTip,
autoCompleteOptions,
placeholder = '输入JS表达式代码',
placeholder = '在这里输入JS代码',
value: valueProp,
status,
allowClear = true,
newStoreTemplate,
showOptionsDropDown = true,
} = props;
const [visible, { on, off }] = useBoolean();
const [inputValue, setInputValue] = useState(() => {
return value2expressionCode(valueProp);
});
const sandbox = useSandboxQuery();
const evaluateContext = sandbox.window;
// const codeValue = getCodeOfWrappedCode(valueProp);
const [inputValue, setInputValue] = useState(valueProp);
// when receive new value, sync state
useEffect(() => {
setInputValue(value2expressionCode(valueProp));
setInputValue(valueProp);
}, [valueProp]);
const change = useCallback(
@@ -104,20 +71,17 @@ export function ExpressionSetter(props: ExpressionSetterProps) {
if (code === valueProp) {
return;
}
const ret = getWrappedExpressionCode(code);
if (ret === valueProp) {
return;
}
onChange(ret);
onChange(code);
},
[valueProp, onChange],
);
const sandbox = useSandboxQuery();
const evaluateContext = sandbox.window;
return (
<Box className="ExpressionSetter">
{/* 同时支持下拉框展示 */}
<InputCode
placeholder={placeholder}
suffix={
@@ -130,11 +94,36 @@ export function ExpressionSetter(props: ExpressionSetterProps) {
}}
/>
)}
<Action
tooltip="打开表达式变量选择面板"
icon={<ExpandAltOutlined />}
onClick={on}
size="small"
{showOptionsDropDown && autoCompleteOptions?.length && (
<Dropdown
menu={{
items: autoCompleteOptions.map((option) => ({
key: option,
label: (
<Text
onClick={() => {
change(option);
}}
>
{option}
</Text>
),
})),
}}
>
<Action tooltip="使用预设代码片段" icon={<MenuOutlined />} size="small" />
</Dropdown>
)}
<ExpressionPopover
title={modalTitle}
subTitle={modalTip}
placeholder={placeholder}
autoCompleteOptions={autoCompleteOptions}
newStoreTemplate={newStoreTemplate}
value={inputValue}
onOk={(value) => {
change(value);
}}
/>
</Box>
}
@@ -148,32 +137,16 @@ export function ExpressionSetter(props: ExpressionSetterProps) {
status={status}
maxHeight="200px"
/>
<ExpressionModal
title={modalTitle}
subTitle={modalTip}
placeholder={placeholder}
autoCompleteOptions={autoCompleteOptions}
newStoreTemplate={newStoreTemplate}
visible={visible}
value={inputValue}
onCancel={() => off()}
onOk={(value) => {
change(value);
off();
}}
/>
</Box>
);
}
export interface ExpressionModalProps {
export interface ExpressionPopoverProps {
title?: string;
subTitle?: string;
placeholder?: string;
visible?: boolean;
defaultValue?: string;
value?: string;
onCancel?: () => void;
onOk?: (value: string) => void;
dataSource?: IVariableTreeNode[];
autoCompleteOptions?: string[];
@@ -181,25 +154,27 @@ export interface ExpressionModalProps {
* 新建 store 的模板代码
*/
newStoreTemplate?: string;
children?: React.ReactNode;
}
export function ExpressionModal({
export function ExpressionPopover({
title,
subTitle,
placeholder,
visible,
onCancel = noop,
onOk = noop,
defaultValue,
value,
dataSource,
autoCompleteOptions,
newStoreTemplate = CODE_TEMPLATES.newStoreTemplate,
}: ExpressionModalProps) {
children,
}: ExpressionPopoverProps) {
const [exp, setExp] = useState(value ?? defaultValue);
const [error, setError] = useState('');
const workspace = useWorkspace();
const selectNodePath = workspace.selectSource.selected[0]?.codeId;
const { expressionVariables } = useWorkspaceData();
const serviceModules = Object.keys(workspace.serviceModules).map((key) => ({
label: key === 'index' ? '默认模块' : key,
@@ -218,109 +193,145 @@ export function ExpressionModal({
}, [value]);
return (
<Modal
closable={false}
destroyOnClose
width="60%"
open={visible}
onCancel={onCancel}
onOk={() => {
onOk(exp);
<DragPanel
width={700}
title={`${selectNodePath}/${title} 设置为引用变量或自定义表达式`}
extra={subTitle}
onOpenChange={(open) => {
if (!open) {
setExp(undefined);
setError('');
}
}}
bodyStyle={{
padding: 0,
popoverStyle={{
height: '615px',
}}
body={
<>
<Panel shape="solid">
<InputCode
shape="inset"
minHeight="56px"
maxHeight="200px"
value={exp}
placeholder={placeholder}
onChange={handleExpInputChange}
onBlur={() => {
setError(expressionValueValidate(exp));
}}
autoCompleteContext={evaluateContext}
autoCompleteOptions={autoCompleteOptions}
/>
{error ? (
<Text color="red" fontSize="12px">
</Text>
) : (
<Text fontSize="12px" color="text3">
javascript 使 jsx
</Text>
)}
</Panel>
<Panel
title="从变量列表中选中"
shape="solid"
borderTop="0"
overflow="hidden"
bodyProps={{ overflow: 'hidden' }}
>
<VariableTree
height={380}
showViewButton
dataSource={dataSource || expressionVariables}
appContext={sandbox?.window['tango']}
getStoreNames={() => Object.keys(workspace.storeModules)}
serviceModules={serviceModules}
getServiceData={(serviceKey) => {
const data = workspace.getServiceFunction(serviceKey);
return {
name: data.name,
moduleName: data.moduleName,
method: 'get',
...data.config,
};
}}
onSelect={(node) => {
if (!node.key) {
return;
}
if (node.key.split('.').length < 2) {
return;
}
let str;
if (/^(stores|services)\./.test(node.key)) {
str = `tango.${node.key.replaceAll('.', '?.')}`;
} else {
str = `${node.key}`;
}
setExp(str);
}}
onAddStoreVariable={(storeName, data) => {
workspace.addStoreState(storeName, data.name, data.initialValue);
}}
onUpdateStoreVariable={(variableKey, code) => {
workspace.updateStoreVariable(variableKey, code);
}}
onAddStore={(storeName) => {
workspace.addStoreFile(storeName, newStoreTemplate);
}}
onRemoveStoreVariable={(variableKey) => {
workspace.removeStoreVariable(variableKey);
}}
onRemoveService={(serviceKey) => {
workspace.removeServiceFunction(serviceKey);
}}
onAddService={(data) => {
const { name, moduleName, ...payload } = shapeServiceValues(data);
workspace.addServiceFunction(name, payload, moduleName);
}}
onUpdateService={(data) => {
const { name, moduleName, ...payload } = shapeServiceValues(data);
workspace.updateServiceFunction(name, payload, moduleName);
}}
getPreviewValue={(node) => {
if (!node || !node.key) {
return;
}
if (node.type === 'function') {
return;
}
return getValue(evaluateContext['tango'], node.key);
}}
/>
</Panel>
</>
}
footer={(close) => (
<Box display="flex" justifyContent="flex-end" gap="5px">
<Button
type="primary"
size="small"
onClick={() => {
if (!error) {
onOk(exp);
close();
}
}}
>
</Button>
<Button size="small" onClick={close}>
</Button>
</Box>
)}
>
<Panel title={`${title} 设置为引用变量或自定义表达式`} subTitle={subTitle} shape="solid">
<InputCode
shape="inset"
minHeight="56px"
maxHeight="200px"
value={exp}
placeholder={placeholder}
onChange={handleExpInputChange}
onBlur={() => {
setError(expressionValueValidate(exp));
}}
autoCompleteContext={evaluateContext}
autoCompleteOptions={autoCompleteOptions}
/>
{error ? <Text color="red"></Text> : null}
</Panel>
<Panel
title="从变量列表中选中"
shape="solid"
borderTop="0"
overflow="hidden"
bodyProps={{ overflow: 'hidden' }}
>
<VariableTree
height={380}
showViewButton
dataSource={dataSource || expressionVariables}
appContext={sandbox?.window['tango']}
getStoreNames={() => Object.keys(workspace.storeModules)}
serviceModules={serviceModules}
getServiceData={(serviceKey) => {
const data = workspace.getServiceFunction(serviceKey);
return {
name: data.name,
moduleName: data.moduleName,
method: 'get',
...data.config,
};
}}
onSelect={(node) => {
if (!node.key) {
return;
}
if (node.key.split('.').length < 2) {
return;
}
let str;
if (/^(stores|services)\./.test(node.key)) {
str = `{tango.${node.key.replaceAll('.', '?.')}}`;
} else {
str = `{${node.key}}`;
}
setExp(str);
}}
onAddStoreVariable={(storeName, data) => {
workspace.addStoreState(storeName, data.name, data.initialValue);
}}
onUpdateStoreVariable={(variableKey, code) => {
workspace.updateStoreVariable(variableKey, code);
}}
onAddStore={(storeName) => {
workspace.addStoreFile(storeName, newStoreTemplate);
}}
onRemoveStoreVariable={(variableKey) => {
workspace.removeStoreVariable(variableKey);
}}
onRemoveService={(serviceKey) => {
workspace.removeServiceFunction(serviceKey);
}}
onAddService={(data) => {
const { name, moduleName, ...payload } = shapeServiceValues(data);
workspace.addServiceFunction(name, payload, moduleName);
}}
onUpdateService={(data) => {
const { name, moduleName, ...payload } = shapeServiceValues(data);
workspace.updateServiceFunction(name, payload, moduleName);
}}
getPreviewValue={(node) => {
if (!node || !node.key) {
return;
}
if (node.type === 'function') {
return;
}
return getValue(evaluateContext['tango'], node.key);
}}
/>
</Panel>
</Modal>
{children || (
<Action tooltip="打开表达式变量选择面板" icon={<ExpandAltOutlined />} size="small" />
)}
</DragPanel>
);
}

View File

@@ -12,7 +12,7 @@ import { ListSetter } from './list-setter';
import { ModelSetter } from './model-setter';
import { OptionSetter } from './option-setter';
import { PickerSetter } from './picker-setter';
import { RenderSetter, TableCellSetter, TableExpandableSetter } from './render-props-setter';
import { RenderSetter, TableCellSetter, TableExpandableSetter } from './render-setter';
import { RouterSetter } from './router-setter';
import {
SpacingSetter,
@@ -25,44 +25,46 @@ import {
FlexAlignItemsSetter,
FlexDirectionSetter,
} from './style-setter';
import { BoolSetter } from './bool-setter';
import { ChoiceSetter } from './choice-setter';
import { NumberSetter, SliderSetter } from './number-setter';
import { TextAreaSetter, TextSetter } from './text-setter';
import { isValidExpressionCode } from '@music163/tango-core';
const setters: IFormItemCreateOptions[] = [
const codeValidate: IFormItemCreateOptions['validate'] = (value, field) => {
if (!value) return;
const rawCode = field.detail.rawCode;
if (!rawCode) return;
return isValidExpressionCode(rawCode) ? '' : '请输入合法的 Javascript 代码片段';
};
const jsonValidate: IFormItemCreateOptions['validate'] = (value, field) => {
if (!value) return;
try {
JSON.parse(field.detail.rawCode);
return;
} catch (e) {
return '请输入合法的 JSON 字符串';
}
};
export const BUILT_IN_SETTERS: IFormItemCreateOptions[] = [
{
name: 'boolSetter',
component: BoolSetter,
},
{
name: 'choiceSetter',
component: ChoiceSetter,
},
{
name: 'expSetter',
alias: ['expressionSetter'],
name: 'codeSetter',
alias: ['expSetter', 'expressionSetter'],
component: ExpressionSetter,
type: 'code',
validate: codeValidate,
},
{
name: 'numberSetter',
component: NumberSetter,
},
{
name: 'textSetter',
component: TextSetter,
},
{ name: 'textAreaSetter', component: TextAreaSetter },
{
name: 'sliderSetter',
component: SliderSetter,
name: 'radioGroupSetter',
alias: ['choiceSetter'],
component: ChoiceSetter,
},
{
name: 'actionListSetter',
component: ActionListSetter,
},
{
name: 'columnSetter',
name: 'tableColumnsSetter',
alias: ['columnSetter'], // 兼容
component: ColumnSetter,
},
{
@@ -94,18 +96,17 @@ const setters: IFormItemCreateOptions[] = [
alias: ['actionSetter', 'functionSetter', 'callbackSetter'],
component: EventSetter,
},
{
name: 'expressionSetter',
alias: ['expSetter'],
component: ExpressionSetter,
},
{
name: 'jsonSetter',
component: JSONSetter,
type: 'code',
validate: jsonValidate,
},
{
name: 'jsxSetter',
component: JsxSetter,
type: 'code',
validate: codeValidate,
},
{
name: 'listSetter',
@@ -120,20 +121,26 @@ const setters: IFormItemCreateOptions[] = [
component: OptionSetter,
},
{
name: 'pickerSetter',
name: 'selectSetter',
alias: ['pickerSetter'],
component: PickerSetter,
},
{
name: 'renderPropsSetter',
component: RenderSetter,
type: 'code',
validate: codeValidate,
},
{
name: 'tableCellSetter',
component: TableCellSetter,
type: 'code',
},
{
name: 'tableExpandableSetter',
component: TableExpandableSetter,
type: 'code',
validate: codeValidate,
},
{
name: 'routerSetter',
@@ -169,5 +176,5 @@ const setters: IFormItemCreateOptions[] = [
];
export function registerBuiltinSetters() {
setters.forEach(register);
BUILT_IN_SETTERS.forEach(register);
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Box } from 'coral-system';
import { SingleMonacoEditor } from '@music163/tango-ui';
import { FormItemComponentProps } from '@music163/tango-setting-form';
@@ -6,14 +6,18 @@ import { FormItemComponentProps } from '@music163/tango-setting-form';
/**
* JSON Setter
*/
export function JSONSetter({ value, onChange }: FormItemComponentProps) {
export function JSONSetter({ value: valueProp, onChange }: FormItemComponentProps) {
const [value, setValue] = useState(valueProp || '');
return (
<Box height="120px" border="solid" borderColor="line.normal" borderRadius="s">
<SingleMonacoEditor
defaultValue={value}
onChange={(newValue) => {
setValue(newValue);
}}
onBlur={(newValue) => {
if (newValue !== value) {
onChange(`{${newValue}}`);
onChange(newValue);
}
}}
language="json"

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { isPlainString } from '@music163/tango-helpers';
import { ActionSelect } from '@music163/tango-ui';
import React, { useEffect, useState } from 'react';
import { ActionSelect, InputCode } from '@music163/tango-ui';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { Box } from 'coral-system';
const options = [
{ label: '取消自定义', value: '' },
@@ -25,26 +25,27 @@ interface JsxSetterProps extends FormItemComponentProps {
}
const defaultGetTemplate = (key: string) => {
let ret;
const deps = [];
let ret: [string, string[]];
switch (key) {
case 'Box':
ret = '<Box></Box>';
ret = ['<Box></Box>', ['Box']];
break;
case 'Text':
ret = '<Text>示例文本</Text>';
ret = ['<Text>示例文本</Text>', ['Text']];
break;
case 'Placeholder':
ret = '<Placeholder text="拖拽到此进行替换" />';
ret = ['<Placeholder text="拖拽到此进行替换" />', ['Placeholder']];
break;
case 'ButtonGroup':
ret = '<ButtonGroup><Button>按钮1</Button><Button>按钮2</Button></ButtonGroup>';
deps.push('Button');
ret = [
'<ButtonGroup><Button>按钮1</Button><Button>按钮2</Button></ButtonGroup>',
['ButtonGroup', 'Button'],
];
break;
default:
break;
}
return [ret, deps];
return ret;
};
/**
@@ -52,21 +53,36 @@ const defaultGetTemplate = (key: string) => {
*/
export function JsxSetter(props: JsxSetterProps) {
const { showInput, getTemplate = defaultGetTemplate, value, onChange } = props;
const [inputValue, setInputValue] = useState(value);
useEffect(() => {
setInputValue(value);
}, [value]);
return (
<ActionSelect
showInput={showInput}
defaultInputValue={isPlainString(value) ? value : undefined}
options={options}
text="将此区域初始化为"
onInputChange={onChange}
onSelect={(key) => {
const [tpl, deps] = getTemplate(key);
if (tpl) {
onChange(`{${tpl}}`, { relatedImports: [...deps, key] });
} else {
onChange(undefined);
}
}}
/>
<Box>
<ActionSelect
showInput={showInput}
defaultInputValue={value}
options={options}
text="设置此区域为"
onInputChange={onChange}
onSelect={(key) => {
const [tpl, deps] = getTemplate(key);
if (tpl) {
onChange(tpl, { relatedImports: deps });
} else {
onChange(undefined);
}
}}
/>
{value && (
<InputCode
value={value}
onChange={(val) => setInputValue(val)}
onBlur={() => {
onChange(inputValue);
}}
/>
)}
</Box>
);
}

View File

@@ -234,6 +234,7 @@ function NewOptionForm({ fields = [], initialValues = {}, onSubmit }: NewOptionF
label={item.label}
name={item.name}
required={item.required}
rules={[{ required: item.required }]}
valuePropName={item.valuePropName}
extra={item.extra}
style={{
@@ -260,7 +261,7 @@ interface ListSetterProps extends FormItemComponentProps<any[]> {
addBtnText?: string;
getListItemKey?: (item: any) => React.Key;
renderItem?: (item: any) => React.ReactNode;
};
}
const defaultListItemFormFields: ListSetterItemProps['formFields'] = [
{ label: 'key', name: 'key', required: true },

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import { ActionSelect } from '@music163/tango-ui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ActionSelect, InputCode } from '@music163/tango-ui';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { Box } from 'coral-system';
interface IRenderOption {
label: string;
@@ -15,15 +16,26 @@ export interface RenderSetterProps {
fallbackOption?: IRenderOption;
}
const defaultOptions: IRenderOption[] = [
{ label: '取消自定义', value: '' },
{ label: '自定义渲染', value: 'Box', render: '() => <Box></Box>' },
];
/**
* Render Props Setters
*/
export function RenderSetter({
value,
onChange,
text = '自定义渲染为',
options = [],
options = defaultOptions,
fallbackOption,
}: FormItemComponentProps & RenderSetterProps) {
const [inputValue, setInputValue] = useState(value || '');
useEffect(() => {
setInputValue(value);
}, [value]);
const optionsMap = useMemo(() => {
return options.reduce((prev, cur) => {
prev[cur.value] = cur;
@@ -42,21 +54,37 @@ export function RenderSetter({
},
[optionsMap, fallbackOption, onChange],
);
return <ActionSelect text={text} options={options} onSelect={onSelect} />;
return (
<Box>
<ActionSelect text={text} options={options} onSelect={onSelect} />
{inputValue && (
<InputCode
value={inputValue}
onChange={(val) => setInputValue(val)}
onBlur={() => onChange(inputValue)}
/>
)}
</Box>
);
}
const getRender = (content: string, type?: 'tableCell' | 'tableExpandable') => {
let code;
switch (type) {
case 'tableCell':
return `{(value, record, index) => ${content}}`;
code = `(value, record, index) => ${content}`;
break;
case 'tableExpandable':
return `{{
expandedRowRender: (record) => ${content},
rowExpandable: (record) => true
}}`;
code = `{
expandedRowRender: (record) => ${content},
rowExpandable: (record) => true
}`;
break;
default:
return `{() => ${content}}`;
code = `() => ${content}`;
break;
}
return code;
};
const tableCellOptions: RenderSetterProps['options'] = [
@@ -101,6 +129,7 @@ export function TableCellSetter(props: FormItemComponentProps) {
return <RenderSetter options={tableCellOptions} {...props} />;
}
// FIXME: 应该直接用 props 嵌套的模式
export function TableExpandableSetter(props: FormItemComponentProps) {
return <RenderSetter options={tableExpandableOptions} text="配置表格可展开行" {...props} />;
}

View File

@@ -7,8 +7,6 @@ import { BgColorsOutlined, EyeInvisibleOutlined, FileImageOutlined } from '@ant-
import { SingleMonacoEditor, LineSolidOutlined, LineDashedOutlined } from '@music163/tango-ui';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { ChoiceSetter } from './choice-setter';
import { TextSetter } from './text-setter';
// import { ImageSetter } from './image-setter';
function getRawCssValue(value: string) {
if (value && value.startsWith('{css`')) {
@@ -17,12 +15,13 @@ function getRawCssValue(value: string) {
return value;
}
// TODO: style object setter
/**
* 不稳定,暂不推荐使用
*/
export function CssCodeSetter({ value, onChange }: FormItemComponentProps<string>) {
const contentValue = getRawCssValue(value);
// TODO: relatedImports
return (
<Popover
placement="leftBottom"
@@ -34,9 +33,7 @@ export function CssCodeSetter({ value, onChange }: FormItemComponentProps<string
value={contentValue}
onBlur={(newCode) => {
if (newCode != contentValue) {
onChange(`{css\`${newCode}\`}`, {
// relatedImports: ['']
});
onChange(`{css\`${newCode}\`}`, {});
}
}}
/>
@@ -239,9 +236,10 @@ export function BgSetter({ value, onChange }: FormItemComponentProps<string>) {
<Box mt="m">
{mode === 'color' && <ColorSetter value={value} onChange={onChange} />}
{mode === 'image' && (
<TextSetter
<Input
value={getImageUrl(value)}
onChange={(imgUrl) => {
onChange={(e) => {
const imgUrl = e.target.value;
onChange(imgUrl ? `url(${imgUrl})` : undefined);
}}
/>

View File

@@ -2,11 +2,15 @@ import React from 'react';
import { Box } from 'coral-system';
import { SettingForm, FormModel, SettingFormProps } from '@music163/tango-setting-form';
import { Panel } from '@music163/tango-ui';
import { clone } from '@music163/tango-helpers';
import { clone, parseDndId } from '@music163/tango-helpers';
import { observer, useDesigner, useWorkspace } from '@music163/tango-context';
import { registerBuiltinSetters } from './setters';
registerBuiltinSetters();
let registered = false;
if (!registered) {
registerBuiltinSetters();
registered = true;
}
export interface SettingPanelProps extends SettingFormProps {
title?: React.ReactNode;
@@ -91,6 +95,9 @@ export const SettingPanel = observer((props: SettingPanelProps) => {
borderLeftColor="line2"
bg="white"
headerProps={headerProps}
bodyProps={{
overflowY: 'hidden',
}}
className="SettingPanel"
>
{prototype ? (
@@ -100,6 +107,11 @@ export const SettingPanel = observer((props: SettingPanelProps) => {
prototype={prototype}
showIdentifier={{
identifierKey: 'tid',
getIdentifier: () => {
// 直接拿当前的虚拟 id 作为 tid
const { codeId } = parseDndId(workspace.selectSource.first.id);
return codeId;
},
}}
{...props}
/>

View File

@@ -1,21 +1,29 @@
import React, { useMemo, useState } from 'react';
import { Box, Grid, Text } from 'coral-system';
import styled from 'styled-components';
import styled, { css } from 'styled-components';
import {
ComponentPrototypeType,
IComponentPrototype,
MenuDataType,
MenuValueType,
createContext,
logger,
PartialRecord,
upperCamelCase,
} from '@music163/tango-helpers';
import { CollapsePanel, IconFont, Search, Tabs } from '@music163/tango-ui';
import { observer, useWorkspace } from '@music163/tango-context';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { Button, Empty, Spin, Popover } from 'antd';
import { Button, Empty, Spin, Popover, TabsProps } from 'antd';
import { getDragGhostElement } from '../helpers';
type MenuKeyType = 'common' | 'atom' | 'snippet' | 'bizComp' | 'localComp';
type MenuValueType = Array<{ title: string; items: string[] }>;
export type MenuDataType = PartialRecord<MenuKeyType, MenuValueType>;
type IComponentsPanelContext = {
isScope: boolean;
onItemSelect: (name: string) => void;
layout: 'grid' | 'line';
};
const [ComponentsPanelProvider, usePanelContext] = createContext<IComponentsPanelContext>({
name: 'ComponentsPanelContext',
});
export interface ComponentsPanelProps {
/**
@@ -36,6 +44,26 @@ export interface ComponentsPanelProps {
* @returns
*/
getBizCompName?: (name: string) => string;
/**
* 是否局部模式 (快捷添加组件面板中使用)
*/
isScope?: boolean;
/**
* 组件选中回调
*/
onItemSelect?: (name: string) => void;
/**
* 自定义样式
*/
style?: React.CSSProperties;
/**
* tabProps
*/
tabProps?: TabsProps;
/**
* 布局模式,默认网格布局
*/
layout?: 'grid' | 'line';
}
const localeMap = {
@@ -72,10 +100,15 @@ export function useFlatMenuData<T>(menuData: T) {
export const ComponentsPanel = observer(
({
isScope = false,
menuData = emptyMenuData,
showBizComps = true,
getBizCompName = upperCamelCase,
loading = false,
style,
tabProps,
onItemSelect,
layout = 'grid',
}: ComponentsPanelProps) => {
const [keyword, setKeyword] = useState<string>('');
const allList = useFlatMenuData<MenuDataType>(menuData);
@@ -109,22 +142,44 @@ export const ComponentsPanel = observer(
children: <MaterialList data={localCompData} />,
});
}
const contentNode =
tabs.length === 1 ? (
tabs[0].children
) : (
<Tabs centered isTabBarSticky tabBarStickyOffset={48} items={tabs} />
<Tabs
size={isScope ? 'small' : 'middle'}
centered
isTabBarSticky
tabBarStickyOffset={48}
items={tabs}
{...tabProps}
/>
);
return (
<Box className="ComponentsView" overflowY="auto">
<Box px="l" py="m" position="sticky" top="0" zIndex={1} bg="white">
<Search placeholder="搜索物料" onChange={setKeyword} />
<ComponentsPanelProvider
value={{
isScope,
onItemSelect,
layout,
}}
>
<Box className="ComponentsView" overflowY="auto" style={style}>
<Box px="l" py="m" position="sticky" top="0" zIndex={3} bg="white">
<Search
style={{
borderRadius: '4px',
}}
placeholder="搜索物料"
onChange={setKeyword}
/>
</Box>
<Spin spinning={loading} tip="正在加载物料列表...">
{!keyword ? contentNode : <MaterialList data={allList} filterKeyword={keyword} />}
</Spin>
</Box>
<Spin spinning={loading} tip="正在加载物料列表...">
{!keyword ? contentNode : <MaterialList data={allList} filterKeyword={keyword} />}
</Spin>
</Box>
</ComponentsPanelProvider>
);
},
);
@@ -146,6 +201,9 @@ interface MaterialListProps {
function MaterialList({ data, filterKeyword, type = 'common' }: MaterialListProps) {
const workspace = useWorkspace();
const { layout } = usePanelContext();
const isGrid = layout === 'grid';
return (
<Box className="ComponentsViewList">
{data.map((cate) => {
@@ -166,15 +224,21 @@ function MaterialList({ data, filterKeyword, type = 'common' }: MaterialListProp
title={cate.title}
borderBottom="solid"
borderColor="line.normal"
showBottomBorder={false}
bodyProps={{
padding: isGrid ? '4px 12px 12px' : '0',
}}
>
{!items.length && (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有匹配到任何组件" />
)}
<Grid
columns={type === 'localComp' ? 1 : 2}
templateColumns={isGrid ? 'repeat(auto-fit,minmax(55px,1fr))' : '1fr'}
spacing="1px"
bg="background.normal"
padding="0"
gap={isGrid ? '12px 8px' : '0'}
backgroundColor="white"
>
{items.map((item) => {
const prototype = workspace.componentPrototypes.get(item);
@@ -194,7 +258,7 @@ function MaterialList({ data, filterKeyword, type = 'common' }: MaterialListProp
}
interface MaterialProps {
data: ComponentPrototypeType;
data: IComponentPrototype;
}
const StyledCommonGridItem = styled.div`
@@ -202,9 +266,8 @@ const StyledCommonGridItem = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
cursor: move;
justify-content: start;
cursor: ${(props) => (props.draggable ? 'grab' : 'pointer')};
text-align: center;
color: var(--tango-colors-text-body);
background-color: #fff;
@@ -213,7 +276,19 @@ const StyledCommonGridItem = styled.div`
white-space: nowrap;
.material-icon {
font-size: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
background: #f9f9f9;
border-radius: 4px;
border: 1px solid #ebebeb;
width: 100%;
height: 52px;
position: relative;
transition: 0.15s ease-in-out;
transition-property: transform;
will-change: transform;
}
.info {
@@ -225,19 +300,13 @@ const StyledCommonGridItem = styled.div`
.anticon-question-circle {
display: none;
font-size: 13px;
position: absolute;
top: 8px;
right: 8px;
}
img {
height: 40px;
width: 40px;
top: 4px;
right: 4px;
}
&:hover {
box-shadow: 0 0 10px rgb(0 0 0 / 10%);
> span {
color: var(--tango-colors-brand);
}
@@ -245,11 +314,26 @@ const StyledCommonGridItem = styled.div`
.anticon-question-circle {
display: inline-block;
}
.material-icon {
border-color: #c7c7c7;
}
}
`;
const GridLineItemStyle = css`
cursor: pointer;
user-select: none;
&:hover {
background-color: var(--tango-colors-fill2);
}
`;
function MaterialGrid({ data }: MaterialProps) {
const workspace = useWorkspace();
const { isScope, onItemSelect, layout } = usePanelContext();
const isLine = layout === 'line';
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.effectAllowed = 'move';
@@ -266,28 +350,91 @@ function MaterialGrid({ data }: MaterialProps) {
workspace.dragSource.clear();
};
const handleSelect = () => {
onItemSelect?.(data.name);
};
const icon = data.icon || 'icon-placeholder';
if (isLine) {
return (
<Box
key={data.name}
display="flex"
columnGap="m"
px="l"
py="m"
fontSize="12px"
css={GridLineItemStyle}
onClick={handleSelect}
>
<Box
p="m"
fontSize="32px"
width="46px"
height="46px"
background="fill1"
border="1px solid"
borderRadius="4px"
borderColor="line2"
display="flex"
alignItems="center"
justifyContent="center"
>
{icon.startsWith('icon-') ? (
<IconFont className="material-icon" type={data.icon || 'icon-placeholder'} />
) : (
<img className="material-icon" src={icon} alt={data.name} />
)}
</Box>
<Box flex={1}>
<Box fontWeight="500" display="flex" justifyContent="space-between">
<Text flex={1}>{data.title}</Text>
{data.docs ? (
<Popover
zIndex={9999}
placement="right"
title={data.title}
content={<CommonMaterialInfoBox docs={data.docs} />}
>
<QuestionCircleOutlined />
</Popover>
) : null}
</Box>
<Box color="text2" fontStyle="italic">
{data.help ?? data.title}
</Box>
</Box>
</Box>
);
}
return (
<StyledCommonGridItem
draggable
key={data.name}
draggable={!isScope}
data-name={data.name}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={handleSelect}
>
{icon.startsWith('icon-') ? (
<IconFont className="material-icon" type={data.icon || 'icon-placeholder'} />
) : (
<img src={icon} alt={data.name} />
<img className="material-icon" src={icon} alt={data.name} />
)}
<Text fontSize="12px" lineHeight="1.5">
{data.title}
<Text fontSize="12px" marginTop="4px">
{data.title ?? data.name}
</Text>
<Text fontSize="12px" color="gray.50">
<Text fontSize="10px" color="gray.50">
{data.name}
</Text>
{data.docs || data.help ? (
<Popover placement="right" title={data.title} content={<CommonMaterialInfoBox {...data} />}>
<Popover
zIndex={9999}
placement="right"
title={data.title}
content={<CommonMaterialInfoBox {...data} />}
>
<QuestionCircleOutlined />
</Popover>
) : null}
@@ -295,7 +442,7 @@ function MaterialGrid({ data }: MaterialProps) {
);
}
function CommonMaterialInfoBox({ help, docs }: ComponentPrototypeType) {
function CommonMaterialInfoBox({ help, docs }: Pick<IComponentPrototype, 'help' | 'docs'>) {
return (
<Box maxWidth={300}>
{!!help && <Box mb="m">{help}</Box>}

View File

@@ -1,8 +1,6 @@
import React from 'react';
import { observer, useWorkspace, useWorkspaceData } from '@music163/tango-context';
import { Box } from 'coral-system';
import { getVariableContent } from '@music163/tango-helpers';
import { isWrappedByExpressionContainer } from '@music163/tango-core';
import { VariableTree } from '../../components';
import { useSandboxQuery } from '../../context';
@@ -10,10 +8,6 @@ import { useSandboxQuery } from '../../context';
export function shapeServiceValues(val: any) {
const shapeValues = { ...val };
delete shapeValues.type;
// 兼容旧版,如果 formatter 包裹了 {} 则删掉首尾
if (shapeValues.formatter && isWrappedByExpressionContainer(shapeValues.formatter)) {
shapeValues.formatter = getVariableContent(shapeValues.formatter);
}
return shapeValues;
}

View File

@@ -10,7 +10,6 @@ import {
Popconfirm,
Radio,
message,
Tag,
Checkbox,
ModalProps,
} from 'antd';
@@ -18,7 +17,7 @@ import { FormListProps } from 'antd/lib/form';
import { Box, Text } from 'coral-system';
import semverLt from 'semver/functions/lt';
import semverValid from 'semver/functions/valid';
import { ConfigGroup, ConfigItem } from '@music163/tango-ui';
import { ColorTag, ConfigGroup, ConfigItem } from '@music163/tango-ui';
import { MinusCircleOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { useBoolean } from '@music163/tango-helpers';
import { isUndefined } from 'lodash-es';
@@ -101,8 +100,8 @@ export const DependencyPanel = observer(
/>
<ConfigGroup
title={
<>
&nbsp;
<div>
<Tooltip
title={
<>
@@ -115,7 +114,7 @@ export const DependencyPanel = observer(
>
<QuestionCircleOutlined />
</Tooltip>
</>
</div>
}
key={DependencyItemType.}
>
@@ -332,25 +331,19 @@ function RenderItem({
const packageJsonIncluded = !!dependencies?.[name];
const tangoConfigJsonIncluded = !!packageInfo;
return [
hasUmd && (
<Tooltip title="该依赖拥有 UMD 资源,可优化加载速度">
<Tag key="hasUmd" color="green">
return (
<Box display="inline-block">
{hasUmd && (
<ColorTag key="hasUmd" color="green" tooltip="该依赖拥有 UMD 资源,可优化加载速度">
UMD
</Tag>
</Tooltip>
),
packageJsonIncluded && (
<Tooltip title="此依赖在 package.json 中存在">
<Tag key="packageJson">package</Tag>
</Tooltip>
),
tangoConfigJsonIncluded && (
<Tooltip title="此依赖在 tango.config.json 中存在">
<Tag key="tangoConfigJson">tango.config</Tag>
</Tooltip>
),
].filter((e) => e);
</ColorTag>
)}
{packageJsonIncluded && <ColorTag tooltip="此依赖在 package.json 中存在">package</ColorTag>}
{tangoConfigJsonIncluded && (
<ColorTag tooltip="此依赖在 tango.config.json 中存在">tango.config</ColorTag>
)}
</Box>
);
}, [showTags, workspace, record]);
return (

View File

@@ -81,8 +81,8 @@ const OutlineTreeNode: React.FC<{ node: ITangoViewNodeData } & ComponentsTreePro
const sandboxQuery = useSandboxQuery();
const [visible, setVisible] = useState(true);
const nodeLabel = (() => {
const { index } = parseDndId(node.id);
return index;
const { codeId } = parseDndId(node.id);
return codeId;
})();
const componentPrototype = workspace.componentPrototypes.get(node.component);
const icon = componentPrototype?.icon || 'icon-placeholder';
@@ -172,12 +172,7 @@ export const ComponentsTree: React.FC<ComponentsTreeProps> = observer(
const slotKey = keys?.[0] as string;
const data = sandboxQuery.getDraggableParentsData(buildQueryBySlotId(slotKey), true);
if (data && data.id) {
workspace.selectSource.select({
id: data.id,
name: data.name,
bounding: data.bounding,
parents: data.parents,
});
workspace.selectSource.select(data);
}
// export selected
onSelect(slotKey);

View File

@@ -23,11 +23,7 @@ export function OutlinePanel({ showStateView = true, ...treeProps }: OutlineView
>
<ComponentsTree {...treeProps} />
</CollapsePanel>
{showStateView && (
<CollapsePanel title="页面状态" stickyHeader maxHeight="90%" overflowY="auto">
<StateTree />
</CollapsePanel>
)}
{showStateView && <StateTree />}
</Box>
);
}

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useReducer } from 'react';
import { observer } from '@music163/tango-context';
import { isFunction, isObject, pick } from '@music163/tango-helpers';
import { JsonView } from '@music163/tango-ui';
import { CollapsePanel, IconButton, JsonView } from '@music163/tango-ui';
import { ReloadOutlined } from '@ant-design/icons';
import { useSandboxQuery } from '../../context';
function processTango(obj: any = {}, index = 0) {
@@ -26,7 +27,7 @@ export const StateTree = observer(() => {
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const tangoContext = pick(sandboxQuery.window['tango'] || {}, [
'stores',
'page',
'pageStore',
'services',
'config',
]);
@@ -34,6 +35,7 @@ export const StateTree = observer(() => {
forceUpdate();
};
useEffect(() => {
// FIXME: 优化这段代码,应该监听 tangoContext 的变化,来刷新
const settingForm = document.querySelector('.SettingPanel');
const ob = new MutationObserver(callback);
if (settingForm) {
@@ -48,13 +50,31 @@ export const StateTree = observer(() => {
};
}, []);
return (
<JsonView
src={processTango(tangoContext)}
collapsed={1}
name="tango"
displayObjectSize={false}
indentWidth={2}
enableCopy
/>
<CollapsePanel
title="页面状态"
stickyHeader
maxHeight="90%"
overflowY="auto"
extra={
<IconButton
tooltip="同步状态"
size="small"
icon={<ReloadOutlined />}
onClick={(e) => {
e.stopPropagation();
forceUpdate();
}}
/>
}
>
<JsonView
src={processTango(tangoContext)}
collapsed={1}
name="tango"
displayObjectSize={false}
indentWidth={2}
enableCopy
/>
</CollapsePanel>
);
});

View File

@@ -74,7 +74,7 @@ export interface SidebarPanelItemProps
widgetProps?: object;
}
function BaseSidebarPanel({ panelWidth: defaultPanelWidth = 280, footer, children }: SidebarProps) {
function BaseSidebarPanel({ panelWidth: defaultPanelWidth = 266, footer, children }: SidebarProps) {
const designer = useDesigner();
const items = useMemo(() => {

View File

@@ -1,13 +1,13 @@
import React from 'react';
import styled, { css, keyframes } from 'styled-components';
import { Box, Button, Group, HTMLCoralProps } from 'coral-system';
import { Dropdown, DropdownProps } from 'antd';
import { PlusSquareOutlined, HolderOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip } from 'antd';
import { HolderOutlined, InfoCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { ISelectedItemData, isString, noop } from '@music163/tango-helpers';
import { observer, useDesigner, useWorkspace } from '@music163/tango-context';
import { Action, IconFont } from '@music163/tango-ui';
import { getDragGhostElement } from '../helpers';
import { getWidget } from '../widgets';
import { ComponentsPopover } from '../components';
/**
* 选择辅助工具的对齐方式
@@ -24,7 +24,9 @@ export interface SelectionToolsProps {
}
export const SelectionTools = observer(
({ actions: actionsProp = ['viewSource', 'copyNode', 'deleteNode'] }: SelectionToolsProps) => {
({
actions: actionsProp = ['selectParentNode', 'viewSource', 'copyNode', 'deleteNode'],
}: SelectionToolsProps) => {
const workspace = useWorkspace();
const selectSource = workspace.selectSource;
const actions = actionsProp.map((item) => {
@@ -59,12 +61,25 @@ const selectionBoxStyle = css`
top: 0;
`;
interface IInsertedData {
name: string;
label: string;
icon: string;
description: string;
}
const topAddSiblingBtnStyle = css`
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
top: 0;
border-radius: 32px;
z-index: 999;
pointer-events: auto;
`;
const bottomAddSiblingBtnStyle = css`
position: absolute;
left: 50%;
transform: translate(-50%, 50%);
bottom: 0;
border-radius: 32px;
z-index: 999;
pointer-events: auto;
`;
export interface SelectionBoxProps {
/**
@@ -97,23 +112,6 @@ function SelectionBox({ showActions, actions, data }: SelectionBoxProps) {
const prototype = workspace.componentPrototypes.get(data.name);
const isPage = prototype?.type === 'page';
// 如果声明了 childrenName提供快捷子元素创建入口
let insertedList: IInsertedData[] = [];
if (prototype?.childrenName) {
const names = Array.isArray(prototype?.childrenName)
? prototype.childrenName
: [prototype.childrenName];
insertedList = names.map((child) => {
const proto = workspace.componentPrototypes.get(child);
return {
name: child,
label: proto?.title || child,
icon: proto?.icon,
description: proto?.help,
};
});
}
let selectionHelpersAlign: SelectionHelperAlignType = 'top-right';
if (data.bounding) {
if (data.bounding.left + data.bounding.width + boundingOffset < designer.viewport.width) {
@@ -126,6 +124,7 @@ function SelectionBox({ showActions, actions, data }: SelectionBoxProps) {
}
const isFromCurrentFile = data.filename === workspace.activeViewFile;
const selectedNodeName = workspace.selectSource?.selected?.[0]?.codeId ?? '未选中';
let style: React.CSSProperties;
if (data.bounding) {
@@ -150,6 +149,18 @@ function SelectionBox({ showActions, actions, data }: SelectionBoxProps) {
css={selectionBoxStyle}
style={style}
>
<>
<ComponentsPopover type="before">
<Tooltip title={`${selectedNodeName} 的前方添加兄弟节点`}>
<SelectionHelper icon={<PlusOutlined />} css={topAddSiblingBtnStyle} />
</Tooltip>
</ComponentsPopover>
<ComponentsPopover type="after">
<Tooltip title={`${selectedNodeName} 的后方添加兄弟节点`}>
<SelectionHelper icon={<PlusOutlined />} css={bottomAddSiblingBtnStyle} />
</Tooltip>
</ComponentsPopover>
</>
{showActions && (
<SelectionHelpers align={selectionHelpersAlign}>
<SelectionHelper
@@ -179,27 +190,20 @@ function SelectionBox({ showActions, actions, data }: SelectionBoxProps) {
}}
/>
{!isFromCurrentFile && (
<Action
as="span"
color="white"
tooltip={`该节点来自其他文件(${data.filename}`}
icon={<InfoCircleOutlined />}
size="small"
/>
<Tooltip title={`该节点来自其他文件(${data.filename}`}>
<InfoCircleOutlined />
</Tooltip>
)}
</>
}
/>
<SelectionToolSet>{!isPage && actions}</SelectionToolSet>
{insertedList.length > 0 && (
<InsertedDropdown
options={insertedList}
onSelect={(name) => {
workspace.insertToSelectedNode(name);
}}
>
<SelectionHelper icon={<PlusSquareOutlined />} />
</InsertedDropdown>
{prototype.hasChildren !== false && (
<ComponentsPopover>
<Tooltip title="快捷添加子元素">
<SelectionHelper icon={<PlusOutlined />} />
</Tooltip>
</ComponentsPopover>
)}
</SelectionHelpers>
)}
@@ -248,10 +252,6 @@ const selectionHelperStyle = css`
cursor: pointer;
background: var(--color-active, var(--tango-colors-brand));
svg {
margin-right: 4px;
}
&:hover {
background: var(--color-active-hover, var(--tango-colors-primary-50));
}
@@ -262,7 +262,13 @@ interface SelectionHelperProps extends HTMLCoralProps<'button'> {
label?: React.ReactNode;
}
const SelectionHelper = ({ icon, label, children, ...rest }: SelectionHelperProps) => {
const SelectionHelper = ({
icon,
label,
children,
css: customCss,
...rest
}: SelectionHelperProps) => {
return (
<Button
position="relative"
@@ -272,7 +278,7 @@ const SelectionHelper = ({ icon, label, children, ...rest }: SelectionHelperProp
border="0"
borderRadius="s"
fontSize="12px"
css={selectionHelperStyle}
css={[selectionHelperStyle, customCss]}
{...rest}
>
{icon}
@@ -370,83 +376,3 @@ const NameSelector = ({ label, parents = [], onSelect = noop }: NameSelectorProp
</NameSelectorWrapper>
);
};
interface InsertedDropdownProps extends DropdownProps {
options?: IInsertedData[];
onSelect?: (name: string) => void;
}
function InsertedDropdown({ options = [], onSelect, ...props }: InsertedDropdownProps) {
return (
<Dropdown
trigger={['click']}
dropdownRender={() => {
return (
<Box
bg="#FFF"
borderRadius="m"
boxShadow="lowDown"
border="solid"
borderColor="line2"
overflow="hidden"
>
<Box px="l" py="m" color="text2">
</Box>
<Box>
{options.map((item) => (
<InsertedItem
key={item.name}
label={item.label}
icon={item.icon}
description={item.description}
onClick={() => onSelect?.(item.name)}
/>
))}
</Box>
</Box>
);
}}
{...props}
/>
);
}
const insertedItemStyle = css`
cursor: pointer;
&:hover {
background-color: var(--tango-colors-fill2);
}
`;
function InsertedItem({
label,
icon = 'icon-placeholder',
description,
}: HTMLCoralProps<'div'> & Omit<IInsertedData, 'name'>) {
return (
<Box display="flex" columnGap="m" px="l" py="m" fontSize="12px" css={insertedItemStyle}>
<Box
size="35px"
fontSize="32px"
background="fill1"
border="1px solid"
borderColor="line2"
display="flex"
alignItems="center"
justifyContent="center"
>
{icon.startsWith('icon-') ? (
<IconFont className="material-icon" type={icon} />
) : (
<img src={icon} alt={label} />
)}
</Box>
<Box>
<Box fontWeight="500">{label}</Box>
<Box color="text2">{description}</Box>
</Box>
</Box>
);
}

View File

@@ -1,13 +1,25 @@
import React from 'react';
import React, { useCallback } from 'react';
import { Group } from 'coral-system';
import { Modal } from 'antd';
import { ToggleButton, CodeOutlined } from '@music163/tango-ui';
import { ToggleButton, CodeOutlined, DualOutlined } from '@music163/tango-ui';
import { observer, useDesigner, useWorkspace } from '@music163/tango-context';
import { BorderOutlined } from '@ant-design/icons';
export const ModeSwitchTool = observer(() => {
const workspace = useWorkspace();
const designer = useDesigner();
const activeFileCheck = useCallback(() => {
if (workspace.activeFile !== workspace.activeViewFile) {
Modal.confirm({
title: '当前打开的文件与视图不匹配,是否切换到当前视图对应的文件?',
onOk: () => {
workspace.setActiveFile(workspace.activeViewFile);
},
});
}
}, [workspace]);
return (
<Group attached>
<ToggleButton
@@ -26,19 +38,24 @@ export const ModeSwitchTool = observer(() => {
onClick={() => {
designer.setActiveView('code'); // 切换到源码视图
designer.setActiveSidebarPanel(''); // 关闭左侧面板
if (workspace.activeFile !== workspace.activeViewFile) {
Modal.confirm({
title: '当前打开的文件与视图不匹配,是否切换到当前视图对应的文件?',
onOk: () => {
workspace.setActiveFile(workspace.activeViewFile);
},
});
}
activeFileCheck();
}}
tooltip="源码视图"
>
<CodeOutlined />
</ToggleButton>
<ToggleButton
shape="ghost"
selected={designer.activeView === 'dual'}
onClick={() => {
designer.setActiveView('dual'); // 切换到双屏视图
designer.setActiveSidebarPanel(''); // 关闭左侧面板
activeFileCheck();
}}
tooltip="双屏视图"
>
<DualOutlined />
</ToggleButton>
</Group>
);
});

View File

@@ -13,7 +13,7 @@ export const PreviewTool = observer(() => {
onClick={() => {
const nextIsPreview = !designer.isPreview;
designer.toggleIsPreview(nextIsPreview);
if (nextIsPreview) {
if (nextIsPreview && designer.activeView === 'code') {
designer.setActiveView('design');
}
}}

View File

@@ -13,7 +13,12 @@ import {
OutlinePanel,
VariablePanel,
} from './sidebar';
import { CopyNodeAction, DeleteNodeAction, ViewSourceAction } from './selection-menu';
import {
CopyNodeAction,
DeleteNodeAction,
ViewSourceAction,
SelectParentNodeAction,
} from './selection-menu';
const widgets = {};
@@ -48,4 +53,5 @@ registerWidget('sidebar.dataSource', DataSourcePanel);
registerWidget('selectionMenu.copyNode', CopyNodeAction);
registerWidget('selectionMenu.deleteNode', DeleteNodeAction);
registerWidget('selectionMenu.selectParentNode', SelectParentNodeAction);
registerWidget('selectionMenu.viewSource', ViewSourceAction);

View File

@@ -9,7 +9,6 @@ export function WorkspacePanel({ children }: WorkspacePanelProps) {
<Box
flex="1"
display="flex"
flexDirection="column"
overflow="hidden"
bg="colors.custom.viewportBg"
position="relative"

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Box } from 'coral-system';
import { observer, useDesigner } from '@music163/tango-context';
import { DesignerViewType } from '@music163/tango-core';
import { ComponentsPopover } from './components';
export interface WorkspaceViewProps {
/**
@@ -21,12 +22,14 @@ export const WorkspaceView = observer((props: WorkspaceViewProps) => {
return (
<Box
className={`ViewPanel ${mode}`}
display={display}
display={designer.activeView === 'dual' ? 'block' : display}
flex="1"
overflow={overflow}
position="relative"
>
{children}
{/* 添加组件弹层 */}
{display === 'block' && <ComponentsPopover type="inner" isControlled />}
</Box>
);
});

View File

@@ -3,6 +3,48 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.1.1](https://github.com/netease/tango/compare/@music163/tango-helpers@1.1.0...@music163/tango-helpers@1.1.1) (2024-05-21)
### Bug Fixes
- support add components with popover ([#155](https://github.com/netease/tango/issues/155)) ([f17ccbb](https://github.com/netease/tango/commit/f17ccbb7f645f8047ecd96d9f3f2185048a3b726))
# [1.1.0](https://github.com/netease/tango/compare/@music163/tango-helpers@1.0.0...@music163/tango-helpers@1.1.0) (2024-05-17)
### Bug Fixes
- parse module with module alias ([#147](https://github.com/netease/tango/issues/147)) ([56d0877](https://github.com/netease/tango/commit/56d0877507b0138877eac6f36db288147b43c7d9))
- support quick set tid in SettingForm ([#151](https://github.com/netease/tango/issues/151)) ([1fc22a4](https://github.com/netease/tango/commit/1fc22a4535a8bdc12a018a41ebd5ab908fc46817))
### Features
- refactor parse attribute value ([#149](https://github.com/netease/tango/issues/149)) ([ffaa276](https://github.com/netease/tango/commit/ffaa276b5c205ed962d37e2fdb358a703f8fad01))
# [1.0.0-alpha.7](https://github.com/netease/tango/compare/@music163/tango-helpers@1.0.0-alpha.6...@music163/tango-helpers@1.0.0-alpha.7) (2024-04-22)
### Bug Fixes
- allow reload state tree & display pageStore in variable tree ([#135](https://github.com/netease/tango/issues/135)) ([2664613](https://github.com/netease/tango/commit/2664613ab263aead2a5239fa012454fc7fd5ff99))
# [1.0.0-alpha.6](https://github.com/netease/tango/compare/@music163/tango-helpers@1.0.0-alpha.5...@music163/tango-helpers@1.0.0-alpha.6) (2024-04-09)
### Bug Fixes
- check is in design mode ([cb6aabf](https://github.com/netease/tango/commit/cb6aabf6ce45a33ba9055c45734f7db0fba540ec))
- update setters and use tabOptions to filter props ([#129](https://github.com/netease/tango/issues/129)) ([93608d1](https://github.com/netease/tango/commit/93608d1037327afa4f755976b86427b6128ae3d0))
# [1.0.0-alpha.5](https://github.com/netease/tango/compare/@music163/tango-helpers@1.0.0-alpha.4...@music163/tango-helpers@1.0.0-alpha.5) (2024-03-26)
### Features
- quick add sibling nodes ([#127](https://github.com/netease/tango/issues/127)) ([9ed6a7d](https://github.com/netease/tango/commit/9ed6a7d1a4944d69d96e034f243b61531862e317))
# [1.0.0-alpha.4](https://github.com/netease/tango/compare/@music163/tango-helpers@1.0.0-alpha.3...@music163/tango-helpers@1.0.0-alpha.4) (2024-03-18)
### Bug Fixes
- add deprecated and data group to ComponentPropType ([#116](https://github.com/netease/tango/issues/116)) ([9f7441c](https://github.com/netease/tango/commit/9f7441c13400dba55c58b7dd6ce9429013edbc45))
# [1.0.0-alpha.3](https://github.com/netease/tango/compare/@music163/tango-helpers@1.0.0-alpha.2...@music163/tango-helpers@1.0.0-alpha.3) (2024-03-18)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-helpers",
"version": "1.0.0-alpha.3",
"version": "1.1.1",
"description": "Shared types, helpers, and hooks of tango-apps",
"keywords": [
"shared",

View File

@@ -50,7 +50,7 @@ export function isNil(val: any) {
* @returns
*/
export function isStoreVariablePath(key: string) {
return /^stores\.[a-zA-Z0-9]+\.\w+$/.test(key);
return key === 'pageStore' || /^stores\.[a-zA-Z0-9]+\.\w+$/.test(key);
}
/**
@@ -66,5 +66,5 @@ export function isServiceVariablePath(key: string) {
* 是否在 Tango 设计模式
*/
export function isInTangoDesignMode() {
return '__TANGO_DESIGNER__' in window;
return !!(window as any).__TANGO_DESIGNER__;
}

View File

@@ -0,0 +1,197 @@
import { isString } from './assert';
/**
* 给定字符串是否是合法的 JSON 字符串
* @param str
*/
export function isJSONString(str: string) {
if (!str) {
return false;
}
let json;
try {
json = JSON.parse(str);
return typeof json === 'object';
} catch (err) {
return false;
}
}
/**
* 给定代码是否是有效的函数代码
* @param str
*/
export function isValidFunctionCode(str: string) {
try {
// eslint-disable-next-line no-eval
const ret = eval(`typeof (${str})`);
return ret === 'function';
} catch (e) {
return false;
}
}
const templatePattern = /^{{(.+)}}$/s;
/**
* 判断给定代码是否被双花括号包裹
* @example {{[]}}
* @example {{{}}}
* @example {{this.foo}}
* @example {{123}}
* @param str
* @returns
*/
export function isWrappedCode(str: string) {
// 排除简单对象后,再用正则匹配
return templatePattern.test(str);
}
export const isVariableString = isWrappedCode;
/**
* 从包裹的代码中获取代码内容
* @param str
* @returns
*/
export function getCodeOfWrappedCode(str: string) {
const match = templatePattern.exec(str);
if (match && match.length) {
return match[1];
}
return str;
}
export const getVariableContent = getCodeOfWrappedCode;
/**
* 给输入代码加上双花括号 code -> {{code}}
* @example foo -> {{foo}}
* @example "hello" => {{"hello"}}
* @example () => {} => {{() => {}}}
*
* @param code 输入代码
* @returns 加上花括号后的代码
*/
export function wrapCode(code: string) {
if (isWrappedCode(code)) {
return code;
}
return `{{${code}}}`;
}
/**
* 使用 JSX 表达式容器包裹代码
* @example foo -> {foo}
* @example "hello" => {"hello"}
* @param code
* @returns
*/
export function wrapCodeWithJSXExpressionContainer(code: string) {
return `{${code}}`;
}
/**
* 是否为简单字符串,非变量字符串
* @param str
*/
export function isPlainString(str: string) {
const isWrapped = isString(str) && isWrappedCode(str);
return isString && !isWrapped;
}
const codeBlockPattern = /```(\w*)([\s\S]*?)```/g;
/**
* 从 markdown 中解析出代码片段,仅返回第一个匹配的代码片段
* @param markdown
* @returns
*/
export function getCodeBlockFormMarkdown(markdown: string) {
const match = codeBlockPattern.exec(markdown.trim());
if (match && match.length) {
return match[2];
}
}
export function url2serviceName(url: string) {
if (url.startsWith('http')) {
// 去除域名前缀
url = url
.replace(/https?:\/\//, '')
.split('/')
.slice(1)
.join('/');
}
return (
url
// 去除 api + 模块名前缀
// - 云音乐 api 规范为 /api/模块名/
// - 后端公技基本使用 /模块名/api/
// - 中台类服务似乎常用 /api/middle/模块名/
// 目前的实现是去除了模块名,只干掉 /api/middle/ 和 /api/backend/ 这种常用前缀
.replace(/^\/[^/]+?\/api\/|^\/api\/middle\/|^\/api\/backend\/|^\/api\//, '')
// 去除路由参数
.replace(/\/\{.*?\}/, '')
// 忽略下划线与减号,将后面的字符转成大驼峰
.replace(/[-/_]+\w/g, (str) => str.replace(/[-/_]+/, '').toUpperCase())
// 首字母转小写
.replace(/^./, (str) => str.toLowerCase())
// 方法名以数字开头,添加 api 前缀
.replace(/^\d/, (str) => `api${str}`)
);
}
/**
* 解析状态变量的 path
* @example stores.foo.bar => { storeName: 'foo', variableName: 'bar' }
* @example stores.user.count => { storeName: 'user', variableName: 'count' }
*
* @param variablePath
* @returns
*/
export function parseStoreVariablePath(variablePath: string) {
const [, storeName, variableName] = variablePath.split('.');
return {
storeName,
variableName,
};
}
/**
* 解析服务变量的 path
* @param variablePath
* @returns
*
* @example services.list => { moduleName: 'index', name: 'list' }
* @example services.sub.list => { moduleName: 'sub', name: 'list' }
* @example foo => undefined
*/
export function parseServiceVariablePath(variablePath: string) {
const parts = variablePath.split('.');
if (parts[0] !== 'services') {
return {};
}
let moduleName = 'index';
let name = '';
switch (parts.length) {
case 2: {
name = parts[1];
break;
}
case 3: {
moduleName = parts[1];
name = parts[2];
break;
}
default:
break;
}
return {
moduleName,
name,
};
}

View File

@@ -7,5 +7,6 @@ export * from './events';
export * from './function';
export * from './logger';
export * from './string';
export * from './react-helper';
export * from './object';
export * from './react-helper';
export * from './code-helper';

View File

@@ -75,9 +75,13 @@ export function parseDndTrackId(str: string) {
interface DndIdParsedType {
/**
* 完整的 ID
* full id
*/
id?: string;
/**
* 组件代码中的 id一般对应组件的 tid 属性
*/
codeId?: string;
/**
* 组件名
*/
@@ -86,19 +90,11 @@ interface DndIdParsedType {
* 文件名
*/
filename?: string;
/**
* @deprecated 使用 filename 代替
*/
module?: string;
/**
* 序号
*/
index?: string;
}
/**
* 解析 dnd id
* @example Button:123 -> { component: "Button", id: "Button:123" }
* @example Button:button123 -> { component: "Button", id: "Button:123" }
* @example LocalBlock:Button:123 -> { filename: LocalBlock, component: "Button", id: "Button:123" }
* @param str
*/
@@ -109,277 +105,23 @@ export function parseDndId(str: string): DndIdParsedType {
const parts = str.split(':');
if (parts.length === 2) {
// e.g. Button:button123
return {
component: parts[0],
index: parts[1],
id: str,
component: parts[0],
codeId: parts[1],
};
} else if (parts.length >= 3) {
// e.g. LocalBlock:Button:button123
const filename = decodeURIComponent(parts[0]);
return {
module: filename,
id: str,
filename,
component: parts[1],
index: parts[2],
id: str,
codeId: parts[2],
};
}
return {
id: str,
};
}
/**
* 给定字符串是否是合法的 JSON 字符串
* @param str
*/
export function isJSONString(str: string) {
if (!str) {
return false;
}
let json;
try {
json = JSON.parse(str);
return typeof json === 'object';
} catch (err) {
return false;
}
}
/**
* 给定代码是否是有效的函数代码
* @param str
*/
export function isValidFunctionCode(str: string) {
try {
// eslint-disable-next-line no-eval
const ret = eval(`typeof (${str})`);
return ret === 'function';
} catch (e) {
return false;
}
}
/**
* 是否是有效的对象字符串
*
* @example { foo: 'foo' }
* @example [{ foo: 'foo' }]
* TODO: 考虑箭头函数的情况 () => {}
*
* @param str
* @returns
*/
export function isValidObjectString(str: string) {
const obj = code2object(str);
if (obj && typeof obj === 'object') {
return true;
}
return false;
}
const templatePattern = /^{(.+)}$/s;
/**
* 判断给定字符串是否为变量字符串
* @deprecated 使用 isWrappedByExpressionContainer 代替
*
* @example {[]}
* @example {{}}
* @example {this.foo}
* @example {123}
* @param str
* @returns
*/
export function isVariableString(str: string) {
// 先检查是否是简单的对象
// FIXME: 这里有问题,如果代码中有引用,会被误判
if (code2object(str)) {
return false;
}
// 排除简单对象后,再用正则匹配
return templatePattern.test(str);
}
/**
* 给输入代码加上花括号
* @example foo -> {foo}
* @example "hello" => {"hello"}
* @example () => {} => {() => {}}
*
* @deprecated 有问题,不要使用
*
* @param code 输入代码
* @returns 加上花括号后的代码
*/
export function wrapCode(code: string) {
if (isVariableString(code)) {
return code;
}
return `{${code}}`;
}
/**
* 是否为简单字符串,非变量字符串
* @param str
*/
export function isPlainString(str: string) {
const isString = typeof str === 'string';
const isVarString = isString && isVariableString(str);
return isString && !isVarString;
}
/**
* 解析并获取变量字符串的内容
* @param str
* @returns
*/
export function getVariableContent(str: string) {
const match = templatePattern.exec(str);
if (match && match.length) {
return match[1];
}
return str;
}
// 提供给代码执行环境的全局变量
const patchCode = `
var tango = {
stores: {},
services: {},
config: {},
refs: {},
};
`;
/**
* 将代码放到函数体中进行执行
* @param code
* @returns 函数执行的结果
*/
export function runCode(code: string) {
let ret;
try {
// eslint-disable-next-line no-new-func
ret = new Function(`${patchCode}\n return ${code}`)();
} catch (err) {
// ignore error
}
return ret;
}
// eslint-disable-next-line no-useless-escape
const objectWrapperPattern = /^[{\[].*[}\]]$/s;
/**
* 将代码片段转成 js 对象
* @param code 代码文本
* @param isStrict 是否为严格模式(是否废弃)
* @returns
*/
export function code2object(code: string, isStrict = true) {
// 非严格模式直接执行
// 严格模式下需检测 code 是一个对象
if (!isStrict || (isStrict && objectWrapperPattern.test(code))) {
const ret = runCode(code);
return typeof ret === 'object' ? ret : undefined;
}
return code;
}
const codeBlockPattern = /```(\w*)([\s\S]*?)```/g;
/**
* 从 markdown 中解析出代码片段,仅返回第一个匹配的代码片段
* @param markdown
* @returns
*/
export function getCodeBlockFormMarkdown(markdown: string) {
const match = codeBlockPattern.exec(markdown.trim());
if (match && match.length) {
return match[2];
}
}
export function url2serviceName(url: string) {
if (url.startsWith('http')) {
// 去除域名前缀
url = url
.replace(/https?:\/\//, '')
.split('/')
.slice(1)
.join('/');
}
return (
url
// 去除 api + 模块名前缀
// - 云音乐 api 规范为 /api/模块名/
// - 后端公技基本使用 /模块名/api/
// - 中台类服务似乎常用 /api/middle/模块名/
// 目前的实现是去除了模块名,只干掉 /api/middle/ 和 /api/backend/ 这种常用前缀
.replace(/^\/[^/]+?\/api\/|^\/api\/middle\/|^\/api\/backend\/|^\/api\//, '')
// 去除路由参数
.replace(/\/\{.*?\}/, '')
// 忽略下划线与减号,将后面的字符转成大驼峰
.replace(/[-/_]+\w/g, (str) => str.replace(/[-/_]+/, '').toUpperCase())
// 首字母转小写
.replace(/^./, (str) => str.toLowerCase())
// 方法名以数字开头,添加 api 前缀
.replace(/^\d/, (str) => `api${str}`)
);
}
/**
* 解析状态变量的 path
* @example stores.foo.bar => { storeName: 'foo', variableName: 'bar' }
* @example stores.user.count => { storeName: 'user', variableName: 'count' }
*
* @param variablePath
* @returns
*/
export function parseStoreVariablePath(variablePath: string) {
const [, storeName, variableName] = variablePath.split('.');
return {
storeName,
variableName,
};
}
/**
* 解析服务变量的 path
* @param variablePath
* @returns
*
* @example services.list => { moduleName: 'index', name: 'list' }
* @example services.sub.list => { moduleName: 'sub', name: 'list' }
* @example foo => undefined
*/
export function parseServiceVariablePath(variablePath: string) {
const parts = variablePath.split('.');
if (parts[0] !== 'services') {
return {};
}
let moduleName = 'index';
let name = '';
switch (parts.length) {
case 2: {
name = parts[1];
break;
}
case 3: {
moduleName = parts[1];
name = parts[2];
break;
}
default:
break;
}
return {
moduleName,
name,
};
}

View File

@@ -132,8 +132,3 @@ export interface ISelectedItemData {
element?: HTMLElement;
parents?: ISelectedItemData[];
}
export type SetterOnChangeDetailType = {
relatedImports?: string[];
isRawCode?: boolean;
};

View File

@@ -3,11 +3,36 @@
*/
import { OptionType } from './advanced';
import { PartialRecord } from './base';
/**
* @deprecated 请使用 IComponentProp 代替
*/
export type ComponentPropType<T = any> = IComponentProp<T>;
/**
* @deprecated
*/
export type ComponentDndRulesType = IComponentDndRules;
/**
* @deprecated
*/
export type ComponentPrototypeType = IComponentPrototype;
/**
* 组件校验规则
*/
export type ComponentPropValidate = (
value: any,
field: any,
trigger: string,
) => string | Promise<string>;
/**
* 组件属性类型
*/
export type ComponentPropType<T = any> = {
export interface IComponentProp<T = any> {
/**
* 属性名
*/
@@ -23,11 +48,12 @@ export type ComponentPropType<T = any> = {
/**
* 属性的分组
* - basic 常用属性
* - event 事件属性
* - style 样式属性
* - advanced 高级属性
* - event 事件属性
* - data 数据属性
*/
group?: 'basic' | 'event' | 'style' | 'advanced';
group?: 'basic' | 'event' | 'style' | 'data' | 'advanced';
/**
* 帮助文档链接地址
* @example https://foo.bar/help
@@ -49,7 +75,7 @@ export type ComponentPropType<T = any> = {
/**
* 如果是对象属性,这里声明子属性列表
*/
props?: ComponentPropType[];
props?: IComponentProp[];
/**
* 输入提示
*/
@@ -78,7 +104,7 @@ export type ComponentPropType<T = any> = {
/**
* 动态设置属性,覆盖已有的 prop 对象
*/
getProp?: (form: any) => Partial<Omit<ComponentPropType<T>, 'getProp'>>;
getProp?: (form: any) => Partial<Omit<IComponentProp<T>, 'getProp'>>;
/**
* 配置项的可选值setterProps.options 的简写
*/
@@ -91,12 +117,20 @@ export type ComponentPropType<T = any> = {
* 动态设置表单项是否展示
*/
getVisible?: (form: any) => boolean;
};
/**
* 表单值校验逻辑
*/
validate?: ComponentPropValidate;
/**
* 标记属性是否已废弃
*/
deprecated?: boolean | string;
}
/**
* 组件拖拽规则类型
*/
export type ComponentDndRulesType = {
export interface IComponentDndRules {
/**
* 当前节点是否可以被拖拽
*/
@@ -117,12 +151,12 @@ export type ComponentDndRulesType = {
* 子节点的容器选择器,用于快速定位子节点容器,适合组件存在多个可搭建区域时使用
*/
childrenContainerSelector?: string;
};
}
/**
* 组件原型类型
*/
export type ComponentPrototypeType = {
export interface IComponentPrototype {
/**
* 组件名
* @example Button
@@ -228,20 +262,24 @@ export type ComponentPrototypeType = {
* 直接子节点的类型
*/
childrenName?: string | string[];
/**
* 兄弟节点的类型
*/
siblingNames?: string[];
/**
* 组件的可配置属性集
*/
props?: ComponentPropType[];
props?: IComponentProp[];
/**
* 组件的拖拽规则
*/
rules?: ComponentDndRulesType;
rules?: IComponentDndRules;
/**
* 用法
* @example import { Button } from 'antd';
*/
usage?: string;
};
}
/**
* TangoConfigJson 类型配置
@@ -263,6 +301,19 @@ export interface ITangoConfigJson {
* 外部资源,例如 js, css 文件等
*/
externalResources?: Record<string, any>;
/**
* 沙箱配置
*/
sandbox?: {
/**
* 在沙箱中执行的脚本
*/
evaluateJavaScript?: string;
/**
* 模块的别名
*/
alias?: Record<string, string>;
};
/**
* 设计器配置
*/
@@ -277,3 +328,19 @@ export interface ITangoConfigJson {
autoGenerateComponentId: boolean;
};
}
/**
* 物料类型
common: '基础组件',
atom: '原子组件',
snippet: '组合',
bizComp: '业务组件',
localComp: '本地组件',
*/
export type MenuKeyType = 'common' | 'atom' | 'snippet' | 'bizComp' | 'localComp';
export type MenuValueType = Array<{ title: string; items: string[] }>;
/**
* 菜单项类型
*/
export type MenuDataType = PartialRecord<MenuKeyType, MenuValueType>;

View File

@@ -1,7 +1,5 @@
import {
isVariableString,
isValidObjectString,
code2object,
parseDndTrackId,
getVariableContent,
camelCase,
@@ -35,40 +33,22 @@ describe('string', () => {
});
it('isVariableString', () => {
expect(isVariableString('{this.foo}')).toBeTruthy();
expect(isVariableString('{!false}')).toBeTruthy();
expect(isVariableString('{[]}')).toBeTruthy();
expect(isVariableString('{{ foo: "bar" }}')).toBeTruthy();
expect(isVariableString('{[{ foo: "bar" }]}')).toBeTruthy();
expect(isVariableString('{123}')).toBeTruthy();
expect(isVariableString('{value}')).toBeTruthy();
expect(isVariableString('{"hello"}')).toBeTruthy();
expect(isVariableString('{{this.foo}}')).toBeTruthy();
expect(isVariableString('{{!false}}')).toBeTruthy();
expect(isVariableString('{{[]}}')).toBeTruthy();
expect(isVariableString('{{{ foo: "bar" }}}')).toBeTruthy();
expect(isVariableString('{{[{ foo: "bar" }]}}')).toBeTruthy();
expect(isVariableString('{{123}}')).toBeTruthy();
expect(isVariableString('{{value}}')).toBeTruthy();
expect(isVariableString('{{"hello"}}')).toBeTruthy();
expect(isVariableString('{ foo: "bar" }')).toBeFalsy();
expect(isVariableString('{ type: tango.stores?.homePage?.tabKey }')).toBeFalsy();
// expect(isVariableString('{ type: tango.stores.homePage.tabKey }')).toBeFalsy(); // TIP: failed
expect(isVariableString('{ type: tango.stores.homePage.tabKey }')).toBeFalsy();
expect(isVariableString('{ foo: "bar" }')).toBeFalsy();
});
it('getVariableContent', () => {
expect(getVariableContent('{!false}')).toBe('!false');
});
it('isValidObjectString', () => {
expect(isValidObjectString('{ foo: "bar" }')).toBeTruthy();
expect(isValidObjectString('[{ foo: "bar" }]')).toBeTruthy();
expect(isValidObjectString('[1,2,3]')).toBeTruthy();
expect(isValidObjectString('["hello", "world"]')).toBeTruthy();
// expect(isValidObjectString('() => {}')).toBeTruthy();
expect(isValidObjectString('hello')).toBeFalsy();
});
it('code2object', () => {
expect(code2object(`{ foo: 12 }`)).toEqual({ foo: 12 });
expect(code2object(`[]`)).toEqual([]);
expect(code2object(`{this.foo}`)).toBeUndefined();
expect(code2object(`{foo}`)).toBeUndefined();
expect(code2object('() => {}')).toEqual('() => {}');
expect(code2object('hello')).toEqual('hello');
expect(getVariableContent('{{!false}}')).toBe('!false');
});
it('parseDndTrackId', () => {
@@ -78,22 +58,21 @@ describe('string', () => {
it('parseDndId', () => {
expect(parseDndId('123')).toEqual({ id: '123' });
expect(parseDndId('Button:123')).toEqual({
expect(parseDndId('Button:button123')).toEqual({
component: 'Button',
id: 'Button:123',
index: '123',
id: 'Button:button123',
codeId: 'button123',
});
expect(parseDndId('Button.Group:123')).toEqual({
expect(parseDndId('Button.Group:buttonGroup123')).toEqual({
component: 'Button.Group',
id: 'Button.Group:123',
index: '123',
id: 'Button.Group:buttonGroup123',
codeId: 'buttonGroup123',
});
expect(parseDndId('LocalBlock:Button.Group:123')).toEqual({
expect(parseDndId('LocalBlock:Button.Group:buttonGroup123')).toEqual({
id: 'LocalBlock:Button.Group:buttonGroup123',
filename: 'LocalBlock',
module: 'LocalBlock',
component: 'Button.Group',
id: 'LocalBlock:Button.Group:123',
index: '123',
codeId: 'buttonGroup123',
});
});

View File

@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.0.4](https://github.com/netease/tango/compare/@music163/tango-sandbox@1.0.3...@music163/tango-sandbox@1.0.4) (2024-05-21)
**Note:** Version bump only for package @music163/tango-sandbox
## [1.0.3](https://github.com/netease/tango/compare/@music163/tango-sandbox@1.0.2...@music163/tango-sandbox@1.0.3) (2024-05-17)
**Note:** Version bump only for package @music163/tango-sandbox
## [1.0.1](https://github.com/netease/tango/compare/@music163/tango-sandbox@1.0.0...@music163/tango-sandbox@1.0.1) (2024-04-22)
**Note:** Version bump only for package @music163/tango-sandbox
# [1.0.0-alpha.10](https://github.com/netease/tango/compare/@music163/tango-sandbox@1.0.0-alpha.9...@music163/tango-sandbox@1.0.0-alpha.10) (2024-04-09)
**Note:** Version bump only for package @music163/tango-sandbox
# [1.0.0-alpha.9](https://github.com/netease/tango/compare/@music163/tango-sandbox@1.0.0-alpha.8...@music163/tango-sandbox@1.0.0-alpha.9) (2024-03-26)
**Note:** Version bump only for package @music163/tango-sandbox
# [1.0.0-alpha.8](https://github.com/netease/tango/compare/@music163/tango-sandbox@1.0.0-alpha.7...@music163/tango-sandbox@1.0.0-alpha.8) (2024-03-18)
**Note:** Version bump only for package @music163/tango-sandbox
# [1.0.0-alpha.7](https://github.com/netease/tango/compare/@music163/tango-sandbox@1.0.0-alpha.6...@music163/tango-sandbox@1.0.0-alpha.7) (2024-03-18)
**Note:** Version bump only for package @music163/tango-sandbox

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-sandbox",
"version": "1.0.0-alpha.7",
"version": "1.0.4",
"description": "sandbox of tango apps",
"author": "wwsun <ww.sun@outlook.com>",
"homepage": "",
@@ -29,8 +29,8 @@
},
"dependencies": {
"@ant-design/icons": "^4.8.0",
"@music163/tango-core": "^1.0.0-alpha.7",
"@music163/tango-helpers": "^1.0.0-alpha.3",
"@music163/tango-core": "^1.2.0",
"@music163/tango-helpers": "^1.1.1",
"crypto-js": "^4.1.1",
"lodash.isequal": "4.5.0",
"react-frame-component": "^5.2.4"

View File

@@ -3,6 +3,54 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.2.1](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.2.0...@music163/tango-setting-form@1.2.1) (2024-05-21)
### Bug Fixes
- enhance code value validate in SettingForm ([#152](https://github.com/netease/tango/issues/152)) ([791fbb1](https://github.com/netease/tango/commit/791fbb162a7147243924e01f54e9c0b586f14438))
- prototype2code & go back history error ([#156](https://github.com/netease/tango/issues/156)) ([8bf53a7](https://github.com/netease/tango/commit/8bf53a76f8a71eaf261ea68b9ee44e5bf19893aa))
# [1.2.0](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.1.0...@music163/tango-setting-form@1.2.0) (2024-05-17)
### Bug Fixes
- support quick set tid in SettingForm ([#151](https://github.com/netease/tango/issues/151)) ([1fc22a4](https://github.com/netease/tango/commit/1fc22a4535a8bdc12a018a41ebd5ab908fc46817))
### Features
- refactor parse attribute value ([#149](https://github.com/netease/tango/issues/149)) ([ffaa276](https://github.com/netease/tango/commit/ffaa276b5c205ed962d37e2fdb358a703f8fad01))
## [1.0.2](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.1...@music163/tango-setting-form@1.0.2) (2024-04-22)
**Note:** Version bump only for package @music163/tango-setting-form
## [1.0.1](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.0...@music163/tango-setting-form@1.0.1) (2024-04-16)
**Note:** Version bump only for package @music163/tango-setting-form
# [1.0.0-alpha.14](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.0-alpha.13...@music163/tango-setting-form@1.0.0-alpha.14) (2024-04-09)
### Bug Fixes
- update setters and use tabOptions to filter props ([#129](https://github.com/netease/tango/issues/129)) ([93608d1](https://github.com/netease/tango/commit/93608d1037327afa4f755976b86427b6128ae3d0))
- update SettingForm style ([#125](https://github.com/netease/tango/issues/125)) ([3f42516](https://github.com/netease/tango/commit/3f42516816968888554f719332d3cef7d8576aa8))
# [1.0.0-alpha.13](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.0-alpha.12...@music163/tango-setting-form@1.0.0-alpha.13) (2024-03-26)
### Features
- quick add sibling nodes ([#127](https://github.com/netease/tango/issues/127)) ([9ed6a7d](https://github.com/netease/tango/commit/9ed6a7d1a4944d69d96e034f243b61531862e317))
# [1.0.0-alpha.12](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.0-alpha.11...@music163/tango-setting-form@1.0.0-alpha.12) (2024-03-19)
**Note:** Version bump only for package @music163/tango-setting-form
# [1.0.0-alpha.11](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.0-alpha.10...@music163/tango-setting-form@1.0.0-alpha.11) (2024-03-18)
### Bug Fixes
- add deprecated and data group to ComponentPropType ([#116](https://github.com/netease/tango/issues/116)) ([9f7441c](https://github.com/netease/tango/commit/9f7441c13400dba55c58b7dd6ce9429013edbc45))
# [1.0.0-alpha.10](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.0-alpha.9...@music163/tango-setting-form@1.0.0-alpha.10) (2024-03-18)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-setting-form",
"version": "1.0.0-alpha.10",
"version": "1.2.1",
"description": "setting form of tango-apps",
"author": "wwsun <ww.sun@outlook.com>",
"homepage": "",
@@ -28,13 +28,13 @@
},
"dependencies": {
"@ant-design/icons": "^4.8.0",
"@music163/tango-core": "^1.0.0-alpha.7",
"@music163/tango-helpers": "^1.0.0-alpha.3",
"@music163/tango-ui": "^1.0.0-alpha.8",
"@music163/tango-core": "^1.2.0",
"@music163/tango-helpers": "^1.1.1",
"@music163/tango-ui": "^1.2.0",
"antd": "^4.24.2",
"coral-system": "^1.0.5",
"mobx": "6.12.0",
"mobx-react-lite": "4.0.5"
"mobx": "6.12.3",
"mobx-react-lite": "4.0.7"
},
"publishConfig": {
"access": "public",

View File

@@ -1,20 +1,25 @@
import React from 'react';
import React, { useState } from 'react';
import { toJS } from 'mobx';
import { observer } from 'mobx-react-lite';
import {
clone,
ComponentPropType,
SetterOnChangeDetailType,
useBoolean,
ComponentPropValidate,
getCodeOfWrappedCode,
IComponentProp,
isNil,
isString,
isWrappedCode,
wrapCode,
} from '@music163/tango-helpers';
import { isWrappedByExpressionContainer } from '@music163/tango-core';
import { ToggleButton, CodeOutlined } from '@music163/tango-ui';
import { ErrorBoundary } from '@music163/tango-ui';
import { code2value, value2code } from '@music163/tango-core';
import { InputProps } from 'antd';
import { useFormModel, useFormVariable } from './context';
import { FormControl } from './form-ui';
import { FormControl, ToggleCodeButton } from './form-ui';
import { Box, Text } from 'coral-system';
import { ISetterOnChangeCallbackDetail } from './types';
export interface FormItemProps extends ComponentPropType {
export interface FormItemProps extends IComponentProp {
/**
* 无样式模式,仅返回 setter 组件
*/
@@ -31,7 +36,7 @@ export interface FormItemProps extends ComponentPropType {
export interface FormItemComponentProps<T = any> {
value?: T;
onChange: (value: T, detail?: SetterOnChangeDetailType) => void;
onChange: (value: T, detail?: ISetterOnChangeCallbackDetail) => void;
readOnly?: boolean;
disabled?: boolean;
status?: InputProps['status'];
@@ -47,6 +52,10 @@ export interface IFormItemCreateOptions {
* 设置器别名列表,支持多个名字
*/
alias?: string[];
/**
* 设置器类型value类设置器支持切换到codeSetter默认为 value setter
*/
type?: 'code' | 'value';
/**
* 渲染设置器使用的组件
*/
@@ -62,15 +71,109 @@ export interface IFormItemCreateOptions {
/**
* 默认的表单值校验逻辑
*/
validate?: (value: string) => string | Promise<any>;
validate?: ComponentPropValidate;
}
const defaultGetSetterProps = () => ({});
const defaultGetVisible = () => true;
function parseFieldValue(fieldValue: any) {
let value: any;
let code: string;
if (!fieldValue) {
return [];
}
const isCodeString = isString(fieldValue) && isWrappedCode(fieldValue);
if (isCodeString) {
code = getCodeOfWrappedCode(fieldValue);
try {
// 避免 code 报错的情况
value = code2value(code);
} catch (err) {
// do nothing
}
} else {
code = value2code(fieldValue);
value = fieldValue;
}
return [value, code];
}
interface UseSetterValueProps {
fieldValue: any;
setter?: string;
setterType?: IFormItemCreateOptions['type'];
/**
* 强制初始化为 codeSetter适用于外部需要特别干预的情况
*/
forceCodeSetter?: boolean;
}
export function useSetterValue({
fieldValue,
setter,
setterType,
forceCodeSetter,
}: UseSetterValueProps) {
const [value, code] = parseFieldValue(fieldValue);
const [isCodeSetter, setIsCodeSetter] = useState(() => {
if (forceCodeSetter) {
return true;
}
// 同时不存在,表示是空置,使用默认模式
if (!code && !value) {
return false;
}
// value 解析出错的情况,使用 codeSetter
if (isNil(value)) {
return true;
}
// 其他情况,均使用默认模式
return false;
});
const toggleSetter = () => {
setIsCodeSetter(!isCodeSetter);
};
let fixedSetter: string;
let setterValue: any;
if (setterType === 'code') {
fixedSetter = setter;
setterValue = code;
} else {
fixedSetter = isCodeSetter ? 'codeSetter' : setter;
setterValue = isCodeSetter ? code : value;
}
return {
value,
code,
setter: fixedSetter,
setterValue, // setter value
isCodeSetter, // 是否为 codeSetter
toggleSetter, // 切换 setter
};
}
export function createFormItem(options: IFormItemCreateOptions) {
const renderSetter =
options.render ?? ((props: any) => React.createElement(options.component, props));
const setterType = options.type ?? 'value'; // 设置器的模式
function getShowToggleCodeButton(disableVariableSetter = options.disableVariableSetter) {
if (setterType === 'code') {
// codeSetter 无需切换按钮
return false;
}
// 如果用户设置了 disableVariableSetter则不显示切换按钮
return !disableVariableSetter;
}
function FormItem({
name,
@@ -79,50 +182,57 @@ export function createFormItem(options: IFormItemCreateOptions) {
placeholder,
docs,
autoCompleteOptions,
setter,
setter: setterProp,
setterProps,
defaultValue,
options: setterOptions,
disableVariableSetter: disableVariableSetterProp = options.disableVariableSetter,
disableVariableSetter,
getVisible: getVisibleProp,
getSetterProps: getSetterPropsProp,
deprecated,
extra,
footer,
noStyle,
validate = options.validate,
}: FormItemProps) {
const { disableSwitchExpressionSetter, showItemSubtitle } = useFormVariable();
const model = useFormModel();
const field = model.getField(name);
const value = toJS(field.value ?? defaultValue);
const disableVariableSetter = disableSwitchExpressionSetter ?? disableVariableSetterProp;
const [isVariable, { toggle: toggleIsVariable }] = useBoolean(
() => !disableVariableSetter && isWrappedByExpressionContainer(value),
);
const setterName = isVariable ? 'expressionSetter' : setter;
field.setConfig({
validate: options.validate,
const fieldValue = toJS(field.value ?? defaultValue);
const { setterValue, setter, isCodeSetter, toggleSetter } = useSetterValue({
fieldValue,
setter: setterProp,
setterType,
});
const baseComponentProps = clone(
{
value,
defaultValue,
onChange: field.handleChange,
status: field.error ? 'error' : undefined,
placeholder,
options: setterOptions,
field.setConfig({
validate: setter === 'codeSetter' ? getSetter('codeSetter').config.validate : validate,
});
let baseComponentProps: FormItemComponentProps = {
value: setterValue,
defaultValue,
onChange(value, detail = {}) {
if ((setterType === 'code' || isCodeSetter) && isString(value) && value) {
detail.rawCode = value; // 在 detail 中记录原始的 code
value = wrapCode(value);
}
field.setValue(value, detail);
},
false,
) as FormItemComponentProps;
status: field.error ? 'error' : undefined,
placeholder,
options: setterOptions,
};
baseComponentProps = clone(baseComponentProps, false);
let expProps = {};
// FIXME: 重新考虑这段代码的位置,外置这个逻辑
if (
['expressionSetter', 'expSetter', 'actionSetter', 'eventSetter'].includes(setter) ||
isVariable
['codeSetter', 'expressionSetter', 'expSetter', 'actionSetter', 'eventSetter'].includes(
setter,
)
) {
expProps = {
modalTitle: title,
@@ -133,10 +243,10 @@ export function createFormItem(options: IFormItemCreateOptions) {
const getSetterProps = getSetterPropsProp || defaultGetSetterProps;
// 从注册表中获取 expSetter
const ExpressionSetter = REGISTERED_FORM_ITEM_MAP['expressionSetter']?.config?.component;
const CodeSetter = REGISTERED_FORM_ITEM_MAP['codeSetter']?.config?.component;
const setterNode = isVariable ? (
<ExpressionSetter {...expProps} {...baseComponentProps} />
const setterNode = isCodeSetter ? (
<CodeSetter {...expProps} {...baseComponentProps} />
) : (
renderSetter({
...expProps,
@@ -150,9 +260,13 @@ export function createFormItem(options: IFormItemCreateOptions) {
if (noStyle) {
// 无样式模式
return getVisible(model) ? setterNode : <div data-setter={setterName} data-field={name} />;
return getVisible(model) ? setterNode : <div data-setter={setter} data-field={name} />;
}
const showToggleCodeButton = getShowToggleCodeButton(
disableSwitchExpressionSetter || disableVariableSetter,
);
return (
<FormControl
visible={getVisible(model)}
@@ -160,31 +274,21 @@ export function createFormItem(options: IFormItemCreateOptions) {
note={showItemSubtitle ? name : null}
tip={tip}
docs={docs}
deprecated={deprecated}
error={field.error}
extra={
<Box>
{extra}
{!disableVariableSetter ? (
<ToggleButton
borderRadius="s"
size="s"
shape="outline"
type="primary"
tooltip={isVariable ? '关闭 JS 表达式' : '使用 JS 表达式'}
tooltipPlacement="left"
selected={isVariable}
onClick={() => toggleIsVariable()}
>
<CodeOutlined />
</ToggleButton>
{showToggleCodeButton ? (
<ToggleCodeButton selected={isCodeSetter} onToggle={toggleSetter} />
) : null}
</Box>
}
footer={footer}
data-setter={setterName}
data-setter={setter}
data-field={name}
>
{setterNode}
<ErrorBoundary>{setterNode}</ErrorBoundary>
</FormControl>
);
}
@@ -198,14 +302,24 @@ export function createFormItem(options: IFormItemCreateOptions) {
// 已注册的 setter 查找表
const REGISTERED_FORM_ITEM_MAP: Record<string, ReturnType<typeof createFormItem>> = {};
/**
* 获取已注册的 setter
* @param name
* @returns
*/
export function getSetter(name: string) {
return REGISTERED_FORM_ITEM_MAP[name];
}
/**
* Setter 注册
* @param config 注册选项
*/
export function register(config: IFormItemCreateOptions) {
const names = [config.name, ...(config.alias ?? [])];
names.forEach((name) => {
REGISTERED_FORM_ITEM_MAP[name] = createFormItem(config);
// 允许直接覆盖同名 setter
REGISTERED_FORM_ITEM_MAP[config.name] = createFormItem(config);
(Array.isArray(config.alias) ? config.alias : []).forEach((alias) => {
REGISTERED_FORM_ITEM_MAP[alias] = getSetter(config.name);
});
}
@@ -213,13 +327,13 @@ export function SettingFormItem(props: FormItemProps) {
const { setter } = props;
const Comp = REGISTERED_FORM_ITEM_MAP[setter];
if (Comp == null) {
const Fallback = REGISTERED_FORM_ITEM_MAP.expressionSetter;
const Fallback = REGISTERED_FORM_ITEM_MAP.codeSetter;
return (
<Fallback
{...props}
footer={
<Text color="red" mt="m">
{props.setter} is invalid
<Text color="#faad14" mt="m">
invalid {props.setter}, fallback to codeSetter
</Text>
}
/>

View File

@@ -1,6 +1,6 @@
import { makeObservable, observable, computed, action, toJS } from 'mobx';
import { SetterOnChangeDetailType } from '@music163/tango-helpers';
import { observableGetIn, observableSetIn } from './helpers';
import { ISetterOnChangeCallbackDetail } from './types';
export interface IFormModel {
values: any;
@@ -171,7 +171,7 @@ export class Field {
state: FieldStateType = {};
detail: SetterOnChangeDetailType = {};
detail: ISetterOnChangeCallbackDetail = {};
get value() {
return this.parent.getValue(this.name);
@@ -195,7 +195,7 @@ export class Field {
error: computed,
validate: action,
handleBlur: action,
handleChange: action,
setValue: action,
});
}
@@ -234,11 +234,11 @@ export class Field {
this.state.validating = false;
this.state.error = error;
// 校验错误,不触发 onChange
if (error) {
return error;
}
// 校验错误,不触发 onChange
this.parent?.onChange?.(this.name, this.value, this);
}),
);
@@ -248,7 +248,7 @@ export class Field {
return this.validate('blur');
};
handleChange = (nextValue: any, valueDetail: any) => {
setValue = (nextValue: any, valueDetail: any) => {
this.detail = valueDetail;
this.value = nextValue;
return this.validate('change');

View File

@@ -1,13 +1,14 @@
import React from 'react';
import { Box } from 'coral-system';
import { observer } from 'mobx-react-lite';
import { ComponentPropType } from '@music163/tango-helpers';
import { SettingFormItem } from './form-item';
import { IComponentProp, isString, wrapCode } from '@music163/tango-helpers';
import { SettingFormItem, useSetterValue } from './form-item';
import { FormModelProvider, useFormModel } from './context';
import { FormControlGroup } from './form-ui';
import { FormControlGroup, ToggleCodeButton } from './form-ui';
import { isValidNestProps } from './helpers';
import { CodeSetter } from './setters';
export type SettingFormObjectProps = ComponentPropType;
export type SettingFormObjectProps = IComponentProp;
const defaultGetVisible = () => true;
@@ -27,6 +28,12 @@ export const SettingFormObject = observer(
const parent = useFormModel();
const visible = getVisible(parent);
const subModel = parent.getSubModel(name);
const subModelValue = subModel.values || defaultValue;
const forceCodeSetter = isString(subModelValue);
const { setterValue, isCodeSetter, toggleSetter } = useSetterValue({
fieldValue: subModelValue,
forceCodeSetter, // TODO: 最好是在内部 code2value 失败,而不是在这里强制设置
});
return (
<FormModelProvider value={subModel}>
<Box className="FormObject" display={visible ? 'block' : 'none'}>
@@ -41,13 +48,31 @@ export const SettingFormObject = observer(
parent.setValue(name, nextValue);
parent.onChange(name, nextValue); // 非 Field 发起, 主动调一次
}}
extra={
<ToggleCodeButton
confirm={forceCodeSetter}
selected={isCodeSetter}
onToggle={toggleSetter}
/>
}
>
{props.map((prop) => {
if (isValidNestProps(prop.props)) {
return <SettingFormObject key={prop.name} {...prop} />;
}
return <SettingFormItem key={prop.name} {...prop} />;
})}
{isCodeSetter ? (
<CodeSetter
value={setterValue}
onChange={(val) => {
const nextVal = val ? wrapCode(val) : undefined;
parent.setValue(name, nextVal);
parent.onChange(name, nextVal); // 非 Field 发起, 主动调一次
}}
/>
) : (
props.map((prop) => {
if (isValidNestProps(prop.props)) {
return <SettingFormObject key={prop.name} {...prop} />;
}
return <SettingFormItem key={prop.name} {...prop} />;
})
)}
</FormControlGroup>
</Box>
</FormModelProvider>

View File

@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import { css, Box, HTMLCoralProps, Text } from 'coral-system';
import { Checkbox, Tooltip } from 'antd';
import { CollapsePanel } from '@music163/tango-ui';
import { isNil, isString } from '@music163/tango-helpers';
import { css, Box, HTMLCoralProps, Link } from 'coral-system';
import { Checkbox, Popconfirm, Tooltip } from 'antd';
import { CodeOutlined, CollapsePanel, ToggleButton } from '@music163/tango-ui';
import { isString } from '@music163/tango-helpers';
import { WarningOutlined } from '@ant-design/icons';
export interface FormControlProps extends Omit<FormLabelProps, 'type'> {
visible: boolean;
@@ -19,6 +20,7 @@ export function FormControl({
tip,
docs,
extra,
deprecated,
footer,
error,
children,
@@ -27,13 +29,13 @@ export function FormControl({
return (
<Box className="FormControl" display={visible ? 'block' : 'none'} {...rest}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb="s">
<FormLabel label={label} note={note} tip={tip} docs={docs} />
<FormLabel label={label} note={note} tip={tip} docs={docs} deprecated={deprecated} />
{extra}
</Box>
<Box>{children}</Box>
{footer}
{!!error && (
<Box mt="m" color="red">
<Box mt="m" color="red" fontSize="12px">
{error}
</Box>
)}
@@ -60,10 +62,6 @@ export interface FormControlGroupProps extends FormLabelProps {
* 是否勾选
*/
checked?: boolean;
/**
* checkbox 默认是否勾选
*/
defaultChecked?: boolean;
}
export function FormControlGroup({
@@ -76,14 +74,15 @@ export function FormControlGroup({
children,
onCheck,
checked,
defaultChecked = false,
}: FormControlGroupProps) {
const [collapsed, setCollapsed] = useState(isNil(checked) ? !defaultChecked : !checked);
// 默认折叠
const [collapsed, setCollapsed] = useState(true);
return (
<CollapsePanel
collapsed={collapsed}
onCollapse={setCollapsed}
className="FormControlGroup"
stickyHeader
title={
<Box display="flex" columnGap="s">
<Checkbox
@@ -95,7 +94,7 @@ export function FormControlGroup({
onCheck?.(nextChecked);
}}
/>
<FormLabel type="secondary" label={label} note={note} tip={tip} docs={docs} />
<FormLabel label={label} note={note} tip={tip} docs={docs} />
</Box>
}
extra={
@@ -107,6 +106,7 @@ export function FormControlGroup({
borderColor="line.normal"
headerProps={{
bg: 'fill1',
zIndex: 2,
}}
>
<Box
@@ -138,21 +138,16 @@ const labelStyle = css`
`;
const tipStyle = css`
a:link {
color: #fff;
a {
text-decoration: underline;
}
a:hover {
color: var(--tango-colors-brand);
a:link {
color: #fff;
}
`;
interface FormLabelProps extends HTMLCoralProps<'div'> {
/**
* 类型
*/
// eslint-disable-next-line react/no-unused-prop-types
type?: 'normal' | 'secondary';
/**
* 标签
*/
@@ -169,34 +164,48 @@ interface FormLabelProps extends HTMLCoralProps<'div'> {
* 文档地址
*/
docs?: string;
/**
* 是否显示废弃标记
*/
// eslint-disable-next-line react/no-unused-prop-types
deprecated?: boolean | string;
}
function FormLabel({ type = 'normal', label, note, tip, docs, ...rest }: FormLabelProps) {
const help = docs ? (
<Box css={tipStyle}>
<Text mr="m">{tip}</Text>
<a href={docs} target="_blank" rel="noreferrer">
</a>
</Box>
) : (
tip
);
const labelColor = {
normal: 'text.body',
secondary: 'text.note',
}[type];
function FormLabel({ label, note, tip, docs, deprecated, ...rest }: FormLabelProps) {
let help: React.ReactNode;
if (deprecated || docs) {
help = (
<Box>
<Box css={tipStyle}>
{tip}
{docs ? (
<Link href={docs} isExternal ml="m">
</Link>
) : null}
</Box>
{deprecated ? (
<Box color="#faad14">
<WarningOutlined />
{isString(deprecated) ? deprecated : '该属性已废弃,请谨慎使用。'}
</Box>
) : null}
</Box>
);
} else {
help = tip;
}
let labelNode = (
<Box
as="span"
display="inline-block"
color={labelColor}
color="text2"
className={help ? 'hasHelp' : ''}
css={labelStyle}
title={isString(label) ? label : undefined}
>
{deprecated ? <WarningOutlined style={{ color: '#faad14', marginRight: 4 }} /> : null}
{label}
</Box>
);
@@ -209,7 +218,7 @@ function FormLabel({ type = 'normal', label, note, tip, docs, ...rest }: FormLab
<Box fontSize="14px" {...rest}>
{labelNode}
{note && (
<Box as="i" display="inline-block" fontSize="12px" color="text.note" ml="m">
<Box as="i" display="inline-block" fontSize="12px" color="text3" ml="m">
{note}
</Box>
)}
@@ -240,15 +249,86 @@ interface FormHeaderProps {
export function FormHeader({ title, extra, subTitle }: FormHeaderProps) {
return (
<Box className="FormHeader">
<Box display="flex" alignItems="center">
<Box flex="1" display="flex" alignItems="center">
<Box fontSize="16px" fontWeight="500" mr="s">
<Box display="flex" alignItems="center" className="FormHeaderMain">
<Box flex="1" display="flex" alignItems="center" className="FormHeaderMainBody">
<Box
fontSize="16px"
fontWeight="500"
mr="s"
whiteSpace="nowrap"
className="FormHeaderTitle"
textOverflow="ellipsis"
overflow="hidden"
>
{title}
</Box>
{subTitle && <Box>{subTitle}</Box>}
{subTitle && <Box className="FormHeaderSubTitle">{subTitle}</Box>}
</Box>
{extra && <Box>{extra}</Box>}
{extra && <Box className="FormHeaderExtra">{extra}</Box>}
</Box>
</Box>
);
}
export interface ToggleCodeButtonProps {
/**
* 在反选时是否需要用户确认操作
*/
confirm?: boolean;
selected: boolean;
onToggle: () => void;
}
export function ToggleCodeButton({ confirm, selected, onToggle }: ToggleCodeButtonProps) {
if (confirm && selected) {
return (
<Popconfirm
title="当前代码在切换模式后可能会解析失败,是否确认切换?"
color="#fff2f0"
onConfirm={(e) => {
e.stopPropagation();
onToggle?.();
}}
onCancel={(e) => {
e.stopPropagation();
}}
>
<div
onClick={(e) => {
e.stopPropagation();
}}
>
<ToggleButton
borderRadius="s"
size="s"
shape="text"
type="primary"
tooltip={selected ? undefined : '使用 JS 表达式'}
tooltipPlacement="left"
selected={selected}
>
<CodeOutlined />
</ToggleButton>
</div>
</Popconfirm>
);
}
return (
<ToggleButton
borderRadius="s"
size="s"
shape="text"
type="primary"
tooltip={selected ? '关闭 JS 表达式' : '使用 JS 表达式'}
tooltipPlacement="left"
selected={selected}
onClick={(e) => {
e.stopPropagation();
onToggle?.();
}}
>
<CodeOutlined />
</ToggleButton>
);
}

View File

@@ -2,8 +2,8 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { Box, css } from 'coral-system';
import { Empty } from 'antd';
import {
ComponentPrototypeType,
ComponentPropType,
IComponentPrototype,
IComponentProp,
noop,
filterTreeData,
mapTreeData,
@@ -14,26 +14,30 @@ import { FormModelProvider, FormVariableProvider } from './context';
import { FormModel, FormModelOptionsType } from './form-model';
import { SettingFormObject } from './form-object';
import { isValidNestProps } from './helpers';
import { registerBuiltinSetters } from './setter';
import { registerBuiltinSetters } from './setters/register';
import { FormHeader } from './form-ui';
import { QuestionCircleOutlined } from '@ant-design/icons';
registerBuiltinSetters();
function normalizeComponentProps(props: ComponentPrototypeType['props']) {
const groups: Record<string, ComponentPropType[]> = {};
function normalizeComponentProps(
props: IComponentPrototype['props'],
groupOptions: IFormTabsGroupOption[],
) {
const groups: Record<string, IComponentProp[]> = groupOptions.reduce((prev, cur) => {
prev[cur.value] = [];
return prev;
}, {});
props.forEach((prop) => {
const group = prop.group || 'basic';
if (!groups[group]) {
groups[group] = [];
}
const group = groups[prop.group] ? prop.group : 'basic';
groups[group].push(prop);
});
return groups;
}
function filterComponentProps(props: ComponentPrototypeType['props'], keyword: string) {
function filterComponentProps(props: IComponentPrototype['props'], keyword: string) {
if (!keyword) {
return props;
}
@@ -47,7 +51,9 @@ function filterComponentProps(props: ComponentPrototypeType['props'], keyword: s
const formStyle = css`
position: relative;
height: 100%;
display: flex;
flex-direction: column;
> .SettingFormMain > :first-child:is(.FormControl) {
margin-top: var(--tango-space-m);
}
@@ -69,7 +75,7 @@ interface IFormTabsGroupOption {
}
const internalGroups: IFormTabsGroupOption[] = [
{ label: '常用', value: 'basic' },
{ label: '基本', value: 'basic' },
{ label: '事件', value: 'event' },
{ label: '样式', value: 'style' },
{ label: '高级', value: 'advanced' },
@@ -91,14 +97,15 @@ export interface SettingFormProps {
/**
* 组件的可配置描述
*/
prototype?: ComponentPrototypeType;
prototype?: IComponentPrototype;
/**
* 是否显示组件标识
*/
showIdentifier?:
| false
| {
identifierKey: string;
identifierKey: string; // 唯一标识符属性key
getIdentifier?: () => string; // 获取唯一标识符的方法
};
/**
* 是否显示搜索框
@@ -108,6 +115,10 @@ export interface SettingFormProps {
* 是否显示分组导航
*/
showGroups?: boolean;
/**
* 选项分组信息,用于对表单项进行分组展示,如未提供,则使用内置的分组信息
*/
groupOptions?: IFormTabsGroupOption[];
/**
* 是否显示表单项的副标题
*/
@@ -115,13 +126,9 @@ export interface SettingFormProps {
/**
* 自定义渲染表单项的额外内容(标签右侧)
*/
renderItemExtra?: (props: ComponentPropType) => React.ReactNode;
renderItemExtra?: (props: IComponentProp) => React.ReactNode;
/**
* 选项分组信息,用于对表单项进行分组展示,如未提供,则使用内置的分组信息
*/
groupOptions?: IFormTabsGroupOption[];
/**
* 是否允许表单项切换到表达式设置器
* 是否禁用 codeSetter 切换,默认所有的 setter 都支持切换到 codeSetter
*/
disableSwitchExpressionSetter?: boolean;
}
@@ -144,8 +151,8 @@ export function SettingForm({
const [model, setModel] = useState(modelProp ?? new FormModel(defaultValue, { onChange }));
const { props: componentProps = [] } = prototype;
const componentPropGroups = useMemo(() => {
return normalizeComponentProps(componentProps);
}, [componentProps]);
return normalizeComponentProps(componentProps, groupOptions);
}, [componentProps, groupOptions]);
const filterProps = useMemo(() => {
const computedProps = mapTreeData(componentProps, (node) => {
@@ -159,7 +166,15 @@ export function SettingForm({
// 没有进行搜索的情况
if (!keyword) {
if (showGroups) {
return componentPropGroups[tabKey] || [];
return (
componentPropGroups[tabKey].sort((a, b) => {
// TIP: 将废弃属性往后移
if (!a.deprecated && b.deprecated) {
return -1;
}
return 1;
}) || []
);
} else {
return computedProps;
}
@@ -170,7 +185,7 @@ export function SettingForm({
}, [keyword, componentProps, tabKey, model, showGroups, componentPropGroups]);
const renderProps = useCallback(
(list: ComponentPropType[]) =>
(list: IComponentProp[]) =>
list.map((item) => {
const { getProp, ...rest } = item;
const childProp = {
@@ -199,15 +214,18 @@ export function SettingForm({
<FormVariableProvider value={{ disableSwitchExpressionSetter, showItemSubtitle }}>
<FormModelProvider value={model}>
<Box className="SettingForm" mb="xl" css={formStyle}>
<Box className="SettingFormHeader" position="sticky" top="0" bg="white" zIndex="2">
<Box className="SettingFormHeader" bg="white">
<Box px="l" py="m">
{showIdentifier && (
<FormHeader
title={prototype.title}
title={prototype.title || prototype.name}
subTitle={
<SettingFormItem
noStyle
setter="idSetter"
setterProps={{
getId: showIdentifier.getIdentifier,
}}
name={showIdentifier.identifierKey}
/>
}
@@ -217,6 +235,7 @@ export function SettingForm({
icon={<QuestionCircleOutlined />}
tooltip="查看组件文档"
href={prototype.docs}
size="small"
/>
) : null
}
@@ -240,14 +259,21 @@ export function SettingForm({
<Tabs.TabPane
key={group.value}
tab={group.label}
disabled={!componentPropGroups[group.value]?.length}
disabled={!componentPropGroups[group.value].length}
/>
))}
</Tabs>
</Box>
)}
</Box>
<Box className="SettingFormMain" display="flex" flexDirection="column" rowGap="m">
<Box
className="SettingFormMain"
display="flex"
flexDirection="column"
paddingBottom="m"
rowGap="m"
overflow="auto"
>
{renderProps(filterProps)}
{filterProps.length === 0 && (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到配置项" />

View File

@@ -3,3 +3,4 @@ export * from './form-item';
export * from './form-object';
export * from './form-model';
export * from './context';
export * from './setters';

View File

@@ -1,90 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Input, InputProps } from 'antd';
import { InputCode } from '@music163/tango-ui';
import { FormItemComponentProps, register } from './form-item';
import { Box, css } from 'coral-system';
export function ExpressionSetter({ value, onChange, ...rest }: FormItemComponentProps<string>) {
return <InputCode onChange={(val) => onChange?.(val)} value={value || ''} {...rest} />;
}
export function TextSetter({ onChange, ...rest }: FormItemComponentProps<string>) {
return <Input onChange={(e) => onChange?.(e.target.value)} {...rest} />;
}
const idInputStyle = css`
> .ant-input-borderless {
border: 1px solid transparent;
padding: 4px;
}
> .ant-input-borderless:hover {
background-color: var(--tango-colors-fill1);
}
`;
const idPattern = /^[a-z]+[\w]*$/;
export function IdSetter({
value: valueProp,
defaultValue,
onChange,
placeholder = '输入唯一组件ID',
...rest
}: FormItemComponentProps<string>) {
const [value, setValue] = useState(valueProp || defaultValue);
const [error, setError] = useState('');
const [editable, setEditable] = useState(false);
// value controlled
useEffect(() => {
setValue(valueProp);
}, [valueProp]);
const __props: InputProps = editable
? {
status: error ? 'error' : undefined,
onBlur() {
setEditable(false);
if (!error) {
onChange?.(value || undefined);
}
},
onChange(e) {
const newValue = e.target.value;
setValue(e.target.value);
setError(newValue && !idPattern.test(newValue) ? 'error' : '');
},
}
: {
readOnly: true,
bordered: false,
onClick() {
setEditable(true);
},
};
return (
<Box css={idInputStyle}>
<Input placeholder={placeholder} value={value} {...__props} {...rest} />
</Box>
);
}
export function registerBuiltinSetters() {
// 预注册基础 Setter
register({
name: 'expressionSetter',
component: ExpressionSetter,
});
register({
name: 'textSetter',
component: TextSetter,
});
register({
name: 'idSetter',
component: IdSetter,
});
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Switch } from 'antd';
import { FormItemComponentProps } from '@music163/tango-setting-form/src/form-item';
import { FormItemComponentProps } from '../form-item';
export function BoolSetter({ value, onChange, ...props }: FormItemComponentProps<boolean>) {
return <Switch checked={value} onChange={(val) => onChange?.(val)} {...props} />;

View File

@@ -0,0 +1,23 @@
import React, { useState } from 'react';
import { FormItemComponentProps } from '../form-item';
import { InputCode } from '@music163/tango-ui';
export function CodeSetter({
value: valueProp,
onChange,
...rest
}: FormItemComponentProps<string>) {
const [value, setValue] = useState(valueProp || '');
return (
<InputCode
onChange={(val) => {
setValue(val);
}}
onBlur={() => {
onChange?.(value);
}}
value={value}
{...rest}
/>
);
}

View File

@@ -0,0 +1,100 @@
import React, { useEffect, useState } from 'react';
import { Input, InputProps, Tooltip } from 'antd';
import { css, Box } from 'coral-system';
import { FormItemComponentProps } from '../form-item';
import { ExclamationCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { Action } from '@music163/tango-ui';
const idInputStyle = css`
> .ant-input-borderless {
border: 1px solid transparent;
padding: 4px;
}
> .ant-input-borderless:hover {
background-color: var(--tango-colors-fill1);
}
`;
const idPattern = /^[a-z]+[\w]*$/;
export function IdSetter({
value: valueProp,
defaultValue,
onChange,
placeholder = '输入唯一组件ID',
getId,
...rest
}: FormItemComponentProps<string>) {
const [value, setValue] = useState(valueProp || defaultValue);
const [error, setError] = useState('');
const [editable, setEditable] = useState(false);
// value controlled
useEffect(() => {
setValue(valueProp);
}, [valueProp]);
const __props: InputProps = editable
? {
status: error ? 'error' : undefined,
onBlur() {
setEditable(false);
if (!error) {
onChange?.(value || undefined);
}
},
onChange(e) {
const newValue = e.target.value;
setValue(e.target.value);
setError(
newValue && !idPattern.test(newValue)
? '非法的组件ID必须使用字母开头的字母数字组合例如 button1'
: '',
);
},
}
: {
readOnly: true,
bordered: false,
onClick() {
setEditable(true);
},
};
const showQuickIdButton = !value || error; // 没有设置 id 或 id 不合法的时候显示快捷生成按钮
return (
<Box css={idInputStyle}>
<Input
placeholder={placeholder}
value={value}
{...__props}
{...rest}
prefix={
error ? (
<Tooltip title={error} color="#ff4d4f">
<ExclamationCircleOutlined style={{ color: 'red' }} />
</Tooltip>
) : null
}
suffix={
showQuickIdButton ? (
<Action
size="small"
icon={<ThunderboltOutlined />}
tooltip="快捷设置组件ID"
onClick={() => {
const id = getId?.();
if (id) {
setValue(id);
onChange?.(id);
}
}}
/>
) : null
}
/>
</Box>
);
}

View File

@@ -0,0 +1,6 @@
export * from './bool-setter';
export * from './code-setter';
export * from './id-setter';
export * from './number-setter';
export * from './text-setter';
export * from './register';

View File

@@ -1,12 +1,12 @@
import React from 'react';
import { InputNumber, InputNumberProps, Slider } from 'antd';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { InputNumber, Slider } from 'antd';
import { FormItemComponentProps } from '../form-item';
const style = {
width: '100%',
};
export function NumberSetter({ onChange, ...props }: InputNumberProps) {
export function NumberSetter({ onChange, ...props }: FormItemComponentProps<number>) {
return (
<InputNumber
placeholder="请输入数字"

View File

@@ -0,0 +1,55 @@
import { CodeSetter } from './code-setter';
import { BoolSetter } from './bool-setter';
import { IdSetter } from './id-setter';
import { TextAreaSetter, TextSetter } from './text-setter';
import { NumberSetter, SliderSetter } from './number-setter';
import { IFormItemCreateOptions, register } from '../form-item';
const BASIC_SETTERS: IFormItemCreateOptions[] = [
{
name: 'codeSetter',
alias: ['expSetter', 'expressionSetter'],
component: CodeSetter,
type: 'code',
},
{
name: 'textSetter',
component: TextSetter,
},
{
name: 'textAreaSetter',
component: TextAreaSetter,
},
{
name: 'boolSetter',
component: BoolSetter,
},
{
name: 'numberSetter',
component: NumberSetter,
},
{
name: 'sliderSetter',
component: SliderSetter,
},
{
name: 'idSetter',
component: IdSetter,
},
];
let registered = false;
/**
* 注册内置的基础类型 Setter
*/
export function registerBuiltinSetters() {
if (registered) {
// 防止重复注册
return;
}
// 预注册基础 Setter
BASIC_SETTERS.forEach(register);
registered = true;
}

View File

@@ -1,20 +1,15 @@
import React, { useEffect, useState } from 'react';
import { Input, InputProps } from 'antd';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { Input } from 'antd';
import { FormItemComponentProps } from '../form-item';
const noop = () => {};
interface TextSetterProps extends Omit<InputProps, 'onChange' | 'value'> {
value?: string;
onChange?: (value: string) => void;
}
export function TextSetter({
value: valueProp,
onChange = noop,
placeholder = '请输入',
placeholder = '请输入文本',
...props
}: TextSetterProps) {
}: FormItemComponentProps<string>) {
const [valueState, setValue] = useState(valueProp);
useEffect(() => {
@@ -28,7 +23,9 @@ export function TextSetter({
value={valueState}
onChange={(e) => setValue(e.target.value)}
onBlur={() => {
onChange(valueState);
if (valueState !== valueProp) {
onChange(valueState);
}
}}
{...props}
/>
@@ -43,7 +40,7 @@ const autoSize = {
export function TextAreaSetter({
value: valueProp,
onChange = noop,
placeholder = '请输入',
placeholder = '请输入文本',
...props
}: FormItemComponentProps<string>) {
const [valueState, setValue] = useState(valueProp);
@@ -59,7 +56,9 @@ export function TextAreaSetter({
value={valueState}
onChange={(e) => setValue(e.target.value)}
onBlur={() => {
onChange(valueState);
if (valueState !== valueProp) {
onChange(valueState);
}
}}
autoSize={autoSize}
{...props}

View File

@@ -0,0 +1,4 @@
export interface ISetterOnChangeCallbackDetail {
relatedImports?: string[];
rawCode?: string;
}

Some files were not shown because too many files have changed in this diff Show More