4
1

More than 3 years have passed since last update.

kintoneで宣言的にフィールド値の検証を行う

Last updated at Posted at 2020-02-24

先日、 Tynder という、TypeScript/JavaScript用のスキーマ検証ライブラリを公開しました。

tynder.png

Tynder は、TypeScriptのサブセット+独自の拡張文法から成るDSLによって

  1. 型の検査
  2. 単独の項目の必須・値の長さ・範囲や文字列パターンの検証
  3. 複数項目の相関や整合性検証の一部 (Union typeによる)

宣言的に行うことができます。
また、カスタムエラーメッセージを表示することができます。

今回はTynderを使用して、kintoneアプリのフィールド値を宣言的に検証したいと思います。

動機

kintoneは、標準で項目の必須チェック、文字列項目の文字数レンジチェック、数値項目のレンジチェック機能等を備えていますが、やや複雑な条件でのチェックを行うためには、JavaScriptによるカスタマイズを行う必要があります(例えば、条件付き必須、文字列のパターンマッチ)。
しかし、イベントハンドラ(または、分離した関数)で手続き的にチェックを記述するのは、可読性が低く、また、チェック対象のデータはフィールド型等のメタデータも含んでおり、データの階層が深く、あまり直接触りたくありません。

完成イメージ

サブテーブルを含む各フィールドに検証エラーのエラーメッセージを表示します。
以下のサンプルでは、敢えて標準の必須項目等は設定していませんが、必須などのエラーが表示されました。

tynder-kintone-validation.png

設定方法

アプリの設定画面から、以下のJavaScriptファイルを追加してください。

コード

lib.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;
}
app.js
(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) 型情報を自動生成する

型定義をゼロから手動作成するのも面倒ですので、自動生成できるようにしました。
一覧画面にボタンを作成して、ボタンクリックで画面に表示します。
(上記アプリとは別の、空のアプリに組み込みます)

genTypeDef.js
(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);
});

})();

さいごに

書かなくて良いコードは書かずに、楽しくプログラミングしたいですね

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1