Files
KintoneAppBuilder/plugin/kintone-addins/src/actions/cascading-dropdown-selectors.ts
2024-09-13 15:17:16 +09:00

440 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
// ドロップダウンメニューの<E383BC><E381AE><EFBFBD>書タイプ、ドロップダウンオプションの検索を高速化するために使用
// キーの形式は: ドロップダウンメニューの階層_value
// 例: 2番目の階層で結果aを選択した場合、1_aを使用して、値aの次の階層3番目の階層のオプションを取得できます
type DropdownDictionary = Record<string, string[]>;
// ドロップダウンメニューの設定
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のデータが変更されていない場合、辞書を再構築<E6A78B><E7AF89><EFBFBD>る必要はありません
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<string> => {
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<string, Set<string>> = { "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<IActionResult> {
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<number, IFieldList> = new Map();
const selects: Map<string, HTMLElement> = new Map();
let state: Record<string, string> = {};
// 保存ボタンのイベントを追加
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<HTMLElement>(
".recordlist-header-cell-inner-wrapper-gaia"
);
// 現在のセレクタは最初の要素で「レコード番号」を余分に選択し、末尾にも余分な要素があるため、干渉項目を除外する必要があります
headerCells = Array.from(allHeaderCells).slice(1, 1 + fieldList.length);
} else {
headerCells = tableElement.querySelectorAll<HTMLElement>(
".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<HTMLElement>(
".recordlist-editcell-gaia, .control-value-gaia"
);
columnMap.forEach((field, columnIndex) => {
const cell = cells[columnIndex];
if (!cell) return;
const input = cell.querySelector<HTMLInputElement>("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<HTMLSelectElement>("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 = '<option value="">選択してください</option>';
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] ?? "";
};
}