基于react+tailwind+ts+kuc的kintone插件开发

cnDevNet发表于:2023年11月15日 15:22:00更新于:2024年06月19日 13:02:15

Index

概要

react,tailwind,typescript 是现在前端开发中最常见的框架和技术。kintone ui component也是kintone前端自定义开发开发时构建统一ui的利器。所以我们在进行kintone定制的前端工程化的项目中会时常用到这些技术。这里就拿一个范例来说明如何在kintone插件的开发中使用这些技术。

插件目录结构分析

首先我们来了解下kintone插件开发的目录结构,然后对他的目录结构进行一些分析。

001655472d7c047bdf58e49086740bb

src为主目录。

image目录是插件的图标,无需改动。

js目录则是我们编写的jsx(tsx)代码最后编译输出的地址。

css部分可以直接通过styled-components或者tailwind框架来写在js中,或者不做修改,直接引用。

html部分作为插件设置画面,我们可以直接作为我们组件挂载的根节点。设置一个空的div即可。

比如

config.html

<div id='plugin-config-root'></div>

最后通过ReactDOM.createRoot() 进行挂载。

ReactDOM.createRoot(
  document.getElementById("plugin-config-root")
);

项目实战

接下来,我们来看下在具体项目中应用。

需求分析:

我们在做插件开发时,常常会开发这样一个插件。需要对接外部系统。而外部系统往往提供了rest api。而且规范的外部api,往往提供了api token等方式来进行授权访问。

此时我们开发插件需要将api token等敏感信息放在插件中。然后在前台使用kintone.proxy.api ()来发起代理请求,以此达到隐藏token的目的,从而提高安全性。详情参见:https://cybozudev.kf5.com/hc/kb/article/1314677/

所以这次插件的需求是插件设置界面可以设置请求地址,以及token。

前台假设设计了一个同步按钮,模拟发起点击后会调用外部服务器,获取外部数据。

这里我们建立一个kintone应用用来模拟外部服务。同时开启api令牌,并使用rest api 来获取它的数据。

插件后台设置画面:

001655472224c1c32be656811ba285d

应用前台画面

00165547222323bf157e4005558b8dc

准备应用

首先建立两个应用

应用名:appA (作为kintone的应用)

配置

字段名字段类型字段代码
标题单行文本框title
详情多行文本框detail

应用名:outer app (模拟外部服务器,可以接受数据同步)

字段名字段类型字段代码
标题单行文本框title
详情多行文本框detail
id单行文本框id

然后在outer app中设置API令牌

插件开发

初始化安装

首先我们来初始化项目,并安装依赖

# 初始化
npm init
# 安装webpack,typescript,ts-loader 
npm i -D  webpack webpack-cli ts-loader typescript 
# 安装react,react-dom以及对应的类型
npm i react react-dom 
npm i -D @types/react @types/react-dom 
# 安装webpack-plugin-kintone-plugin插件,实现自动打包已经上传
npm i -D @kintone/webpack-plugin-kintone-plugin
# 安装styled-components,以实现css-in-js的写法
npm i -D styled-components
# 安装kintone-ui-component
npm i -D @kintone/kintone-ui-component

manifest.json配置

manifest.json


{
  "manifest_version": 1,
  "version": 1,
  "type": "APP",
  "desktop": {
    "js": ["js/desktop.js"],
    "css": ["css/51-modern-default.css", "css/desktop.css"]
  },
  "icon": "image/icon.png",
  "config": {
    "html": "html/config.html",
    "js": ["js/config.js"],
    "css": ["css/51-modern-default.css", "css/config.css"]
  },
  "name": {
    "en": "外部同步demo",
    "ja": "外部同步demo",
    "zh": "外部同步demo"
  },
  "description": {
    "en": "外部同步demo",
    "ja": "外部同步demo",
    "zh": "外部同步demo"
  }
}

webpack的设置

其中插件后台和应用前台的逻辑并不相干,所以我们将他们做为多入口打包配置。

在entry中定义应用前台(desktop)和插件配置(config)的入口地址

webpack.config.js

