先日、 Tynder という、TypeScript/JavaScript用のスキーマ検証ライブラリを公開しました。
Tynder は、TypeScriptのサブセット+独自の拡張文法から成るDSLによって
- 型の検査
- 単独の項目の必須・値の長さ・範囲や文字列パターンの検証
- 複数項目の相関や整合性検証の一部 (Union typeによる)
を宣言的に行うことができます。
また、カスタムエラーメッセージを表示することができます。
今回はTynderを使用して、kintoneアプリのフィールド値を宣言的に検証したいと思います。
動機
kintoneは、標準で項目の必須チェック、文字列項目の文字数レンジチェック、数値項目のレンジチェック機能等を備えていますが、やや複雑な条件でのチェックを行うためには、JavaScriptによるカスタマイズを行う必要があります(例えば、条件付き必須、文字列のパターンマッチ)。
しかし、イベントハンドラ(または、分離した関数)で手続き的にチェックを記述するのは、可読性が低く、また、チェック対象のデータはフィールド型等のメタデータも含んでおり、データの階層が深く、あまり直接触りたくありません。
完成イメージ
サブテーブルを含む各フィールドに検証エラーのエラーメッセージを表示します。
以下のサンプルでは、敢えて標準の必須項目等は設定していませんが、必須などのエラーが表示されました。
設定方法
アプリの設定画面から、以下のJavaScriptファイルを追加してください。
- tynder.min.js
- lib.js (下述)
- app.js (下述)
コード
// `event.record`をメタデータ(フィールド型等)を含まない普通のデータに変換します
function mapRecord(rec) {
const ret = {};
const keys = Object.keys(rec);
const subTableMapper = x => mapRecord(x.value);
for (const k of keys) {
if (rec[k].value === void 0) {
continue;
}
switch (rec[k].type) {
case 'NUMBER':
ret[k] = typeof rec[k].value === 'string' ?
Number(rec[k].value.replace(/[,]/g, '')) :
rec[k].value;
if (Number.isNaN(ret[k])) {
ret[k] = null;
}
break;
case 'SUBTABLE':
ret[k] = rec[k].value.map(subTableMapper);
break;
default:
ret[k] = rec[k].value;
break;
}
}
return ret;
}
// サブテーブルのブランク行を削除します(新規行のみ)
function removeBlankTableRow(rec, tableFieldCode) {
const validRecs = [];
for (const r of rec[tableFieldCode].value) {
if (r.id !== null) {
validRecs.push(r);
continue;
}
const keys = Object.keys(r.value);
for (const k of keys) {
const q = r.value[k];
if (q.value !== void 0 && q.value !== null && q.value !== '') {
validRecs.push(r);
break;
}
}
}
rec[tableFieldCode].value = validRecs;
return rec;
}
// フィールドにエラーメッセージを表示します
function displayValidationErrorMessages(event, ctx) {
for (const m of ctx.errors) {
const dp = m.dataPath.split('.').map(x => x.split(':').slice(-1)[0]);
const fieldCode = dp[0];
if (m.dataPath.includes('repeated).')) {
const index = /\.\(([0-9]+):repeated\)\./.exec(m.dataPath);
if (index) {
const subFieldCode = dp[dp.length - 1];
event.record[fieldCode].value[Number(index[1])].value[subFieldCode].error = m.message;
}
} else {
if (event.record[fieldCode]) {
event.record[fieldCode].error = m.message;
}
}
}
event.error = 'Validation error';
return event;
}
(function() {
"use strict";
// アプリのレコード型を定義します
// interfaceの各フィールドは、kintoneアプリのフィールドコードと一致させてください
const definition = `
/** サブテーブル */
interface Table {
itemName: string;
itemValue: number;
}
/** 全ステータス共通のフィールド */
interface AppBase {
}
/** アプリ (ステータス1) */
interface App1 extends AppBase {
@minLength(2)
name: string;
amount: number;
table: Table[];
}
// レコードのステータス毎に定義を持てば、殆どの条件付きチェックは
// 条件分岐を含まない形の分解できる
`;
// イベントハンドラ
kintone.events.on([
'app.record.create.submit',
'mobile.app.record.create.submit',
'app.record.edit.submit',
'mobile.app.record.edit.submit',
'app.record.index.edit.submit',
], function(event) {
event.record = removeBlankTableRow(event.record, 'table');
// スキーマ検証を行います
const schema = tynder.compile(definition);
const ctx = {checkAll: true, schema, stereotypes: new Map(tynder.stereotypes)};
const validated = tynder.validate(mapRecord(event.record), tynder.getType(schema, 'App1'), ctx);
if (! validated) {
const errText = JSON.stringify(ctx.errors, null, 2);
console.error(errText);
// エラーを表示します
displayValidationErrorMessages(event, ctx);
}
return event;
});
})();
追記(2020/3/2) 型情報を自動生成する
型定義をゼロから手動作成するのも面倒ですので、自動生成できるようにしました。
一覧画面にボタンを作成して、ボタンクリックで画面に表示します。
(上記アプリとは別の、空のアプリに組み込みます)
(function() {
"use strict";
const escapeString = (s) => {
return (s
.replace(/\x08/g, '\\b')
.replace(/\f/g, '\\f')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/\v/g, '\\v')
.replace(/\\/g, '\\\\')
.replace(/\'/g, '\\\'')
.replace(/\"/g, '\\\"')
.replace(/\`/g, '\\\`')
);
};
const getAppMetaInfo = (appId) => {
// returns promise
return kintone.api(kintone.api.url('/k/v1/app/form/fields', true), 'GET', {app: appId});
};
const getFields = (interfaceName, fields, nestLv) => {
let code = '';
let subTableCode = '';
const keys = Object.keys(fields);
OUTER: for (const k of keys) {
let isArray = false;
let typeName = 'any';
let decorators = '';
const field = fields[k];
switch (field.type) {
case 'STATUS':
typeName = 'string';
break;
case 'CATEGORY':
typeName = 'string';
break;
case 'NUMBER':
typeName = 'number';
break;
case 'SINGLE_LINE_TEXT':
typeName = 'string';
break;
case 'MULTI_LINE_TEXT':
typeName = 'string';
break;
case 'RICH_TEXT':
typeName = 'string';
break;
case 'LINK':
typeName = 'string';
break;
case 'DATE':
decorators += `@stereotype('lcdate')`;
typeName = 'string';
break;
case 'DATETIME':
decorators += `@stereotype('lcdatetime')`;
typeName = 'string';
break;
case 'TIME':
decorators += `@match(/^[0-2]\d:[0-5]\d$/)`;
typeName = 'string';
break;
case 'RADIO_BUTTON': case 'DROP_DOWN':
typeName = Object.keys(field.options)
.map(x => `'${escapeString(x)}'`).join(' | ');
break;
case 'CHECK_BOX': case 'MULTI_SELECT':
isArray = true;
typeName = `(${Object.keys(field.options)
.map(x => `'${escapeString(x)}'`).join(' | ')})[${field.required ? ', 1..' : ''}]`;
break;
case 'USER_SELECT':
isArray = true;
typeName = `Array<{code: string, name: string}${field.required ? ', 1..' : ''}>`;
break;
case 'GROUP_SELECT':
isArray = true;
typeName = `Array<{code: string, name: string}${field.required ? ', 1..' : ''}>`;
break;
case 'ORGANIZATION_SELECT':
isArray = true;
typeName = `Array<{code: string, name: string}${field.required ? ', 1..' : ''}>`;
break;
case 'SUBTABLE':
{
isArray = true;
typeName = field.code[0].toUpperCase() + field.code.slice(1);
const sub = getFields(typeName, field.fields, nestLv);
subTableCode += sub.subTableCode + sub.code;
typeName = `${typeName}[${field.required ? ', 1..' : ''}]`;
}
break;
case 'CALC':
case 'FILE':
case 'STATUS_ASSIGNEE':
continue OUTER;
// system fields
case 'RECORD_NUMBER':
case 'CREATED_TIME':
case 'CREATOR':
case 'UPDATED_TIME':
case 'MODIFIER':
continue OUTER;
// UI elements (no record fields)
case 'REFERENCE_TABLE':
case 'GROUP':
default:
continue OUTER;
}
if (field.maxValue) {
let v = '';
if (field.type === 'NUMBER') {
v = field.maxValue;
} else {
v = `'${escapeString(field.maxValue)}'`;
}
decorators += `${decorators ? ' ' : ''}@maxValue(${v})`;
}
if (field.minValue) {
let v = '';
if (field.type === 'NUMBER') {
v = field.minValue;
} else {
v = `'${escapeString(field.minValue)}'`;
}
decorators += `${decorators ? ' ' : ''}@minValue(${v})`;
}
if (field.maxLength) {
let v = '';
if (field.type === 'NUMBER') {
v = field.maxLength;
} else {
v = `'${escapeString(field.maxLength)}'`;
}
decorators += `${decorators ? ' ' : ''}@maxLength(${v})`;
}
if (field.minLength) {
let v = '';
if (field.type === 'NUMBER') {
v = field.minLength;
} else {
v = `'${escapeString(field.minLength)}'`;
}
decorators += `${decorators ? ' ' : ''}@minLength(${v})`;
}
code += `${''.padEnd((nestLv + 1) * 4)}${
decorators ? decorators + '\n' + ''.padEnd((nestLv + 1) * 4) : ''}${
/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field.code) ? field.code : `'${escapeString(field.code)}'`}${
(isArray || field.required) ? '' : '?'}: ${typeName};\n`;
}
code = `\n\ninterface ${interfaceName} {\n${code}}\n\n`;
return ({subTableCode, code});
};
const outputAppTypes = async (appId) => {
const metaInfo = await getAppMetaInfo(appId);
const fields = metaInfo.properties;
const {subTableCode, code} = getFields('App', fields, 0);
return subTableCode + code;
};
kintone.events.on('app.record.index.show', (event) => {
if (document.getElementById('my_index_button') !== null) {
return;
}
const myIndexButton = document.createElement('button');
myIndexButton.id = 'my_index_button';
myIndexButton.innerText = 'Display App Types';
const myAppIdInput = document.createElement('input');
myAppIdInput.id = 'my_appid_input';
myAppIdInput.value = '38'; // ← AppId
myIndexButton.onclick = async () => {
const myHeaderSpace = kintone.app.getHeaderSpaceElement();
const myListHeaderDiv = document.createElement('pre');
const code = await outputAppTypes(Number(document.getElementById('my_appid_input').value));
myListHeaderDiv.innerText = code;
myHeaderSpace.innerText = '';
myHeaderSpace.appendChild(myListHeaderDiv);
};
kintone.app.getHeaderMenuSpaceElement().appendChild(myAppIdInput);
kintone.app.getHeaderMenuSpaceElement().appendChild(myIndexButton);
});
})();
さいごに
書かなくて良いコードは書かずに、楽しくプログラミングしたいですね