440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
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] ?? "";
|
||
};
|
||
}
|