entry: {
    desktop: "./src/desktop/index.tsx",
    config: "./src/config/index.tsx",
  },
  output: {
    path: path.resolve(__dirname, "plugin", "js"),
    filename: "[name].js",
  },

接下来,我们只需要在src下的desktop和config目录下编写代码即可。我们能愉快的使用模块化方式进行开发了。

kintone ui component 转 react组件

此时我们还有一个问题,就是他是以web component的组件方式来编写的。所以我们没法直接将他当作react组件使用。我们需要用转化工具@lit/react来将kuc的组件转化成react组件。

安装

npm install @lite/react

使用

然后我们来建立一个目录。将kuc的组件转化成react组件后导出

示例代码:

kucComponent/Button.tsx

import React from "react";
import { createComponent } from "@lit/react";
import { Button } from "kintone-ui-component";
import { kucVersion } from "./common";

export const ButtonReact = createComponent({
  tagName: `kuc-button-${kucVersion}`,
  elementClass: Button,
  react: React,
});

最后将这些组件做一个统一的导出

kucComponent/common/index.tsx

export { NotificationReact } from "./Notification";
export { TextReact } from "./Text";
export { ButtonReact } from "./Button";

插件配置画面的开发

目录结构:

index.tsx   //入口文件

config.tsx  //插件配置组件

pluginContext.ts //通过react context,管理公用的状态。比如说plugin id

config/index.tsx

将我们的插件配置组件挂载到config.html上的id为"plugin-config-root"的节点。

同时通过context provider共享plugin id这个状态。

import React from "react";
import ReactDOM from "react-dom/client";
import Config from "./config";
import { PluginIdContext } from "./pluginIdContext";

const root = ReactDOM.createRoot(
  document.getElementById("plugin-config-root") as HTMLElement
);

const PLUGIN_ID = kintone.$PLUGIN_ID;

root.render(
  <React.StrictMode>
    <PluginIdContext.Provider value={PLUGIN_ID}>
      <Config />
    </PluginIdContext.Provider>
  </React.StrictMode>
);

config/pluginContext.ts

import { createContext } from "react";

export const PluginIdContext = createContext("");

config/config.tsx

对应之前的插件设置画面的图,我们需要在插件画面设置以下信息。

token信息(token): 外部系统的token信息

接口的地址(url):外部系统的接口预设地址

同步应用id(syncId):用来模拟外部系统的kintone 应用id

导入转化为react组件的kuc组件,同时使用了react-form-hooks来包装了表单。后期大家可以实现表单校验等。

import { useForm, Controller, SubmitHandler } from "react-hook-form";
import { FC, useContext } from "react";
import { ButtonReact, TextReact } from "../kucComponent";
import { PluginIdContext } from "./pluginIdContext";
import _ from "lodash";

type Inputs = {
  url: string;
  syncId: string;
  token: string;
};

