Appsmith 是一款开源低代码框架,主要用于构建管理面板、内部工具和仪表板等,可以拖放 UI 组件来构建页面,通过连接到任何 API、数据库或 GraphQL 源,并使用 JavaScript 语言编写逻辑,从而在短时间内创建内部应用程序。Appsmith提供了一系列常用的组件以供使用,但有些组件功能不够完善,无法满足复杂的需求,我们可以对其进行二次开发,实现满足我们需求的组件。
一、前端开发环境搭建
官方文档:https://github.com/appsmithorg/appsmith/blob/master/contributions/ClientSetup.md
源码地址:https://github.com/appsmithorg/appsmith
1、部署后端
这里我只关注前端开发环境的搭建,所以后端我是直接通过docker来启动了,docker下载地址:https://docs.docker.com/get-docker/,安装成功后依次执行以下命令进行启动:
cd ./appsmith; // 进入项目根目录
rm -rf stacks;
docker pull appsmith/appsmith-ce // 拉取最新镜像
docker run -d --name appsmith -p 8000:80 -p 9001:9001 appsmith/appsmith-ce:latest; // 启动
docker logs -f appsmith; // 查看日志
在docker desktop里可以看到。
启动成功后可以通过http://localhost:8000/来访问。
2、前端开发环境搭建
1)安装node
要求node版本:18.17.1,低于这个版本后续安装依赖会报错。
建议使用node版本管理工具来管理不同版本的node,常用的管理工具:nvm、n、fnm、nvs等。
2)安装yarn
npm install -g yarn
3)将根目录下的 .env.example 文件复制一份,命名为 .env
cp .env.example .env // 复制.env.example,重命名为.env
4)进入app/client目录,这个目录下就是前端工程的源码所在目录。 yarn instal l安装依赖,然后执行 yarn start 启动。启动成功会有如下输出:
注1:此时还无法访问,需要下一步启动nginx。
注2:这里 yarn start 启动时可能会有个报错:Trace: isModuleDeclaration has been deprecated, please migrate to isImportOrExportDeclaration,在package.json中加上 "@babel/types": "^7.21.2" 可以解决这个问题。
5)在app/client目录下执行start-https.sh,启动nginx,如果本地没有nginx需要先安装。
./start-https.sh http://localhost:8000 --http
注:这里官网上用了https,但是比较麻烦,需要本地安装证书,我跳过了这些步骤,直接指定了http。另外http://localhost:8000就是之前启动的后端地址,如果是其他的地址可以在这里更换。
start-https.sh脚本主要就是生成nginx相关配置文件,并用所携带的参数去生成文件的内容,然后启动nginx。
启动成功会有如下输出:
然后就可以通过http://localhost/来访问了。初次访问是一个要求注册的页面,注册后就可以进入使用了。
6)尝试修改一下代码
成功,可以开始本地开发了~
二、Widget开发
Appsmith的widget是用于构建页面的一个个单元,除了展示在页面上的组件本身,还包括一系列用来修改组件外观的属性以及用来进行交互的事件。
1.创建Widget
widget需要放在 /app/client/src/widgets 目录下。官方提供了一个脚手架用来生成对应的目录和文件:
cd ./app/client && yarn generate:widget
执行该命令后会提示输入widget的名字:
该命令会在 /app/client/src/widgets 目录下创建一个以 输入的名称 + Widget后缀 为名的目录。
目录结构如下所示(这里我把目录名改了):
- index.ts:入口文件,导出Widge本身
- constants.ts:定义组件中用到的一些常量
- icon.svg:widget在Appsmith中的展示图标
- widget/index.tsx:widget的核心代码,需要继承Appsmith中的BaseWidget类,并实现其中的一系列方法,以使Appsmith可以正确地渲染组件。其中包括widget的配置以及定义用户在使用组件时可以进行配置的属性。
- component/index.tsx:组件的核心代码。
这些文件包含了一些初始代码,但是这些代码与当前源码中内置Widget的代码有些差异,所以我对这些文件进行了改写,稍后会通过一个具体的实例(封装一个AntDesign的按钮组件)来加以说明。
2.注册Widget
widget需要在 /app/client/src/widgets/index.ts 文件里引入,才能被Appsmith显示在页面上。
// 其他组件
import ButtonWidgetAntd from "./ButtonWidgetAntd";
const Widgets = [
// 其他组件
ButtonWidgetAntd,
]
export default Widgets;
3.开发一个AntDesign按钮Widget
这里封装一个AntDesign的按钮组件,提供按钮类型(primary、default、dashed、text、link)、按钮主题色、按钮文本、是否可见、是否禁用、点击事件供用户配置。
需要安装AntDesign组件库:
yarn add antd
入口文件index.ts
只需要进行导出:
import Widget from "./widget";
export default Widget;
常量定义constants.ts
在这里定义一下按钮类型的枚举常量以供后续使用。
export enum ButtonTypes {
DEFAULT = "default",
PRIMARY = "primary",
DASHED = "dashed",
TEXT = "text",
LINK = "link",
}
Widget代码
widget的核心代码在widget/index.tsx文件中。这里定义了一个ButtonWidgetAntd类,并且继承了BaseWidget,BaseWidget中有很多方法,有一些方法我没有仔细研究,比如用于布局的getAutoLayoutConfig、getAnvilConfig等等。
// widget/index.tsx
class ButtonWidgetAntd extends BaseWidget<ButtonWidgetAntdProps, ButtonWidgetState> {
constructor(props: ButtonWidgetAntdProps) {
super(props);
}
static type = "BUTTON_WIDGET_ANTD";
// ...
}
// 组件属性类型定义
export interface ButtonWidgetAntdProps extends WidgetProps {
buttonType?: ButtonTypes;
buttonPrimaryColor?: string;
isVisible?: boolean;
isDisabled?: boolean;
text?: string;
onClick?: string;
}
interface ButtonWidgetState extends WidgetState {
isLoading: boolean;
}
export default ButtonWidgetAntd;
1)Widget配置
可以在 getConfig 方法中配置widget在Appsmith的Widgets面板中的显示内容,该方法继承自BaseWidget类。
static getConfig() {
return {
name: "ButtonAntd",
iconSVG: IconSVG,
tags: [WIDGET_TAGS.ANTD],
needsMeta: true,
searchTags: ["click", "submit"],
};
}
- name:显示在Widgets面板中的名称。
- iconSVG:显示在Widgets面板中的图标。
- tags:在Widgets面板中的分组,可以在/app/client/src/constants/WidgetConstants.tsx文件的WIDGET_TAGS中添加:
export const WIDGET_TAGS = {
ANTD: "AntDesign",
SUGGESTED_WIDGETS: "Suggested",
INPUTS: "Inputs",
// ...
} as const;
- needsMeta:组件中是否需要存储临时状态。比如这个示例里的isLoading,设置按钮是否处于loading状态,这个示例里它没有暴露在属性面板上由用户去设置,而是在执行按钮点击事件的前后需要在代码中进行设置,所以将其作为state来设置。
- searchTags:Widgets面板中的搜索关键字。
2)配置属性面板Content部分的内容
这部分在 getPropertyPaneContentConfig 方法中配置,下面的代码配置了4个属性:
- text:设置按钮上的文本
- onClick:设置按钮点击事件
- isVisible:控制按钮是否可见
- isDisabled:控制按钮是否禁用
static getPropertyPaneContentConfig() {
return [
{
sectionName: "Basic",
children: [
{
propertyName: "text",
label: "Label",
helpText: "Sets the label of the button",
controlType: "INPUT_TEXT",
placeholderText: "Submit",
isBindProperty: true,
isTriggerProperty: false,
validation: { type: ValidationTypes.TEXT },
},
{
propertyName: "onClick",
label: "onClick",
helpText: "when the button is clicked",
controlType: "ACTION_SELECTOR",
isJSConvertible: true,
isBindProperty: true,
isTriggerProperty: true,
},
],
},
{
sectionName: "General",
children: [
{
propertyName: "isVisible",
label: "Visible",
helpText: "Controls the visibility of the widget",
controlType: "SWITCH",
isJSConvertible: true,
isBindProperty: true,
isTriggerProperty: false,
validation: { type: ValidationTypes.BOOLEAN },
},
{
propertyName: "isDisabled",
label: "Disabled",
controlType: "SWITCH",
helpText: "Disables clicks to this widget",
isJSConvertible: true,
isBindProperty: true,
isTriggerProperty: false,
validation: { type: ValidationTypes.BOOLEAN },
},
],
},
];
}
- propertyName(必须):属性名。
- controlType(必须):录入该属性值时使用的控件类型,常用的有 INPUT_TEXT 、 SWITCH 、 DROP_DOWN 、 DATE_PICKER 、 COLOR_PICKER 、 ACTION_SELECTOR 等。
- 完整的控件列表见/app/client/src/components/propertyControls/index.ts文件。
- isBindProperty(必须):是否绑定属性。
- isTriggerProperty(必须):是否可以触发事件。
- isJSConvertible:是否允许转换为JS录入。
- validation:校验规则。
3)配置属性面板Style部分的内容
这部分在 getPropertyPaneStyleConfig 方法中配置。下面的代码配置了两个属性:
- buttonType:用于在antDesign的按钮组件中指定按钮类型,有5个取值:default、primary、dashed、text、link, controlType: "DROP_DOWN" 设置下拉选择,options设置下拉框中的选项,defaultValue设置下拉框的默认选中值。
- buttonPrimaryColor:用于设置按钮的主题色,这个属性需要在样式表中使用,所以需要在 getStylesheetConfig 方法中进行设置,如果需要初始值也在该方法中设置,这里设置为: {{appsmith.theme.colors.primaryColor}} ,appsmith.theme用来获取Appsmith当前选择的主题色,当修改了主题时,按钮的颜色也会随之改变。
static getPropertyPaneStyleConfig() {
return [
{
sectionName: "General",
children: [
{
propertyName: "buttonType",
label: "Button Type",
helpText: "Sets the type of the button",
controlType: "DROP_DOWN",
defaultValue: ButtonTypes.PRIMARY,
options: [
{
label: "Primary",
value: ButtonTypes.PRIMARY,
},
{
label: "Default",
value: ButtonTypes.DEFAULT,
},
{
label: "Dashed",
value: ButtonTypes.DASHED,
},
{
label: "Text",
value: ButtonTypes.TEXT,
},
{
label: "Link",
value: ButtonTypes.LINK,
},
],
isJSConvertible: true,
isBindProperty: true,
isTriggerProperty: false,
validation: {
type: ValidationTypes.TEXT,
params: {
allowedValues: [
ButtonTypes.PRIMARY,
ButtonTypes.DEFAULT,
ButtonTypes.DASHED,
ButtonTypes.TEXT,
ButtonTypes.LINK
],
default: ButtonTypes.PRIMARY,
},
},
},
],
},
{
sectionName: "Color",
children: [
{
propertyName: "buttonPrimaryColor",
helpText: "Changes the color of the button",
label: "Button Primary Color",
controlType: "COLOR_PICKER",
isJSConvertible: true,
isBindProperty: true,
isTriggerProperty: false,
validation: { type: ValidationTypes.TEXT },
},
],
},
];
}
static getStylesheetConfig(): Stylesheet {
return {
buttonPrimaryColor: "{{appsmith.theme.colors.primaryColor}}"
};
}
4)设置属性默认值
可以在 getDefaults 方法中设置属性的默认值。不设置的就为undefined。用于样式表的属性在getStylesheetConfig方法中设置(参见上面的buttonPrimaryColor)。
static getDefaults() {
return {
widgetName: "Button",
rows: 4,
columns: 10,
version: 1,
// 上面4个必填,其他根据需要进行添加
text: "Submit",
isDisabled: false,
isVisible: true,
buttonType: ButtonTypes.PRIMARY,
};
}
其中widgetName、rows、columns、version是必须的。
- widgetName:widget拖入画布时生成的名字前缀。
- rows、columns:指定widget拖入画布时占据的行和列。
5)事件处理
点击按钮时,如果用户在属性面板里设置了onClick事件,需要将该按钮设为loading状态,然后执行设置的Action,执行完成后再取消loading状态。
代码如下所示,在onButtonClick方法中判断onClick属性是否有值,有值的话则设置isLoading状态,调用父类的executeAction方法执行onClick属性中绑定的行为,并且设置handleActionComplete回调,用来处理执行完成后的行为。最后在构造函数中绑定该事件,后续传递给组件使用。
onButtonClickBound: (event: React.MouseEvent<HTMLElement>) => void;
constructor(props: ButtonWidgetAntdProps) {
super(props);
this.onButtonClickBound = this.onButtonClick.bind(this);
this.state = {
isLoading: false,
};
}
onButtonClick() {
if (this.props.onClick) {
this.setState({
isLoading: true,
});
super.executeAction({
triggerPropertyName: "onClick",
dynamicString: this.props.onClick,
event: {
type: EventType.ON_CLICK,
callback: this.handleActionComplete,
},
});
}
}
hasOnClickAction = () => {
const { isDisabled, onClick } = this.props;
return Boolean(onClick && !isDisabled);
};
handleActionComplete = (result: ExecutionResult) => {
this.setState({
isLoading: false,
});
if (result.success) {
// 执行成功
}
};
6)Widget组件
getWidgetView 方法返回Widget中封装的组件,该组件在component/index.tsx文件中定义。这里将上述属性传递给该组件,以便控制组件的外观和行为。
getWidgetView() {
return (
<ButtonComponent
widgetId={this.props.widgetId}
key={this.props.widgetId}
text={this.props.text}
buttonType={this.props.buttonType}
buttonPrimaryColor={this.props.buttonPrimaryColor}
isLoading={this.state.isLoading}
onClick={this.hasOnClickAction() ? this.onButtonClickBound : undefined}
isDisabled={this.props.isDisabled}
/>
);
}
组件代码
下面的代码将AntDesign的按钮封装为ButtonComponent,并将其导出供Appsmith渲染。这里使用styed-components创建了样式化组件StyledButton,接收Widget传递的属性,再传给AntDesign的Button组件并设置其样式。
// component/index.tsx
const buttonBaseStyle = css<ButtonStyleProps>`
width: 100%;
height: 100%;
{{({ buttonType, buttonPrimaryColor }) => `
background: {{
buttonType === ButtonTypes.PRIMARY ? buttonPrimaryColor : 'none'
};
`}
`;
const StyledButton = styled((props) => (
<ConfigProvider
theme={{
token: {
colorPrimary: props.buttonPrimaryColor,
},
}}
>
<Button
type={props.buttonType}
{..._.omit(props, [
"buttonType",
"buttonPrimaryColor"
])}
>
{props.text}
</Button>
</ConfigProvider>
))<ButtonStyleProps>`
{{buttonBaseStyle}
`;
function ButtonComponent(props: ButtonComponentProps) {
const {
text,
buttonType,
buttonPrimaryColor,
isLoading,
onClick,
isDisabled
} = props
return (
<StyledButton
text={text}
buttonType={buttonType}
buttonPrimaryColor={buttonPrimaryColor}
loading={isLoading}
onClick={onClick}
disabled={isDisabled}
/>
)
}
interface ButtonStyleProps {
buttonType?: ButtonTypes;
buttonPrimaryColor?: string
}
interface ButtonComponentProps extends ComponentProps {
buttonType?: ButtonTypes;
buttonPrimaryColor?: string;
text?:string;
isLoading: boolean;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
isDisabled?: boolean;
}
export default ButtonComponent;