Appsmith组件开发

2023-11-24 15:12 月霖 985

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;