const Config: FC = () => {
  const pluginId = useContext(PluginIdContext);
  //kintone.plugin.app.getConfig 是js api,而且是同步的,并非异步,保证这是一个纯函数。
  const settingInfo = kintone.plugin.app.getConfig(pluginId);
  let defalutInput: Inputs = {
    url: "",
    syncId: "",
    token: "",
  };
  let headerInfo: any = null;
  let defaultToken: string;
  if (!_.isEmpty(settingInfo)) {
    headerInfo = kintone.plugin.app.getProxyConfig(settingInfo!.url, "GET");
    defaultToken = headerInfo.headers.Authorization;
    defalutInput = { ...settingInfo };
  }

  const {
    register,
    handleSubmit,
    control,
    // formState: { errors },
  } = useForm<Inputs>({ defaultValues: defalutInput });

  const onSubmit: SubmitHandler<Inputs> = (data) => {
    const apiHeader = {
      "X-Cybozu-API-Token": `${data.token}`,
    };
    kintone.plugin.app.setConfig(data, () => {
      kintone.plugin.app.setProxyConfig(data.url, "POST", apiHeader, {}, () => {
        kintone.plugin.app.setProxyConfig(
          data.url,
          "GET",
          apiHeader,
          {},
          () => {
            kintone.plugin.app.setProxyConfig(
              data.url,
              "PUT",
              apiHeader,
              {},
              () => {
                kintone.plugin.app.setProxyConfig(
                  data.url,
                  "DELETE",
                  apiHeader,
                  {}
                );
              }
            );
          }
        );
      });
    });
  };

  const handleCancel = () => {
    window.location.href = `../../${kintone.app.getId()}/plugin/`;
  };

  return (
    <>
      <div>
        <h2 className="m-2 p-2 text-3xl font-bold">信息参数配置</h2>
        <form onSubmit={handleSubmit(onSubmit)}>
          <p className="kintoneplugin-row">
            <Controller
              name="token"
              control={control}
              render={({ field }) => (
                <TextReact
                  defaultValue={defaultToken}
                  label="token信息:"
                  {...register("token")}
                  {...field}
                ></TextReact>
              )}
            />
          </p>
          <p className="kintoneplugin-row">
            <Controller
              name="url"
              control={control}
              render={({ field }) => (
                <TextReact
                  label="接口的地址:"
                  {...register("url")}
                  {...field}
                ></TextReact>
              )}
            />
          </p>
          <p className="kintoneplugin-row">
            <Controller
              name="syncId"
              control={control}
              render={({ field }) => (
                <TextReact
                  label="同步应用id:"
                  {...register("syncId")}
                  {...field}
                ></TextReact>
              )}
            />
          </p>
          <p className="kintoneplugin-row">
            <ButtonReact
              className="my-button mr-4"
              onClick={handleCancel}
              text="取消"
            />
            <ButtonReact
              className="my-button"
              onClick={handleSubmit(onSubmit)}
              text="保存"
              type="submit"
            />
          </p>
        </form>
      </div>
    </>
  );
};
export default Config;

应用前台的开发

我们来模拟一个简单需求。

我们在kintone的应用中。添加了一个同步按钮。利用这个同步按钮,可以将kintone数据同步到外部服务器。(这边用另一个kintone应用来模拟外部服务器)。初次进入应用列表页,会显示外部服务,已经同步到的数据总数。然后点击同步后,会将kintone数据传输到这个外部服务器。

desktop 画面的入口

desktop/index.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import SyncData from "./components/syncData";

interface KintoneEvent {
  record: kintone.types.appASavedFields;
}

kintone.events.on("app.record.index.show", async (event: KintoneEvent) => {
  ReactDOM.createRoot(kintone.app.getHeaderMenuSpaceElement()!).render(
    <React.StrictMode>
      <SyncData />
    </React.StrictMode>
  );
  return event;
});

同步按钮的组件

desktop/components/syncData.tsx

import { FC } from "react";
import { ButtonReact } from "../../kucComponent";
import useSyncToOut from "../hooks/useSyncToOut";
import styled from "styled-components";

const Wrapper = styled.div`
  display: flex;
`;

const SubButton = styled(ButtonReact)`
  --kuc-button-width: 100px;
  margin-right: 30px;
`;

const SyncData: FC = () => {
  const { loading, syncStatus, handleSync } = useSyncToOut();

  return (
    <Wrapper>
      <SubButton onClick={handleSync} text="同步" disabled={loading} />
      <div>syncStatus: {syncStatus}</div>
    </Wrapper>
  );
};

export default SyncData;

自定义同步到外部系统的hooks

desktop/hooks/useSyncToOut.ts

import { useEffect, useState } from "react";
import { appId, syncId, url, PLUGIN_ID } from "../constants";

type appAResult = {
  status: boolean;
  result: kintone.types.appASavedFields[];
};

type outerAppResult = {
  status: boolean;
  result: kintone.types.outerAppSavedFields[];
};

