From 04200193a846e113f4f187fe7f74eda6534c3114 Mon Sep 17 00:00:00 2001 From: Mouriya Date: Fri, 13 Sep 2024 06:25:35 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=AB=E3=82=B9=E3=82=B1=E3=83=BC=E3=83=89?= =?UTF-8?q?=E5=BC=8F=E3=83=89=E3=83=AD=E3=83=83=E3=83=97=E3=83=80=E3=82=A6?= =?UTF-8?q?=E3=83=B3=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=81=99=E3=82=8B=E3=82=B3=E3=83=BC=E3=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/MuiltDropDownBox.vue | 261 +++++++++++ .../src/components/right/MultiDropDown.vue | 79 ++++ .../src/components/right/PropertyList.vue | 4 +- .../src/actions/muilt-dropdown.scss | 7 + .../src/actions/muilt-dropdown.ts | 438 ++++++++++++++++++ .../src/types/action-process.ts | 1 + plugin/kintone-addins/src/util/url.ts | 86 ++++ 7 files changed, 875 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/MuiltDropDownBox.vue create mode 100644 frontend/src/components/right/MultiDropDown.vue create mode 100644 plugin/kintone-addins/src/actions/muilt-dropdown.scss create mode 100644 plugin/kintone-addins/src/actions/muilt-dropdown.ts create mode 100644 plugin/kintone-addins/src/util/url.ts diff --git a/frontend/src/components/MuiltDropDownBox.vue b/frontend/src/components/MuiltDropDownBox.vue new file mode 100644 index 0000000..4de11af --- /dev/null +++ b/frontend/src/components/MuiltDropDownBox.vue @@ -0,0 +1,261 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/right/MultiDropDown.vue b/frontend/src/components/right/MultiDropDown.vue new file mode 100644 index 0000000..2614ab7 --- /dev/null +++ b/frontend/src/components/right/MultiDropDown.vue @@ -0,0 +1,79 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/right/PropertyList.vue b/frontend/src/components/right/PropertyList.vue index 6519313..5784000 100644 --- a/frontend/src/components/right/PropertyList.vue +++ b/frontend/src/components/right/PropertyList.vue @@ -24,6 +24,7 @@ import NumInput from './NumInput.vue'; import DataProcessing from './DataProcessing.vue'; import DataMapping from './DataMapping.vue'; import AppSelect from './AppSelect.vue'; +import MultiDropDown from './MultiDropDown.vue'; import { IActionNode,IActionProperty,IProp } from 'src/types/ActionTypes'; export default defineComponent({ @@ -41,7 +42,8 @@ export default defineComponent({ NumInput, DataProcessing, DataMapping, - AppSelect + AppSelect, + MultiDropDown }, props: { nodeProps: { diff --git a/plugin/kintone-addins/src/actions/muilt-dropdown.scss b/plugin/kintone-addins/src/actions/muilt-dropdown.scss new file mode 100644 index 0000000..d3538ea --- /dev/null +++ b/plugin/kintone-addins/src/actions/muilt-dropdown.scss @@ -0,0 +1,7 @@ +@import "bootstrap/scss/functions"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/variables-dark"; +@import "bootstrap/scss/maps"; +@import "bootstrap/scss/mixins"; +@import "bootstrap/scss/root"; +@import "bootstrap/scss/forms"; diff --git a/plugin/kintone-addins/src/actions/muilt-dropdown.ts b/plugin/kintone-addins/src/actions/muilt-dropdown.ts new file mode 100644 index 0000000..de456e7 --- /dev/null +++ b/plugin/kintone-addins/src/actions/muilt-dropdown.ts @@ -0,0 +1,438 @@ +import { + IAction, + IActionResult, + IActionNode, + IActionProperty, + IContext, +} from "../types/ActionTypes"; +import { actionAddins } from "."; +import { KintoneRestAPIClient } from "@kintone/rest-api-client"; +import { getPageState } from "../util/url"; +import "./muilt-dropdown.scss"; +import { Record as KTRecord } from "@kintone/rest-api-client/lib/src/client/types"; + +// 多段ドロップダウンメニューのプロパティインターフェース +interface IMuiltDropDownProps { + displayName: string; + multiDropDown: IMultiDropDown; +} + +// 多段ドロップダウンメニューの設定インターフェース +interface IMultiDropDown { + sourceApp: IApp; + dropDownApp: IApp; + fieldList: IFieldList[]; +} + +// アプリケーションインターフェース +interface IApp { + name: string; + id: string; +} + +// フィールドリストインターフェース +interface IFieldList { + source: IField; + dropDown: IField; +} + +// フィールドインターフェース +interface IField { + id: string; + name: string; + type: string; + code: string; + label: string; +} + +// ドロップダウンメニューの���書タイプ、ドロップダウンオプションの検索を高速化するために使用 +// キーの形式は: ドロップダウンメニューの階層_value +// 例: 2番目の階層で結果aを選択した場合、1_aを使用して、値aの次の階層(3番目の階層)のオプションを取得できます +type DropdownDictionary = Record; + +// ドロップダウンメニューの設定 +namespace DropDownConfig { + let dictionary: DropdownDictionary; + + // ドロップダウンメニューの設定を初期化、必要な時のみ初期化 + export const init = async (props: IMultiDropDown) => { + try { + const client = new KintoneRestAPIClient(); + const sourceAppId = props.sourceApp.id; + const fields = props.fieldList.map((f) => f.source.code); + // ソースアプリケーションからすべてのレコードを取得し、ドロップダウンメニューが設定されている列のみを取得 + const records = await client.record.getAllRecords({ + app: sourceAppId, + fields, + }); + + // ドロップダウンメニューの辞書を一度構築するコストが高いため、レコードのハッシュ値を計算してキャッシュキーとして使用 + // kintoneのデータが変更されていない場合、辞書を再構築���る必要はありません + const hash = await calculateHash(records); + const storageKey = `dropdown_dictionary::${props.dropDownApp.id}_${hash}`; + + // ローカルストレージから辞書を取得しようとし、存在しない場合は再構築 + dictionary = + getFromLocalStorage(storageKey) || + buildDropdownDictionary(records, props.fieldList); + if (!getFromLocalStorage(storageKey)) { + saveToLocalStorage(storageKey, dictionary); + } + } catch (error) { + console.error( + "多段ドロップダウンの初期化中にエラーが発生しました:", + error + ); + throw error; + } + }; + + // Web Crypto APIを使用してハッシュ値を計算 + const calculateHash = async (records: KTRecord[]): Promise => { + const str = JSON.stringify(records); + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await crypto.subtle.digest("SHA-1", data); // SHA-1を使用、パフォーマンスが良く、セキュリティは不要 + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return hashHex; + }; + + // ドロップダウンメニューの辞書を構築 + const buildDropdownDictionary = ( + records: KTRecord[], + fieldList: IFieldList[] + ): DropdownDictionary => { + const tempDictionary: Record> = { "0_TOP": new Set() }; + + // フィールドコードからドロップダウンメニューの階層へのマッピングテーブルを作成 + const fieldCodeToIndexMap = new Map( + fieldList.map((field, index) => [field.source.code, index]) + ); + + // recordsを2次元配列に変換 + records + .map((record) => + Object.entries(record) + .map(([fieldCode, fieldData]) => ({ + fieldCode, + value: fieldData.value, + index: fieldCodeToIndexMap.get(fieldCode), + })) + .filter((item) => item.index !== undefined) + .sort((a, b) => a.index - b.index) + ) + .forEach((recordArray) => { + recordArray.forEach((item, i, array) => { + const { index, value, fieldCode } = item; + if (!value) return; // 値のないフィールドをスキップ + const v = value as string; + + if (index === 0) { + tempDictionary["0_TOP"].add(v); + } else { + const previousItem = array[index - 1]; + const previousKey = `${previousItem.index}_${previousItem.value}`; + tempDictionary[previousKey] = + tempDictionary[previousKey] || new Set(); + tempDictionary[previousKey].add(v); + } + }); + }); + + // Setをソートされた配列に変換 + const dictionary: DropdownDictionary = {}; + for (const [key, set] of Object.entries(tempDictionary)) { + dictionary[key] = Array.from(set).sort(); + } + + return dictionary; + }; + + // ローカルストレージから辞書を取得 + const getFromLocalStorage = (key: string): DropdownDictionary | null => { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : null; + }; + + // 辞書をローカルストレージに保存 + const saveToLocalStorage = (key: string, data: DropdownDictionary): void => { + localStorage.setItem(key, JSON.stringify(data)); + }; + + // ドロップダウンメニューのキーを構築 + export const buildKey = (level: number, value: string): string => + `${level}_${value}`; + + // 指定された階層と値のドロップダウンメニューオプションを取得 + export const getOptions = (level: number, value: string): string[] => { + const key = level === 0 ? "0_TOP" : buildKey(level -1 , value); + return dictionary[key] || []; + }; +} + +export class MuiltDropDownAction implements IAction { + name: string; + actionProps: IActionProperty[]; + props: IMuiltDropDownProps; + + constructor() { + this.name = "TestDropDown"; + this.actionProps = []; + this.props = {} as IMuiltDropDownProps; + this.register(); + } + + async process( + actionNode: IActionNode, + event: any, + context: IContext + ): Promise { + this.actionProps = actionNode.actionProps; + this.props = actionNode.ActionValue as IMuiltDropDownProps; + + const result: IActionResult = { canNext: true, result: "" }; + + try { + if (!this.props) return result; + + const appId = this.props.multiDropDown.dropDownApp.id; + await this.#handlePageState(appId); + + return result; + } catch (error) { + console.error( + "MuiltDropDownAction プロセス中にエラーが発生しました:", + error + ); + context.errors.handleError(error, actionNode); + return { canNext: false, result: "" }; + } + } + + // ページの状態を処理 + async #handlePageState(appId: string) { + + // kintoneのパスのパラメータは奇妙に#を使用しているため、ここで標準的な記号に置き換えて、後でURLツールを使用してrequestパラメータを検索しやすくします + const currentState = getPageState(window.location.href.replace('#','?'), appId); + + switch (currentState.type) { + case "app": + case "edit": + case "show": + if (currentState.type === "show" && currentState.mode !== "edit") break; + await DropDownConfig.init(this.props.multiDropDown); + this.#setupMultiDropdown(currentState.type); + break; + } + } + + // 多段ドロップダウンメニューを設定 + #setupMultiDropdown(pageType: string): void { + const tableElement = document.getElementById( + pageType === "app" ? "view-list-data-gaia" : "record-gaia" + ); + if (!tableElement) { + console.error("テーブル要素が見つかりません"); + return; + } + + DropdownContainer.init( + pageType, + tableElement, + this.props.multiDropDown.fieldList + ); + DropdownContainer.addSaveBtnEvent( + pageType === "app" + ? tableElement + : document.getElementById("appForm-gaia") + ); + DropdownContainer.render(tableElement); + } + + register(): void { + actionAddins[this.name] = this; + } +} + +new MuiltDropDownAction(); + +// ドロップダウンメニューコンテナの名前空間 +namespace DropdownContainer { + let fieldList: IFieldList[]; + let columMapValueArray: IFieldList[]; + let columMapArray: [number, IFieldList][]; + const columnMap: Map = new Map(); + const selects: Map = new Map(); + let state: Record = {}; + + // 保存ボタンのイベントを追加 + export const addSaveBtnEvent = (tableElement: HTMLElement) => { + const btn = tableElement.querySelector( + ".recordlist-save-button-gaia, .gaia-ui-actionmenu-save" + ); + if (btn) { + btn.addEventListener( + "click", + () => { + // 保存時に、選択された値を隠れた入力フィールドに同期 + Object.entries(state).forEach(([k, v]) => { + const select = selects.get(k); + if (select) { + const input = select.previousElementSibling as HTMLInputElement; + input.value = v; + } else { + console.error(`キー ${k} に対応する入力要素が見つかりません`); + } + }); + }, + { capture: true } + ); + } + }; + + // ドロップダウンメニューコンテナを初期化 + export const init = ( + pageType: string, + tableElement: Element, + fieldListInput: IFieldList[] + ): void => { + fieldList = fieldListInput; + let headerCells; + if (pageType === "app") { + // appページの場合、ユーザー定義フィールドのラベルのみを選択するための適切なセレクタがありません + const allHeaderCells = tableElement.querySelectorAll( + ".recordlist-header-cell-inner-wrapper-gaia" + ); + // 現在のセレクタは最初の要素で「レコード番号」を余分に選択し、末尾にも余分な要素があるため、干渉項目を除外する必要があります + headerCells = Array.from(allHeaderCells).slice(1, 1 + fieldList.length); + } else { + headerCells = tableElement.querySelectorAll( + ".control-label-text-gaia" + ); + } + + headerCells.forEach((th, index) => { + const label = th.textContent?.trim(); + const field = fieldList.find((f) => f.dropDown.label === label); + if (field) { + columnMap.set(index, field); + } + }); + // columnMapの作成後は変更されないため、その配列バージョンを再構築しません + columMapArray = Array.from(columnMap.entries()); + columMapValueArray = Array.from(columnMap.values()); + }; + + // ドロップダウンメニューをレンダリング + export const render = (tableElement: HTMLElement): void => { + const cells = tableElement.querySelectorAll( + ".recordlist-editcell-gaia, .control-value-gaia" + ); + + columnMap.forEach((field, columnIndex) => { + const cell = cells[columnIndex]; + if (!cell) return; + + const input = cell.querySelector("input"); + if (!input) return; + + createSelect(input, field.dropDown.code); + }); + }; + + // ドロップダウンメニューを作成 + const createSelect = (input: HTMLInputElement, fieldCode: string) => { + const div = document.createElement("div"); + div.style.margin = "0.12rem"; + const select = document.createElement("select"); + select.className = "custom-dropdown form-select"; + select.dataset.field = fieldCode; + select.addEventListener("change", (event) => + selectorChangeHandle(fieldCode, event) + ); + div.appendChild(select); + updateOptions(fieldCode, select, input.value); + input.parentNode?.insertBefore(div, input.nextSibling); + input.style.display = "none"; + selects.set(fieldCode, div); + }; + + // ドロップダウンメニューのオプションを更新 + const updateOptions = ( + fieldCode: string, + initSelect?: HTMLSelectElement, + value?: string + ): void => { + let select; + if (!initSelect) { + select = selects + .get(fieldCode) + .querySelector("select"); + if (!select) { + console.error( + `フィールド ${fieldCode} のドロップダウンメニュー要素が見つかりません` + ); + console.error(selects); + return; + } + } else { + select = initSelect; + state[fieldCode] = value; + } + + const field = fieldList.find((f) => f.dropDown.code === fieldCode); + if (!field) { + console.error(`フィールド ${fieldCode} の設定が見つかりません`); + return; + } + + const level = getLevel(fieldCode); + const previousValue = getPreviousValueFromState(fieldCode); + const options = DropDownConfig.getOptions(level, previousValue); + + // オプションリストを更新 + select.innerHTML = ''; + options.forEach((option) => { + const optionElement = document.createElement("option"); + optionElement.value = option; + optionElement.textContent = option; + select.appendChild(optionElement); + }); + + select.value = value ?? state[fieldCode] ?? ""; + select.disabled = level > 0 && !previousValue; + }; + + // ドロップダウンメニューが変更された後にトリガーされる関数 + const selectorChangeHandle = (fieldCode: string, event: Event): void => { + const select = event.target as HTMLSelectElement; + state[fieldCode] = select.value; // 値を状態に同期 + + // すべての下位メニューをリセット + columMapArray + .filter(([index, _]) => index >= getLevel(fieldCode) + 1) + .forEach(([_, field]) => { + const fieldCode = field.dropDown.code; + updateOptions(fieldCode); + ( + selects.get(fieldCode).previousElementSibling as HTMLInputElement + ).value = ""; + delete state[fieldCode]; + }); + }; + + // フィールドのレベルを取得 + const getLevel = (fieldCode: string): number => + columMapValueArray.findIndex((field) => field.dropDown.code === fieldCode); + + // 前のレベルのフィールドの値を取得 + const getPreviousValueFromState = (fieldCode: string): string => { + const currentIndex = getLevel(fieldCode); + if (currentIndex <= 0) return ""; + const previousField = columMapValueArray[currentIndex - 1]; + return state[previousField.dropDown.code] ?? ""; + }; +} diff --git a/plugin/kintone-addins/src/types/action-process.ts b/plugin/kintone-addins/src/types/action-process.ts index 7088b25..b49460f 100644 --- a/plugin/kintone-addins/src/types/action-process.ts +++ b/plugin/kintone-addins/src/types/action-process.ts @@ -22,6 +22,7 @@ import '../actions/half-full-conversion'; import '../actions/login-user-getter'; import '../actions/auto-lookup'; import '../actions/field-disable'; +import '../actions/muilt-dropdown'; import { ActionFlow,ErrorManager,IActionFlow, IActionResult,IContext } from "./ActionTypes"; const ShowErrorEvents:string[] = [ "app.record.create.submit.success", diff --git a/plugin/kintone-addins/src/util/url.ts b/plugin/kintone-addins/src/util/url.ts new file mode 100644 index 0000000..d565451 --- /dev/null +++ b/plugin/kintone-addins/src/util/url.ts @@ -0,0 +1,86 @@ +/** + * ページの種類を表す型 + */ +export type PageType = 'app' | 'show' | 'edit' | 'other'; + +/** + * showページのモードを表す型 + */ +export type ShowPageMode = 'show' | 'edit' | null; + +/** + * ページの状態を表すインターフェース + */ +export interface PageState { + type: PageType; + mode: ShowPageMode; +} + +/** + * URLの特定のセグメントを取得する + * @param url 分析対象のURL + * @param index 取得したいセグメントのインデックス(負の数は末尾からのインデックス) + */ +const getSegment = (url: URL, index: number): string => { + const segments = url.pathname.split('/').filter(Boolean); + return segments.at(index) ?? ''; +}; + +/** + * URLとアプリIDに基づいて現在のページの状態を判断する + * @param url 分析対象のURL + * @param appId 確認するアプリID + */ +export const getPageState = (url: string, appId: string): PageState => { + const parsedUrl = new URL(url); + const lastSegment = getSegment(parsedUrl, -1); + const secondLastSegment = getSegment(parsedUrl, -2); + + if (lastSegment === appId && /^\d+$/.test(appId)) { + return { type: 'app', mode: null }; + } + + if (lastSegment.toLowerCase() === 'show' && secondLastSegment === appId && /^\d+$/.test(appId)) { + const mode = parsedUrl.searchParams.get('mode'); + return { + type: 'show', + mode: (mode === 'show' || mode === 'edit') ? mode : null + }; + } + + if (lastSegment.toLowerCase() === 'edit' && secondLastSegment === appId && /^\d+$/.test(appId)) { + return { type: 'edit', mode: null }; + } + + return { type: 'other', mode: null }; +}; + +/** + * 現在のページの状態を判断する(現在のURLを使用) + * @param appId 確認するアプリID + */ +export const getCurrentPageState = (appId: string): PageState => + getPageState(window.location.href, appId); + +/** + * URLを正規化する(末尾のスラッシュを削除) + * @param url 正規化対象のURL + */ +export const normalizeUrl = (url: string): string => { + const parsedUrl = new URL(url); + parsedUrl.pathname = parsedUrl.pathname.replace(/\/$/, ''); + return parsedUrl.toString(); +}; + +/** + * URLを安全に解析する + * @param url 解析対象のURL文字列 + */ +export const safeParseUrl = (url: string): URL | null => { + try { + return new URL(url); + } catch { + return null; + } +}; +