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 "./cascading-dropdown-selectors.scss"; import { Record as KTRecord } from "@kintone/rest-api-client/lib/src/client/types"; // 多段ドロップダウンメニューのプロパティインターフェース interface ICascadingDropDownProps { displayName: string; cascadingDropDown: ICascadingDropDown; } // 多段ドロップダウンメニューの設定インターフェース interface ICascadingDropDown { 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: ICascadingDropDown) => { 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 || item.index !== null) .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 CascadingDropDownAction implements IAction { name: string; actionProps: IActionProperty[]; props: ICascadingDropDownProps; constructor() { this.name = "TestDropDown"; this.actionProps = []; this.props = {} as ICascadingDropDownProps; this.register(); } async process( actionNode: IActionNode, event: any, context: IContext ): Promise { this.actionProps = actionNode.actionProps; this.props = actionNode.ActionValue as ICascadingDropDownProps; console.log(this.actionProps); console.log(this.props); const result: IActionResult = { canNext: true, result: "" }; try { if (!this.props) return result; const appId = this.props.cascadingDropDown.dropDownApp.id; await this.#handlePageState(appId); return result; } catch (error) { console.error( "CascadingDropDownAction プロセス中にエラーが発生しました:", 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.cascadingDropDown); this.#setupCascadingDropDown(currentState.type); break; } } // 多段ドロップダウンメニューを設定 #setupCascadingDropDown(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.cascadingDropDown.fieldList ); DropdownContainer.addSaveBtnEvent( pageType === "app" ? tableElement : document.getElementById("appForm-gaia")! ); DropdownContainer.render(tableElement); } register(): void { actionAddins[this.name] = this; } } new CascadingDropDownAction(); // ドロップダウンメニューコンテナの名前空間 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] ?? ""; }; }