export default function useSyncToOut() {
  const [syncStatus, setSyncStatus] = useState<string>("");
  const [loading, setLoading] = useState<boolean>(false);

  // 获取外部系统的记录数,简单假设获取最后一条记录的id作为同步的最终结果
  // 使用kintone.proxy 模拟这是一个外部系统。

  //获取外部系统
  const getProxyRecords = async () => {
    const apiUrl = `${url}/k/v1/records.json?app=${syncId}`;
    let resp: outerAppResult = { status: false, result: [] };
    try {
      const [result] = await kintone.plugin.app.proxy(
        PLUGIN_ID,
        apiUrl,
        "GET",
        {},
        {}
      );
      const records = JSON.parse(result).records;
      if (records.length > 0) {
        resp.status = true;
        resp.result = records;
      }
    } catch (e) {
      console.log(e);
    }
    return resp;
  };

  //同步外部系统
  const updateProxyRecords = async (
    records: kintone.types.appASavedFields[]
  ) => {
    const updateRecords = records.map((record) => {
      return {
        id: record.$id,
        title: record.title,
        detail: record.detail,
      };
    });
    const params = {
      app: syncId,
      records: updateRecords,
    };
    const apiUrl = `${url}/k/v1/records.json`;
    try {
      await kintone.plugin.app.proxy(
        PLUGIN_ID,
        apiUrl,
        "POST",
        { "Content-Type": "application/json" },
        params
      );
    } catch (e) {
      console.log(e);
    }
  };

  //获取当前系统
  const getRecords = async () => {
    const params = {
      app: appId,
    };
    const result = await kintone.api(
      kintone.api.url("/k/v1/records", true),
      "GET",
      params
    );
    let resp: appAResult = { status: false, result: [] };
    if (result.records.length > 0) {
      resp.status = true;
      resp.result = result.records;
    }
    return resp;
  };

  //同步:取出当前应用的数据,同步到外部系统。更新同步状态
  const handleSync = async () => {
    setLoading(true);
    const { result } = await getRecords();
    await updateProxyRecords(result);
    const message = `已同步${result.length}条记录`;
    setSyncStatus(message);
    setLoading(false);
  };

  useEffect(() => {
    const getSyncStatus = async () => {
      const { result } = await getProxyRecords();
      if (result.length > 0) {
        const message = `外部系统共有${result.length}条记录`;
        setSyncStatus(message);
      } else {
        setSyncStatus("暂无同步记录");
      }
    };
    getSyncStatus();
  }, []);

  return { loading, syncStatus, handleSync };
}

使用

首先outer app中添加api token。然后在appA中添加插件,并且设置token以及app id。

然后在appA的应用列表页,就能显示这个同步按钮,已经同步的状态啦。

注意

当然这里只是简单的演示,并没有考虑数据的更新,删除,以及超过1次请求总数的数据分页等情况。只是通过这个简单的例子做了一个利用kintone.plugin.app.proxy()来从客户端发起代理请求到外部服务器的示例。是的,通过它,你的请求会从后端服务器进行转发,并且自动带上在插件设置中配置的头部token信息。

kintone插件的监控,自动打包,自动上传

最后通过webpack插件,来实现插件开发中的文件监控,自动打包插件,自动上传kintone等。

1 安装webpack-plugin-kintone-plugin , plugin-packer ,plugin-uploader 等插件

npm install -D @kintone/plugin-packer @kintone/plugin-uploader @kintone/webpack-plugin-kintone-plugin

2 添加一个npm脚本

scripts/npm-start.js

"use strict";
const runAll = require("npm-run-all");

runAll(["develop", "upload"], {
  parallel: true,
  stdout: process.stdout,
  stdin: process.stdin,
}).catch(({ results }) => {
  results
    .filter(({ code }) => code)
    .forEach(({ name }) => {
      console.log(`"npm run ${name}" was failed`);
    });
});

配置webpack插件

const KintonePlugin = require("@kintone/webpack-plugin-kintone-plugin");

plugins: [
    new KintonePlugin({
      manifestJSONPath: "./plugin/manifest.json",
      privateKeyPath: "./private.ppk",
      pluginZipPath: "./dist/plugin.zip",
    }),
  ]

编辑package.json,修改scripts

"scripts": {
    "start": "node scripts/npm-start.js",
    "upload": "kintone-plugin-uploader dist/plugin.zip --watch --waiting-dialog-ms 3000",
    "develop": "webpack --mode development --watch",
    "build": "webpack --mode production"
  }

开发模式下,修改src下的文件,会实现自动编译,自动打包,自动上传。


注意事项

  • 本示例代码不保证其运行。

  • 我们不为本示例代码提供技术支持。