From 6a0a28418f8eb08397255c33f76a34aaf8c92f6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A6=AC?= Date: Mon, 27 Jan 2025 14:48:38 +0900 Subject: [PATCH] =?UTF-8?q?mobile=E5=AF=BE=E5=BF=9C=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/js/KintoneIndexEventHandler.mobile.ts | 328 ++++++++++++++++++ .../src/js/field-types-mobile.ts | 55 +++ .../my-kintone-plugin/src/js/mobile.ts | 46 ++- .../my-kintone-plugin/src/manifest.json | 1 + 4 files changed, 412 insertions(+), 18 deletions(-) create mode 100644 vue-project/my-kintone-plugin/src/js/KintoneIndexEventHandler.mobile.ts create mode 100644 vue-project/my-kintone-plugin/src/js/field-types-mobile.ts diff --git a/vue-project/my-kintone-plugin/src/js/KintoneIndexEventHandler.mobile.ts b/vue-project/my-kintone-plugin/src/js/KintoneIndexEventHandler.mobile.ts new file mode 100644 index 0000000..6b435cb --- /dev/null +++ b/vue-project/my-kintone-plugin/src/js/KintoneIndexEventHandler.mobile.ts @@ -0,0 +1,328 @@ +import type { FieldLayout, FieldsJoinMapping, JoinTable, Record, RecordForParameter, SavedData, WhereCondition } from "@/types/model"; +import { type OneOf, isType } from "./field-types-mobile"; +import type { ConditionValue } from "./conditions"; +declare var KintoneRestAPIClient: typeof import("@kintone/rest-api-client").KintoneRestAPIClient; +export class KintoneIndexEventHandler { + private config: SavedData; + private currentApp: string; + constructor(config: SavedData, currentApp: string) { + this.config = config; + this.currentApp = currentApp; + } + + public init(): void { + this.addButtonToView(); + } + + // ボタン追加 + private addButtonToView(): void { + const headerSpace =kintone.mobile.app.getHeaderSpaceElement(); + if (!headerSpace) { + throw new Error('このページではヘッダー要素が利用できません。'); + }; + + // ボタン追加 + if (document.getElementById('btn-data-fetch')) return; + const kuc = Kucs['1.18.0']; + const button = new kuc.Button({ + text: this.config.buttonName, + type: "submit", + id: 'btn-data-fetch', + }); + button.addEventListener('click', () => this.handleButtonClick()); + headerSpace.appendChild(button); + } + + // ボタンクリック + private handleButtonClick = async (): Promise => { + const spinner = this.showSpinner(); + try { + console.log('データ収集開始...'); + await this.execDataFectch(); + spinner.close(); + location.reload(); + } catch (error) { + spinner.close(); + const detailError = (error instanceof Error) ? "\n詳細:" + error.message : ""; + const errorMsg = `データ収集中処理中例外発生しました。${detailError}`; + console.error(errorMsg, error); + window.alert(errorMsg); + } + } + + private showSpinner() { + const kuc = Kucs['1.18.0']; + const spinner = new kuc.Spinner({ + text: 'データ収集中', + container: document.body + }); + spinner.open(); + return spinner; + } + + /** + * 検索データ取得&作成処理 + */ + private execDataFectch = async (): Promise => { + const mainTable = this.config.joinTables[0]; + const mainRecords = await this.fetchDataFromApp(mainTable); + // フィールド結合 + let mainData = mainRecords.map((record) => { + const rightRecord: Record = {}; + mainTable.fieldsMapping.forEach((mapping => { + rightRecord[this.fieldCode(mapping.rightField)] = record[this.fieldCode(mapping.leftField)] + })); + return rightRecord; + }); + const joinTables = this.config.joinTables.filter((table, index) => index > 0); + for (const table of joinTables) { + const subDatas = await this.fetchDataFromApp(table); + mainData = this.leftJoin(mainData, subDatas, table); + // console.log("LeftJoin", mainData); + }; + //現在のデータをクリアする + await this.deleteCurrentRecords(); + //データを更新する + await this.saveDataToCurrentApp(mainData); + } + /** + * Appからデータを取得する + * @param joinTable 対象アプリかられレコードを取得する + * @returns + */ + private fetchDataFromApp = async (joinTable: JoinTable): Promise => { + // Filter 条件作成 + const filter = this.getWhereCondition(joinTable.whereConditions); + //取得列を設定する + const fetchFields = joinTable.fieldsMapping.map(map => this.fieldCode(map.leftField)); + if (joinTable.table) { + fetchFields.push(joinTable.table); + } + const onFields =joinTable.onConditions.map(cond=>this.fieldCode(cond.leftField)); + onFields.forEach(fld=>{ + if(!fetchFields.includes(fld)){ + fetchFields.push(fld); + } + }); + // KintoneRESTAPI + const client = new KintoneRestAPIClient(); + const records = await client.record.getAllRecords({ + app: joinTable.app, + fields: fetchFields, + condition: filter + }); + //console.log("Data Fetch", records); + //SubTableが含まれる場合、フラットなデータに変換する + return this.convertToFlatDatas(records, joinTable.table); + + } + /** + * 絞り込み条件式作成 + * @param whereCondifions + * @returns + */ + private getWhereCondition(whereCondifions: WhereCondition[]): string { + const conds = whereCondifions + .filter((cond) => this.fieldCode(cond.field) !== ''); + const condition = conds.map((cond) => { + let condition = cond.condition; + if ("subField" in cond.field && cond.field.subField) { + condition = this.mapConditionForSubField(cond.condition); + } + const condValue = this.getConditionValue(cond.field as OneOf, condition, cond.data); + return `${this.fieldCode(cond.field)} ${condition} ${condValue}`; + }).join(' and '); + return condition; + } + /** + * サブフィールドの演算子対応 + * @param condition + * @returns + */ + private mapConditionForSubField(condition: ConditionValue): ConditionValue { + switch (condition) { + case "=": + return "in"; + case "!=": + return "not in"; + default: + return condition; // 既存の条件をそのまま使用 + } + } + private getConditionValue(field: OneOf, condition: ConditionValue, data: string): string { + if (!data) return ""; + if (isType.NUMBER(field) || isType.RECORD_NUMBER(field)) { + // For numbers, return as is + return data; + } else if (isType.DATE(field)) { + // If field is DATE, format as "yyyy-MM-dd" unless it's a reserved function + if (/^\d{4}-\d{2}-\d{2}$/.test(data) || data.match(/^\w+\(.*\)$/)) { + return `"${data}"`; + } + const date = new Date(data); + return `"${date.toISOString().split('T')[0]}"`; + } else if (isType.DATETIME(field) || isType.CREATED_TIME(field) || isType.UPDATED_TIME(field)) { + // If field is DATETIME, format as "yyyy-MM-ddTHH:mm:ssZ" + if (data.match(/^\w+\(.*\)$/)) { + return `"${data}"`; + } + const dateTime = new Date(data); + return `"${dateTime.toISOString()}"`; + } else if ((condition === "in" || condition === "not in")) { + if (data.includes(",")) { + // Handle "in" and "not in" with comma-separated strings + const items = data.split(",").map(item => `"${item.trim()}"`); + return `(${items.join(",")})`; + } else { + return `("${data}")`; + } + } else { + // Default case for other types (treat as text) + return `"${data}"`; + } + } + + + /** + * fieldからコードを取得する + * @param field + * @returns + */ + private fieldCode(field: any): string { + if (!field) { + return ""; + } + if (typeof field === 'string' && field) { + return field; + } else if (typeof field === 'object' && 'code' in field) { + return field.code; + } + return ""; + } + + /** + * ネストされたサブテーブルデータをフラットなデータに変換し、親レコードを複製します。 + * @param records レコードの配列 + * @returns 変換後のフラットなレコード配列 + */ + private convertToFlatDatas(records: Record[], subTable: string): Record[] { + if (!subTable) { + return records; + } + const flattenedData: Record[] = []; + records.forEach((record) => { + // テーブルフィールドが存在するかを確認 + if (record[subTable]?.type === "SUBTABLE" && record[subTable].value.length > 0) { + // サブテーブル内の各レコードを処理 + record[subTable].value.forEach((nested: Record) => { + // 親レコードのコピーを作成 + const flatRecord = { ...record }; + + // サブテーブルフィールドを抽出してフラットな構造に追加 + Object.entries(nested.value).forEach(([key, field]) => { + flatRecord[key] = { value: field.value, type: field.type }; + }); + + // テーブルフィールドを削除 + delete flatRecord[subTable]; + + // 結果の配列に追加 + flattenedData.push(flatRecord); + }); + } else { + // サブテーブルが空の場合、親レコードをそのまま追加 + const flatRecord = { ...record }; + delete flatRecord[subTable]; + flattenedData.push(flatRecord); + } + }); + // console.log("FlatDatas=>", flattenedData); + return flattenedData; + } + + /** + * データLeftJoin処理 + * @param mainData + * @param subData + * @param onConditions + * @param fieldsMapping + * @returns + */ + private leftJoin( + mainData: Record[], + subData: Record[], + joinTable: JoinTable + ): Record[] { + const joinedRecords: Record[] = []; + mainData.forEach((mainRecord) => { + const matchedRecords = subData.filter((subRecord) => + joinTable.onConditions.every( + (cond) => mainRecord[this.fieldCode(cond.rightField)]?.value === subRecord[this.fieldCode(cond.leftField)]?.value + ) + ); + + // マッチ出来ない場合、LEFTの列のみ返す + if (!matchedRecords) { + joinedRecords.push(mainRecord); + } else { + matchedRecords.forEach((matchedRecord) => { + // フィールド結合 + const combinedRecord: Record = { ...mainRecord }; + joinTable.fieldsMapping.forEach((mapping) => { + combinedRecord[this.fieldCode(mapping.rightField)] = matchedRecord[this.fieldCode(mapping.leftField)]; + }); + joinedRecords.push(combinedRecord); + }); + } + }); + return joinedRecords; + } + + /** + * 現在アプリのすべてレコードを削除する + */ + private async deleteCurrentRecords(): Promise { + const client = new KintoneRestAPIClient(); + const currentRecords = await client.record.getAllRecords({ + app: this.currentApp, + fields: ["$id"], + }); + const deleteRecords = currentRecords.map(record => { + return { id: record.$id.value as string } + }); + await client.record.deleteAllRecords({ + app: this.currentApp, + records: deleteRecords + }); + client.record.addAllRecords + } + /** + * 結合後のデータを現在のアプリに挿入する + * @param records + */ + private async saveDataToCurrentApp(records: Record[]): Promise { + try { + const client = new KintoneRestAPIClient(); + const result = await client.record.addAllRecords({ + app: this.currentApp, + records: this.convertForUpdate(records) + }); + } catch (error) { + console.error('データ作成時エラーが発生しました:', error); + throw error; + } + } + + /** + * Recordを更新時の形式を変換する + * @param resords + * @returns + */ + private convertForUpdate(resords: Record[]): RecordForParameter[] { + return resords.map((record) => + Object.fromEntries( + Object.entries(record).map(([fieldCode, { value }]) => [fieldCode, { value }]) + ) + ); + } +} \ No newline at end of file diff --git a/vue-project/my-kintone-plugin/src/js/field-types-mobile.ts b/vue-project/my-kintone-plugin/src/js/field-types-mobile.ts new file mode 100644 index 0000000..f93e786 --- /dev/null +++ b/vue-project/my-kintone-plugin/src/js/field-types-mobile.ts @@ -0,0 +1,55 @@ +declare var KintoneRestAPIClient: typeof import("@kintone/rest-api-client").KintoneRestAPIClient; +const client = new KintoneRestAPIClient(); +export type Properties = Awaited>['properties']; +export type Layout = Awaited>['layout']; + +export type OneOf = Properties[string]; +export type FieldType = OneOf['type']; + +const typeNames = [ + 'RECORD_NUMBER', + 'CREATOR', + 'CREATED_TIME', + 'MODIFIER', + 'UPDATED_TIME', + 'CATEGORY', + 'STATUS', + 'STATUS_ASSIGNEE', + 'SINGLE_LINE_TEXT', + 'NUMBER', + 'CALC', + 'MULTI_LINE_TEXT', + 'RICH_TEXT', + 'LINK', + 'CHECK_BOX', + 'RADIO_BUTTON', + 'DROP_DOWN', + 'MULTI_SELECT', + 'FILE', + 'DATE', + 'TIME', + 'DATETIME', + 'USER_SELECT', + 'ORGANIZATION_SELECT', + 'GROUP_SELECT', + 'GROUP', + 'REFERENCE_TABLE', + 'SUBTABLE', +] as const satisfies readonly FieldType[]; + +export const types = typeNames.reduce( + (acc, name) => { + acc[name] = name; + return acc; + }, + {} as Record<(typeof typeNames)[number], (typeof typeNames)[number]>, +); + +type ExtractOneOf = Extract; +function createTypeGuard(type: T) { + return (value: OneOf): value is ExtractOneOf => value?.type === type; +} + +export const isType = Object.fromEntries( + typeNames.map((typeName) => [typeName, createTypeGuard(typeName as FieldType)]), +) as { [K in (typeof typeNames)[number]]: (value: OneOf) => value is ExtractOneOf }; diff --git a/vue-project/my-kintone-plugin/src/js/mobile.ts b/vue-project/my-kintone-plugin/src/js/mobile.ts index e88ee11..cfff614 100644 --- a/vue-project/my-kintone-plugin/src/js/mobile.ts +++ b/vue-project/my-kintone-plugin/src/js/mobile.ts @@ -1,22 +1,32 @@ +import type { Field, FieldLayout, SavedData } from "@/types/model"; +import { KintoneIndexEventHandler } from "./KintoneIndexEventHandler.mobile"; + (function (PLUGIN_ID) { - kintone.events.on('mobile.app.record.index.show', () => { - const spaceEl = kintone.mobile.app.getHeaderSpaceElement(); - if (spaceEl === null) { - throw new Error('The header element is unavailable on this page.'); + kintone.events.on('mobile.app.record.index.show', (event) => { + try{ + const setting = kintone.plugin.app.getConfig(PLUGIN_ID); + const config:SavedData = getConfig(setting); + const currentApp = kintone.mobile.app.getId()?.toString(); + if(!currentApp) return; + const handler = new KintoneIndexEventHandler(config,currentApp); + handler.init(); + }catch(error){ + const detailError =(error instanceof Error) ? "\n詳細:" + error.message : ""; + const errorMsg = `データ収集中処理中例外発生しました。${ detailError }`; + event.error = errorMsg; } - - const fragment = document.createDocumentFragment(); - const headingEl = document.createElement('h3'); - const messageEl = document.createElement('p'); - - const config = kintone.plugin.app.getConfig(PLUGIN_ID); - messageEl.textContent = config.message; - messageEl.classList.add('plugin-space-message'); - headingEl.textContent = 'Hello kintone plugin!'; - headingEl.classList.add('plugin-space-heading'); - - fragment.appendChild(headingEl); - fragment.appendChild(messageEl); - spaceEl.appendChild(fragment); + return event; }); + /** + * Config設定値を変換する + * @param setting + * @returns + */ + function getConfig(setting:any):SavedData{ + const config:SavedData={ + buttonName:setting.buttonName, + joinTables:JSON.parse(setting.joinTables) + } + return config; +} })(kintone.$PLUGIN_ID); diff --git a/vue-project/my-kintone-plugin/src/manifest.json b/vue-project/my-kintone-plugin/src/manifest.json index 0267056..4573775 100644 --- a/vue-project/my-kintone-plugin/src/manifest.json +++ b/vue-project/my-kintone-plugin/src/manifest.json @@ -38,6 +38,7 @@ }, "mobile": { "js": [ + "js/KintoneRestAPIClient.min.js", "js/kuc.min.js", "js/mobile.js" ],