前面实现了内置的几个动作,这节来实现下自定义 JS。
比如 amis:
它就支持通过代码来自定义动作。
而且自定义 JS 可以拿到 doAction 方法来执行其他动作:
可以通过 context 拿到组件信息。
我们也来实现下。
创建 Setting/actions/CustomJS.tsx
import { useState } from "react";
import { useComponetsStore } from "../../../stores/components";
import MonacoEditor, { OnMount } from "@monaco-editor/react";
export interface CustomJSConfig {
type: "customJS";
code: string;
}
export interface CustomJSProps {
defaultValue?: string;
onChange?: (config: CustomJSConfig) => void;
}
export function CustomJS(props: CustomJSProps) {
const { defaultValue, onChange } = props;
const { curComponentId } = useComponetsStore();
const [value, setValue] = useState(defaultValue);
function codeChange(value?: string) {
if (!curComponentId) return;
setValue(value);
onChange?.({
type: "customJS",
code: value!,
});
}
const handleEditorMount: OnMount = (editor, monaco) => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyJ, () => {
editor.getAction("editor.action.formatDocument")?.run();
});
};
return (
<div className="mt-[40px]">
<div className="flex items-start gap-[20px]">
<div>自定义 JS</div>
<div>
<MonacoEditor
width={"600px"}
height={"400px"}
path="action.js"
language="javascript"
onMount={handleEditorMount}
onChange={codeChange}
value={value}
options={{
fontSize: 14,
scrollBeyondLastLine: false,
minimap: {
enabled: false,
},
scrollbar: {
verticalScrollbarSize: 6,
horizontalScrollbarSize: 6,
},
}}
/>
</div>
</div>
</div>
);
}
和其他动作表单不同的是这里用 monaco editor。
然后在 ActionModal 里用一下:
切换自定义 JS 的 tab 时,渲染 CustomJS 组件。
顺便把类型也改一下,加上 CustomJSConfig 的类型
import { Modal, Segmented } from "antd";
import { useState } from "react";
import { GoToLink, GoToLinkConfig } from "./actions/GoToLink";
import { ShowMessage, ShowMessageConfig } from "./actions/ShowMessage";
import { CustomJS, CustomJSConfig } from "./actions/CustomJS";
export interface ActionModalProps {
visible: boolean;
handleOk: (config?: ActionConfig) => void;
handleCancel: () => void;
}
export type ActionConfig = GoToLinkConfig | ShowMessageConfig | CustomJSConfig;
export function ActionModal(props: ActionModalProps) {
const { visible, handleOk, handleCancel } = props;
const [key, setKey] = useState<string>("访问链接");
const [curConfig, setCurConfig] = useState<ActionConfig>();
return (
<Modal
title="事件动作配置"
width={800}
open={visible}
okText="确认"
cancelText="取消"
onOk={() => handleOk(curConfig)}
onCancel={handleCancel}>
<div className="h-[500px]">
<Segmented
value={key}
onChange={setKey}
block
options={["访问链接", "消息提示", "自定义 JS"]}
/>
{key === "访问链接" && (
<GoToLink
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
{key === "消息提示" && (
<ShowMessage
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
{key === "自定义 JS" && (
<CustomJS
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
</div>
</Modal>
);
}
ComponentEvent 里渲染的时候也支持 customJS,并改下 ts 类型:
import { Collapse, Input, Select, CollapseProps, Button } from "antd";
import { useComponetsStore } from "../../stores/components";
import { useComponentConfigStore } from "../../stores/component-config";
import type { ComponentEvent } from "../../stores/component-config";
import { ActionConfig, ActionModal } from "./ActionModal";
import { useState } from "react";
import { DeleteOutlined } from "@ant-design/icons";
export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } =
useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [actionModalOpen, setActionModalOpen] = useState(false);
const [curEvent, setCurEvent] = useState<ComponentEvent>();
if (!curComponent) return null;
function deleteAction(event: ComponentEvent, index: number) {
if (!curComponent) {
return;
}
const actions = curComponent.props[event.name]?.actions;
actions.splice(index, 1);
updateComponentProps(curComponent.id, {
[event.name]: {
actions: actions,
},
});
}
const items: CollapseProps["items"] = (
componentConfig[curComponent.name].events || []
).map((event) => {
return {
key: event.name,
label: (
<div className="flex justify-between leading-[30px]">
{event.label}
<Button
type="primary"
onClick={(e) => {
e.stopPropagation();
setCurEvent(event);
setActionModalOpen(true);
}}>
添加动作
</Button>
</div>
),
children: (
<div>
{(curComponent.props[event.name]?.actions || []).map(
(item: ActionConfig, index: number) => {
return (
<div>
{item.type === "goToLink" ? (
<div
key="goToLink"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
跳转链接
</div>
<div>{item.url}</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
{item.type === "showMessage" ? (
<div
key="showMessage"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
消息弹窗
</div>
<div>{item.config.type}</div>
<div>{item.config.text}</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
{item.type === "customJS" ? (
<div
key="customJS"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
自定义 JS
</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
</div>
);
}
)}
</div>
),
};
});
function handleModalOk(config?: ActionConfig) {
if (!config || !curEvent || !curComponent) {
return;
}
updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: [
...(curComponent.props[curEvent.name]?.actions || []),
config,
],
},
});
setActionModalOpen(false);
}
return (
<div className="px-[10px]">
<Collapse
className="mb-[10px]"
items={items}
defaultActiveKey={componentConfig[
curComponent.name
].events?.map((item) => item.name)}
/>
<ActionModal
visible={actionModalOpen}
handleOk={handleModalOk}
handleCancel={() => {
setActionModalOpen(false);
}}
/>
</div>
);
}
测试下:
动作添加成功。
在 json 里可以看到这个配置:
接下来只要 Preview 的时候实现这种 action 的执行就好了。
支持 customJS 的 action 执行,顺便改下类型。
props[event.name] = () => {
eventConfig?.actions?.forEach((action: ActionConfig) => {
if (action.type === "goToLink") {
window.location.href = action.url;
} else if (action.type === "showMessage") {
if (action.config.type === "success") {
message.success(action.config.text);
} else if (action.config.type === "error") {
message.error(action.config.text);
}
} else if (action.type === "customJS") {
const func = new Function(action.code);
func();
}
});
};
测试下:
这样就实现了自定义 JS 的执行。
然后给执行的函数加上一些参数:
new Function 可以传入任意个参数,最后一个是函数体,前面都会作为函数参数的名字。
然后调用的时候传入参数。
我们这里只传入了当前组件的 name、props 还有一个方法。
const func = new Function("context", action.code);
func({
name: component.name,
props: component.props,
showMessage(content: string) {
message.success(content);
},
});
测试下:
这样,自定义 JS 的功能就完成了。
但现在有个问题:
我们上节做了动作的新增、删除,并没有做编辑。
这对于跳转链接、消息弹窗这种动作还好,参数比较简单。
但是对于自定义 JS,写一段 JS 成本还是挺高的,删了再重写体验不好,所以我们得支持下编辑。
改下 ComponentEvent 组件:
<div
style={{ position: "absolute", top: 10, right: 30, cursor: "pointer" }}
onClick={() => editAction(item)}>
<EditOutlined />
</div>
加一个绝对定位的 icon。
点击的时候打开弹窗:
function editAction(config: ActionConfig) {
if (!curComponent) {
return;
}
setActionModalOpen(true);
}
测试下:
能打开弹窗,但是还没回显内容。
在 ActionModal 传入 action 来回显:
import { Modal, Segmented } from "antd";
import { useEffect, useState } from "react";
import { GoToLink, GoToLinkConfig } from "./actions/GoToLink";
import { ShowMessage, ShowMessageConfig } from "./actions/ShowMessage";
import { CustomJS, CustomJSConfig } from "./actions/CustomJS";
export type ActionConfig = GoToLinkConfig | ShowMessageConfig | CustomJSConfig;
export interface ActionModalProps {
visible: boolean;
action?: ActionConfig;
handleOk: (config?: ActionConfig) => void;
handleCancel: () => void;
}
export function ActionModal(props: ActionModalProps) {
const { visible, action, handleOk, handleCancel } = props;
const map = {
goToLink: "访问链接",
showMessage: "消息提示",
customJS: "自定义 JS",
};
const [key, setKey] = useState<string>("访问链接");
const [curConfig, setCurConfig] = useState<ActionConfig>();
useEffect(() => {
if (action?.type) {
setKey(map[action.type]);
}
}, [action]);
return (
<Modal
title="事件动作配置"
width={800}
open={visible}
okText="确认"
cancelText="取消"
onOk={() => handleOk(curConfig)}
onCancel={handleCancel}>
<div className="h-[500px]">
<Segmented
value={key}
onChange={setKey}
block
options={["访问链接", "消息提示", "自定义 JS"]}
/>
{key === "访问链接" && (
<GoToLink
key="goToLink"
defaultValue={
action?.type === "goToLink" ? action.url : ""
}
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
{key === "消息提示" && (
<ShowMessage
key="showMessage"
value={
action?.type === "showMessage"
? action.config
: undefined
}
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
{key === "自定义 JS" && (
<CustomJS
key="customJS"
defaultValue={
action?.type === "customJS" ? action.code : ""
}
onChange={(config) => {
setCurConfig(config);
}}
/>
)}
</div>
</Modal>
);
}
然后在 ComponentEvent 里传入这个参数:
const [curAction, setCurAction] = useState<ActionConfig>();
测试下:
这样,回显就完成了。
然后保存的时候也要处理下:
记录下当前编辑的 action 的 index。
保存的时候如果有 curAction,就是修改,没有的话才是新增。
import { Collapse, Input, Select, CollapseProps, Button } from "antd";
import { useComponetsStore } from "../../stores/components";
import { useComponentConfigStore } from "../../stores/component-config";
import type { ComponentEvent } from "../../stores/component-config";
import { ActionConfig, ActionModal } from "./ActionModal";
import { useState } from "react";
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } =
useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [actionModalOpen, setActionModalOpen] = useState(false);
const [curEvent, setCurEvent] = useState<ComponentEvent>();
const [curAction, setCurAction] = useState<ActionConfig>();
const [curActionIndex, setCurActionIndex] = useState<number>();
if (!curComponent) return null;
function deleteAction(event: ComponentEvent, index: number) {
if (!curComponent) {
return;
}
const actions = curComponent.props[event.name]?.actions;
actions.splice(index, 1);
updateComponentProps(curComponent.id, {
[event.name]: {
actions: actions,
},
});
}
function editAction(config: ActionConfig, index: number) {
if (!curComponent) {
return;
}
setCurAction(config);
setCurActionIndex(index);
setActionModalOpen(true);
}
const items: CollapseProps["items"] = (
componentConfig[curComponent.name].events || []
).map((event) => {
return {
key: event.name,
label: (
<div className="flex justify-between leading-[30px]">
{event.label}
<Button
type="primary"
onClick={(e) => {
e.stopPropagation();
setCurEvent(event);
setActionModalOpen(true);
}}>
添加动作
</Button>
</div>
),
children: (
<div>
{(curComponent.props[event.name]?.actions || []).map(
(item: ActionConfig, index: number) => {
return (
<div>
{item.type === "goToLink" ? (
<div
key="goToLink"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
跳转链接
</div>
<div>{item.url}</div>
<div
style={{
position: "absolute",
top: 10,
right: 30,
cursor: "pointer",
}}
onClick={() =>
editAction(item, index)
}>
<EditOutlined />
</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
{item.type === "showMessage" ? (
<div
key="showMessage"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
消息弹窗
</div>
<div>{item.config.type}</div>
<div>{item.config.text}</div>
<div
style={{
position: "absolute",
top: 10,
right: 30,
cursor: "pointer",
}}
onClick={() =>
editAction(item, index)
}>
<EditOutlined />
</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
{item.type === "customJS" ? (
<div
key="customJS"
className="border border-[#aaa] m-[10px] p-[10px] relative">
<div className="text-[blue]">
自定义 JS
</div>
<div
style={{
position: "absolute",
top: 10,
right: 30,
cursor: "pointer",
}}
onClick={() =>
editAction(item, index)
}>
<EditOutlined />
</div>
<div
style={{
position: "absolute",
top: 10,
right: 10,
cursor: "pointer",
}}
onClick={() =>
deleteAction(event, index)
}>
<DeleteOutlined />
</div>
</div>
) : null}
</div>
);
}
)}
</div>
),
};
});
function handleModalOk(config?: ActionConfig) {
if (!config || !curEvent || !curComponent) {
return;
}
if (curAction) {
updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: curComponent.props[curEvent.name]?.actions.map(
(item: ActionConfig, index: number) => {
return index === curActionIndex ? config : item;
}
),
},
});
} else {
updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: [
...(curComponent.props[curEvent.name]?.actions || []),
config,
],
},
});
}
setCurAction(undefined);
setActionModalOpen(false);
}
return (
<div className="px-[10px]">
<Collapse
className="mb-[10px]"
items={items}
defaultActiveKey={componentConfig[
curComponent.name
].events?.map((item) => item.name)}
/>
<ActionModal
visible={actionModalOpen}
handleOk={handleModalOk}
action={curAction}
handleCancel={() => {
setCurAction(undefined);
setActionModalOpen(false);
}}
/>
</div>
);
}
测试下:
action 的新增和修改正常。
这时候我发现虽然最终保存的是对的,回显的不对:
如上图,我修改下面的 action 的时候,回显的依然是之前的值,但保存是对的。
这是为什么呢?我们不是传了参数了么:
因为我们是用非受控模式写的,传的参数作为表单的默认值:
所以修改 defaultValue 并不会修改表单值。
有回显需求的表单,必须用受控模式来写。
我们改一下:
当传入 value 参数的时候,同步设置内部的 value
测试下:
这样就好了。
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 29562eb568bdc05e4efbdd02ba4f817f47201279
总结
这节我们实现了自定义 JS。
通过 monaco editor 来输入代码,然后通过 new Function 来动态执行代码,执行的代码可以访问 context,传入一些属性方法。
然后我们实现了动作的编辑,点击编辑按钮会在弹窗回显 action,保存之后会修改 json。
主要回显的表单一定是受控模式,这样才可以随时 value,不然只能设置初始值 defaultValue
这样,内置动作、自定义 JS 的动作就都完成了。
