Index
前言
众所周知,Lookup 是 kintone 中一个比较好用的功能。它可以在填写数据的时候,复制别的 app 的字段值,从而可以避免大块数据重复手动录入的问题。
但是,在日常的运用中,常常有一个需求被广泛提及,那就是 kintone 提供的 Lookup 所复制的数据是固定的,不会随参照源的改变而改变,而有时候,用户就是希望数据会随着改变。这时,所谓的 【Lookup 自动更新】功能便被提了出来。
其实,已经有不少的开发者成功开发出了【Lookup 自动更新】功能,有些是专门定制的自定义开发,有些是基于一些泛用插件的功能来实现。但是,通过最近的研究,我们发现这些方法都存在一些问题,在研究就这些问题的过程中,得到了一些开发的经验,于是想在这篇文章中同大家分享。
明确共识
在展开讲述之前,我们应该了解【Lookup 自动更新】方面的基础概念。
首先,我们要知道,kintone 默认 提供的 Lookup 功能,没有自动更新,并不是功能上的缺陷,而是最开始功能上就是这么定义的。因为所谓的【Lookup 自动更新】只是一部分场景的需求,而很多时候,我们就是不希望进行自动更新,自动更新对于这些场景来说是错误的表现。
举例来说,假设现在有两张表,一张商品表,一张订单表,客户买东西时,会生成订单表,订单表里会有客户所选的商品,包括商品名,商品价格等信息。在这种场景下,我们就不能用【Lookup 自动更新】来更新商品价格,因为订单所反映的是成交时的商品价格,但是商品价格会随时间的变化而变化,不能因为这个变化而影响了之前已经产生过的订单。
那么,另一方面,什么时候我们会需要用到【Lookup 自动更新】功能呢?总的来说,如果用户只关心被参照数据的最新数据,而不想知道历史数据的时候,就会用到【Lookup 自动更新】功能了。
举例来说,也是其他相关文章经常提到的一个案例。一张是客户表,是被 Lookup 参照的 app,我们称它为 A。另一张是案件表,Lookup 客户表的字段是客户名,需要复制的数据有客户电话,客户地址等。 我们称它为 B。当 B 的一条记录生成后,他所参照的客户的电话发生了改变,当再次查看 B 的记录时,希望看到的是最新变化后的电话号码,因为以前的电话号码,用户已经不关心了。
引出问题
我研究了市面上能找到的介绍【Lookup 自动更新】功能的各种文章,发现他们都存在一些难以解决的问题。要讲清楚这些问题,先要了解它们基本的设计思路。
就用上面的【客户-案件】案例来说,设计思路是这样的:
对 A 表进行自定义。
当 A 的记录发生改变时,找到 B 中所有参照 A 这条记录的记录,并更新它们。
然后,这时候需求增加,有一个叫 C 的 App 也希望能参照 A,于是,开发人员编辑对 A 的自定义,在找参照的地方模仿 B,同样的逻辑, 再更新一遍 C。
现在,有的读者可能已经发现问题了:
如果 A 是一个常用 master 表,例如【员工表】,公司中可能有大量的 App 在 Lookup 它的值。要知道【员工表】可能是人事部门或IT部门在管理的,每当有别的同事制作新 App 需要参照【员工表】并需要自动更新时,必须通过【员工表】的管理员进行自定义代码的改动,当这种改动变得频繁起来,跨部门的沟通和代码冗长容易造成各种各样的问题。
B、C 这样的表一旦改动,或者删除,A 也需要进行相应的改动。繁琐只是一部分问题,更可怕的是,改动 B、C 的人(新接手管理的人)可能根本不知道有【Lookup 自动更新】的功能,因为自定义并不存在于参照方,从 B、C 这里根本看不出有自定义的痕迹。
B、C 这样的表一旦多起来,性能可能也会成为问题。因为改一条 A 的记录,会牵涉到很多记录的查询和更新。
说到这里,产生这种问题的原因也应该呼之欲出了。可以说设计思路一开始就存在缺陷。A 相当于一个内容的发布者,B、C 相当于内容阅读者。如果 A 发布了某个内容的两个版本,那 B、C 需要什么版本不应该由 A 来分发,而是应该他们自己来订阅。
新思路
为了描述更准确一些,在下文中,我们将 A 类的表叫做【主表】,B、C 类的表叫做【事务表】
根据上文我们已经总结好的问题症结,现在可以拓展新的思路去实现相似的功能效果。大致的设计框架如下:
所有的自定义开发程序,写在【事务表】中,【主表】中不写入任何自定义程序
【事务表】中的 Lookup 字段,以及需要复制的字段,即需要表示【主表】相应字段的最新数据的,不做真正的数据更新操作,而只是在有需要显示的时候,才去参照【主表】的内容。
优缺点
下面对这种设计框架进行解读:1 带给我们的好处是不必每次都劳烦【主表】的管理员,可谓解决了之前提出的主要问题。但是随之带来的是,【主表】更新的时机,不可能被【事务表】中的程序所探知,于是在 2 中,我们放弃了真正的数据更新,改为在显示时去实时参照【主表】中的内容。这样做的利弊有:
所有数据维持 kintone 默认下的 Lookup 仕样,即数据生成时的原始状态。当我们撤销自定义时,一切都会恢复原状,避免了数据不整合的风险。
绝大多数的情况下,都是查看网页上内容时,需要看到【主表】的最新内容,所以即使原始数据不改动,只是实时参照的话,也足够应付。
由于原始数据没有真正改动,所以所有的汇总统计都是不会是根据【主表】最新数据所产生的,所以有统计需求的场景,就不要采用这种设计框架了。
更进一步
有了上面的思路,已经可以动手开发了,但是我们应该可以更有追求一点。
考虑一下,当如下需求产生时,我们应该如何应对?
实时参照的 Lookup 看上去的确不错,我有很多应用都想这么做
Lookup 的字段,和需要复制的字段中,有一些想看原始数据,另一些则想看最新数据
我们发现这些需求是那种从特殊定制运用到广泛适用的那种情况,这时候就想到,plugin 不正是 kintone 为了解决通用性开发问题而提供的开发模式吗?在这里用插件是再合适不过了。
开发插件必定要产生更多的准备工作和更多的代码规模,如果您只是想实现单一功能,可以从之后的代码逻辑舍去插件相关部分,可以大大缩短开发进程。
开发过程
现在我们已经明确了要做一个什么样的东西,下面应该就可以进入到正式开发的环节了。
俗话说,万事开头难。由于本次开发中,运用到的技术相对较多,如果一开始没有规划好的话,很难保证后面的开发能顺利进行。所以这里有必要对开发前的准备,做一番说明。
开发准备
开发准备中,我把内容分为三个方面,可行性验证,开发环境,和 kintone App。
可行性验证
在正式开发之前,我们还需要确认一下本次开发的功能是可以实现的,几个最关键的节点,逻辑是可以连通的。如果事先不进行验证的话,可能开发到一半才发现功能实现不了,就像空中楼阁是注定建不成的。
1. 字段类型为 lookup 的字段可否知道?它所引用的【主表】的字段名可否知道?
通过寻找 REST API 我们发现,【获取表单的设置】这个 API 可以达到要求。它会返回字段设置的各种信息,其中也包括 Lookup 的关联应用信息。所以结论为可行。
2. 在【事务表】详细页面或者列表页面,是否能够拿到 lookup 的关联信息,即和【主表】关联的记录号?
根据【事务表】中的 Lookup 字段,找到【主表】中的参照字段。说简单点就是寻找 Lookup 的目标。经过一定的研究发现,在 REST API中并没有提供相应的信息,你获取的 Lookup 中,只回有当前的值,而不会有参照的目标。但是这点根本难不倒我们,因为我们发现,网页上 Lookup 是能链接到参照的那条记录的(类似 /k/88/show#record=1 这样的形式),所以根据超链接地址,我们便可拿到想要的 Record ID。所以结论为可行。
有了以上两点的验证,我们确保项目是可行的。
开发环境
由于本次的开发内容还是略有点复杂的,所以一个纯 js 文件一镜到底的模式是万万不可取的。我默认读者已经具有搭建 npm 包管理项目经验,之后所有的叙述也是基于这个基础之上。如果您对这方面不是很了解的话,可以参看之前发布的 环境搭建的系列文章。
下面我会讲一下本次开发中运用到的技术,以及为什么会这样选择的缘由。
插件。 在接下来的开发过程介绍中,我会以插件开发为例来讲述。所以如果有读者对插件开发不熟悉的话,可以先看参这两篇文章: 插件开发的准备, 插件开发流程。
Vue。 因为涉及到插件开发,在配置页面,必定会产生画面的制作,并且会产生前后台的数据交换逻辑。这时候我们采用前端框架制作画面上的一部分元素,会是一个不错的选择。当然这里选择 React 也是很推荐的。有需求的读者自行替换即可。
TypeScript。 对于本项目要不要上 TypeScript,我也是有所犹豫。因为 TypeScript 向来争议比较大,虽然在提高代码质量方面十分优秀,但是也带来了开发难度直线上升的问题。这里最后还是选择迎难而上,一方会面的原因是,开发中会遇到 API 获取 App 配置的部分,这部分的数据的结构有点复杂,把类型定义清晰之后,可以避免一些处理数据时的失误,并把主要精力放在逻辑上,而不是数据类型比对上。另一方面,开发者网站本应主打开拓引导,提供先例等方面的宗旨,况且之前虽然推出过 TypeScript 介绍的文章,但是还没有实际开发的案例文章,所以本文就斗胆做一次尝试。希望对将来有意向运用 TypeScript 进行开发的个人或团队提供一些参考。
运用 TypeScript 肯定会产生一些前期配置工作,幸好之前我们有过几篇文章的介绍,有需要的读者可以参考一下。
环境小结:开发环境的搭建,在项目建立的初期是比较花时间的。但是一个好的环境对项目的帮助也是非常之大,可以节省后期很多时间。当然上面说的这些技术也不是必须引入的,开发者可以根据实际情况,酌情考虑,或弃用或寻找替代品都是可以的。
kintone App
这次我们还是用之前讲到的案件管理项目来作为范例。主表我们采用kintone商城里自带的【客户信息】应用。
而事务表我们选择自行创建,取名【案件管理】。因为演示并不需要太多的字段,所以这里简单地加几个字段。
这里的【对方客户】字段设置成 Lookup 类型,用来引用【客户信息】应用中的信息。而【客户电话】字段,是复制【客户信息】中的电话,但需要显示最新电话,而不是创建时的电话。
在【客户信息】应用中录入一条数据,电话现在写的是 88880000,注意,这是给旧电话准备的数据。
在【案件管理】应用中也添加一条数据,对方客户里点击搜选。
电话号码 88880000 已经复制过来了。
然后再把客户信息的电话号码改成 99990000,模拟数据发生了变化。
最后,我们期待的目标是,【案件管理】中的【客户电话】变成了 99990000。
到这里,我们的前期准备工作已经全部完成了,虽然过程可能比较长,但是扎实的准备一定能为后面的开发打下坚实的基础的。
代码解读
项目创建好之后,大概的目录结构长这个样子
红框中的 src 目录中的内容为具体的插件代码,其他文件则是一些项目运行的配置文件。读者如果对项目配置不熟悉的话,上文中已经提到过,可以参考本网站的开发环境搭建的系列文章,这里不作赘述。
src 中文件结构基本上是按插件的推荐配置摆放的,下面对 js 文件夹中的文件大致负责的功能做一个介绍。
config.ts
插件配置页面的总入口,负责传递【PLUGIN_ID】给创建好的 Vue 组件ConfigPage.vue
Vue 的组件文件。负责插件配置页面的画面摆放和逻辑交互。desktop.ts
用户使用 App 时起作用。负责读取插件配置信息,和主要功能的实现。DesktopPage.vue
Vue 的组件文件。可以在列表画面或者详细画面做一些自定义的UI,譬如做一个 switch 按钮来切换新老数据的展示。不包括在本次项目代码解说中。fields.d.ts
补充了一些 kintone 公用字段的类型信息等。本项目未必为用到,作为通用文件存在。lookup.ts
定义了 lookup 类型的外壳,并包装了获取 lookup 参照信息的 API。
下面我们逐个解读代码。请对照代码中的注释来理解实现的功能。
config.html
<!-- 配置画面的HTML模板 --> <section class="settings"> <h2 class="settings-heading">启用LookUp实时参照最新数据</h2> <p class="kintoneplugin-desc">这里将列出所有Form中的LookUp字段,请选择您要启用实时参照的项目</p> <!-- 给配置内容预留一个容器,取id为main,config.ts中会用到 --> <div id="main"></div> </section>
config.ts
import { createApp } from 'vue' import ConfigPage from './ConfigPage.vue' // 获取plugin id,读取和保存时配置信息时都需要用到 const pluginId = kintone.$PLUGIN_ID // 获取模板中所预留的容器 const main = document.getElementById('main') as HTMLDivElement // 创建vue组件ConfigPage,并传给它plugin id const app = createApp(ConfigPage, { pluginId }) // 挂在组件到main app.mount(main)
lookup.ts
import { KintoneRestAPIClient } from '@kintone/rest-api-client' // 定义了一个Lookup类型,其中给lookup设置的是对象类型 // 下面还有展开,到具体用的时候还会定义的更详细,这里够用了 export type Lookup = { label: string code: string lookup: object } // 创建sdk客户端 const client = new KintoneRestAPIClient({ baseUrl: 'https://yourdomain.cybozu.cn', }) // 获取一个应用中所有类型是Lookup的字段 export const getFormSetting = async () => { const prop = (await client.app.getFormFields({ app: kintone.app.getId() as number })).properties const lookUpFields = Object.values(prop).filter((f) => 'lookup' in f) as Lookup[] return lookUpFields }
ConfigPage.vue
<template> <!-- 一个循环获取Lookup字段的模板 --> <div v-for="i in data.saveConfig" :key="i.label" class="config-row"> <span class="toggle-wrapper"> <input :id="`css${i.label}`" v-model="i.checked" type="checkbox" /> <label class="toggle" :for="`css${i.label}`"><span class="toggle-handler"></span></label> </span> <span class="lookup-field-name-text-parent"> <span class="control-label-gaia lookup-field-name-text">{{ i.label }}</span> </span> </div> <div class="kintoneplugin-row"> <button type="button" class="js-cancel-button kintoneplugin-button-dialog-cancel" @click="cancel">Cancel</button> <button type="submit" class="kintoneplugin-button-dialog-ok" @click="save">Save</button> </div> </template> <script setup lang="ts"> /* eslint-disable no-console */ import { reactive, onMounted } from 'vue' import { getFormSetting, Lookup } from './lookup.ts' // 接受外面传递过来的pluginId const props = defineProps<{ pluginId: string }>() // 用到的数据声明,并且是响应式的 const data: { config: Lookup[]; saveConfig: Array<{ label: string; checked: boolean; code: string }> } = reactive({ config: [], saveConfig: [], }) onMounted(async () => { const gettedConfig = kintone.plugin.app.getConfig(props.pluginId) data.saveConfig = JSON.parse(gettedConfig.setting) // 拿到所有是Lookup的字段 const res: Lookup[] = await getFormSetting() // 比对上次保存的结果,并确定哪些勾选哪些不勾选 const cp = res.map((i) => { for (let j = 0; j < data.saveConfig.length; j += 1) { if (data.saveConfig[j].code === i.code) { return { code: i.code, label: i.label, checked: data.saveConfig[j].checked } } } return { code: i.code, label: i.label, checked: false } }) data.saveConfig = cp }) // 按下保存按钮时的操作:用plugin的API来保存到kintone内 function save() { kintone.plugin.app.setConfig({ setting: JSON.stringify(data.saveConfig) }) } // 按下取消按钮时的操作:退回到plugin默认页面 function cancel() { window.location.href = `../../${kintone.app.getId()}/plugin/` } </script> // 引入css <style> @import '../css/config.css'; </style>
config.css
config画面的css,主要是开关按钮的效果
.settings-heading { font-size: 1.8em; } .config-check-box { width: 1.2em; height: 1.2em; } .config-row { height: 4.6em; } .lookup-field-name-text-parent { position: relative; top: -0.64em; margin-left: 1.2em; } .lookup-field-name-text { font-size: 1.5em; } .toggle-wrapper { overflow: hidden; transform: translate3d(-50%, -50%, 0); } .toggle-wrapper input { position: absolute; left: -99em; } .toggle { cursor: pointer; display: inline-block; position: relative; width: 120px; height: 50px; background: #d21626; border-radius: 5px; transition: all 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95); } .toggle::before, .toggle::after { position: absolute; line-height: 50px; font-size: 14px; z-index: 2; transition: all 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95); } .toggle::before { content: 'OFF'; left: 20px; color: #d21626; } .toggle::after { content: 'ON'; right: 20px; color: #fff; } .toggle-handler { display: inline-block; position: relative; z-index: 1; background: #fff; width: 65px; height: 44px; border-radius: 3px; top: 3px; left: 3px; transition: all 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95); transform: translateX(0); } input:checked + .toggle { background: #66b317; } input:checked + .toggle::before { color: #fff; } input:checked + .toggle::after { color: #66b317; } input:checked + .toggle .toggle-handler { width: 54px; transform: translateX(60px); border-color: #fff; }
desktop.ts
定义和通用方法:
import { KintoneRestAPIClient } from '@kintone/rest-api-client' import { getFormSetting, Lookup } from './lookup.ts' const pid = kintone.$PLUGIN_ID const client = new KintoneRestAPIClient({ baseUrl: 'https://yourdomain.cybozu.cn', }) kintone.events.on('app.record.edit.submit', (event: KintoneEvent) => { return event }) // 需要更详细的结构,所以扩充了Lookup类型 type SpecificLookup = Lookup & { lookup: { relatedApp: { app: string; code: string } // 所参照的app fieldMappings: Array<{ field: string; relatedField: string }> // 同时复制的字段 relatedKeyField: string // 本lookup字段的字段代码 } } /** * 根据所给的需要参照的app,给出该条记录的信息。record的编号则需要通过解析href得到 * @param targetApp * @param hle * @returns */ async function getTargetRecord(targetApp: string, hle: HTMLAnchorElement) { const targetRecordId = (hle.href.match(/record=(\d+)/) as string[])[1] const targetRecord = await client.record.getRecord({ app: targetApp, id: targetRecordId }) return targetRecord }
在详细页面做的自定义:
// 在详细页面做的事 kintone.events.on('app.record.detail.show', async (event) => { // 先拿到插件的设定,表示哪些lookup字段需要适用实时参照功能 const savedConfig: Array<{ label: string; checked: boolean; code: string }> = JSON.parse( kintone.plugin.app.getConfig(pid).setting, ) // 拿到本app的form信息,从中只拿lookup字段的信息 const lookupSetting: SpecificLookup[] = (await getFormSetting()) as SpecificLookup[] // 比对form信息和插件设定信息,从而得到一个真正需要起作用的lookup字段们 // 我不需要得到比设定内容更多的lookup,即使新增了lookup字段,却还未在插件中设定过,反正默认他们也是disable,所以以savedConfig为基础循环比较好 const enabledLookupSetting = savedConfig.map((i) => { for (let j = 0; j < lookupSetting.length; j += 1) { if (i.checked && lookupSetting[j].code === i.code) { // saveConfig中只有最基础的信息,而我们在这里将要使用很多lookup中的设定信息,比如参照的app,其他要复制的字段的对应关系等,所以这里返回SpecificLookup类的东西 return lookupSetting[j] } } return undefined }) // 对这些lookup字段们进行循环 for (let i = 0; i < enabledLookupSetting.length; i += 1) { // 简化变量名且帮助ts检查空 const els = enabledLookupSetting[i] if (els) { const code = els.code as string // 拿到looup的html元素,希望它是一个<a>即HTMLAnchorElement,但其实并不一定,因为如果是空,kintone给出的结构将跳过<a>这一层,直接给下一层的<span> const anchorEle = (kintone.app.record.getFieldElement(code) as HTMLElement).firstChild // 如果不是<a>,即没有参照信息,接下来所有的操作都可以跳过了,这个判断也cover了href为空的可能性 if (anchorEle && anchorEle instanceof HTMLAnchorElement) { const targetRecord = await getTargetRecord(els.lookup.relatedApp.app, anchorEle) // 正常情况下,spanEle 会是一个 instance of HTMLSpanElement,但万一是其他,反正textContent也应该拿得到,这里就先不加断言 const spanEle = anchorEle.firstChild if (spanEle && spanEle.textContent) { // 把lookup所参照的值赋值给元素 // 注意,这里不能用赋值给event.record;return event;来做,因为kookup类型的字段,kintone不支持通过event来改变值 // 得到值的写法比较繁琐,这是由于所参照的app的字段代码,在lookup的设定对象嵌套比较深 // 另一种考虑是不再获取sapn,直接给anchorELe赋值,也能达到差不多的效果,优点是简单,缺点是改变了结构,span消失了 spanEle.textContent = targetRecord?.record[els.lookup.relatedKeyField].value as string // 其他要复制的字段也要把参照值复制过来 // 写在这个if下的原因是,如果lookup的值不是参照来的,那为了数据一致性,其他要赋值的字段最好也不要显示参照的值 els.lookup.fieldMappings.map((mapping) => { // 拿到参照字段的元素 const refEl = (kintone.app.record.getFieldElement(mapping.field) as HTMLElement).firstChild if (!refEl || !refEl.textContent) return null // 拿到要复制的值 const overwriteValue = targetRecord?.record[mapping.relatedField].value // 判断一下需要复制的值是什么类型的,一般是文本框之类的都是string // 但是如果是用户选择类型之类的话,就需要根据具体情况,特殊设计了 if (typeof overwriteValue === 'string') { refEl.textContent = overwriteValue as string } return null }) } } } } return event })
在列表页面的自定义
// 在列表页面要做的事 kintone.events.on('app.record.index.show', async (event) => { const savedConfig: Array<{ label: string; checked: boolean; code: string }> = JSON.parse( kintone.plugin.app.getConfig(pid).setting, ) const lookupSetting: SpecificLookup[] = (await getFormSetting()) as SpecificLookup[] const enabledLookupSetting = savedConfig.map((i) => { for (let j = 0; j < lookupSetting.length; j += 1) { if (i.checked && lookupSetting[j].code === i.code) { return lookupSetting[j] } } return undefined }) for (let i = 0; i < enabledLookupSetting.length; i += 1) { const els = enabledLookupSetting[i] if (els) { const code = els.code as string // 拿到表格中的td元素们 const tdEls = kintone.app.getFieldElements(code) tdEls?.map(async (tdEl) => { const anchorEl = tdEl.querySelector('a') as HTMLAnchorElement const targetRecord = await getTargetRecord(els.lookup.relatedApp.app, anchorEl) // Lookup元素的值改为关联字段的值 anchorEl?.textContent && (anchorEl.textContent = targetRecord?.record[els.lookup.relatedKeyField].value as string) }) } } return event })
fields.d.ts
declare namespace kintone { const $PLUGIN_ID: string } declare namespace kintone.types { interface Fields {} interface SavedFields extends Fields { [key: string]: { type: string; value: string | number | boolean } $id: kintone.fieldTypes.Id $revision: kintone.fieldTypes.Revision 更新人: kintone.fieldTypes.Modifier 创建人: kintone.fieldTypes.Creator 更新时间: kintone.fieldTypes.UpdatedTime 创建时间: kintone.fieldTypes.CreatedTime key: kintone.fieldTypes.RecordNumber } } interface KintoneEvent { record: kintone.types.SavedFields }
编写完代码,调试没有问题之后,我们看到了效果。详细画面的电话号码已经显示为最新的【99990000】了。
友情提示
上方展示的代码只是一种示范例子,有些地方可能并不完美。读者可根据自身需求进行改造。
建议加一个总体切换的按钮,在最新数据和原始数据直接随意切换。这样可以让用户时时刻刻意识到自己在看什么样的数据。
在列表页面,范例中只做到了 Lookup 字段自身的改变,复制的字段,还请读者自行完成。
这里要声明的是,给出的代码只是为了示例和演示,请读者务必在理解的基础上进行开发和改造,切不可全部照搬,更不建议直接当产品使用。我们不保证示例代码的长久可用性。
结束语
本篇文章对 Lookup 要查看最新数据的需求进行了分析,并提出与传统做法不同的,只有看数据时才改变显示数据的新思路。一方面可以解决 Lookup 一直以来的一种需求,另外这种思路也可为其他的开发场景提供一些启发。