階層化ドロップダウンの不具合修正、実装完了
This commit is contained in:
@@ -1,51 +1,3 @@
|
||||
// @import 'bootstrap/scss/bootstrap';
|
||||
@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";
|
||||
.bs-scope{
|
||||
// Required
|
||||
@import "bootstrap/scss/utilities";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap/scss/type";
|
||||
@import "bootstrap/scss/images";
|
||||
@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/grid";
|
||||
// @import "bootstrap/scss/tables";
|
||||
// @import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
// @import "bootstrap/scss/button-group";
|
||||
// @import "bootstrap/scss/nav";
|
||||
// @import "bootstrap/scss/navbar"; // Requires nav
|
||||
@import "bootstrap/scss/card";
|
||||
// @import "bootstrap/scss/breadcrumb";
|
||||
// @import "bootstrap/scss/accordion";
|
||||
// @import "bootstrap/scss/pagination";
|
||||
// @import "bootstrap/scss/badge";
|
||||
// @import "bootstrap/scss/alert";
|
||||
// @import "bootstrap/scss/progress";
|
||||
// @import "bootstrap/scss/list-group";
|
||||
@import "bootstrap/scss/close";
|
||||
// @import "bootstrap/scss/toasts";
|
||||
@import "bootstrap/scss/modal"; // Requires transitions
|
||||
// @import "bootstrap/scss/tooltip";
|
||||
@import "bootstrap/scss/popover";
|
||||
// @import "bootstrap/scss/carousel";
|
||||
@import "bootstrap/scss/spinners";
|
||||
@import "bootstrap/scss/offcanvas"; // Requires transitions
|
||||
// @import "bootstrap/scss/placeholders";
|
||||
|
||||
// Helpers
|
||||
// @import "bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "bootstrap/scss/utilities/api";
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
--bs-backdrop-zindex: 1050;
|
||||
--bs-backdrop-bg: #000;
|
||||
|
||||
47
plugin/kintone-addins/src/actions/bootstrap.scss
vendored
Normal file
47
plugin/kintone-addins/src/actions/bootstrap.scss
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
// @import 'bootstrap/scss/bootstrap';
|
||||
@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";
|
||||
.bs-scope{
|
||||
// Required
|
||||
@import "bootstrap/scss/utilities";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap/scss/type";
|
||||
@import "bootstrap/scss/images";
|
||||
@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/grid";
|
||||
// @import "bootstrap/scss/tables";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
// @import "bootstrap/scss/button-group";
|
||||
// @import "bootstrap/scss/nav";
|
||||
// @import "bootstrap/scss/navbar"; // Requires nav
|
||||
@import "bootstrap/scss/card";
|
||||
// @import "bootstrap/scss/breadcrumb";
|
||||
// @import "bootstrap/scss/accordion";
|
||||
// @import "bootstrap/scss/pagination";
|
||||
// @import "bootstrap/scss/badge";
|
||||
// @import "bootstrap/scss/alert";
|
||||
// @import "bootstrap/scss/progress";
|
||||
// @import "bootstrap/scss/list-group";
|
||||
@import "bootstrap/scss/close";
|
||||
// @import "bootstrap/scss/toasts";
|
||||
@import "bootstrap/scss/modal"; // Requires transitions
|
||||
// @import "bootstrap/scss/tooltip";
|
||||
@import "bootstrap/scss/popover";
|
||||
// @import "bootstrap/scss/carousel";
|
||||
@import "bootstrap/scss/spinners";
|
||||
@import "bootstrap/scss/offcanvas"; // Requires transitions
|
||||
// @import "bootstrap/scss/placeholders";
|
||||
|
||||
// Helpers
|
||||
// @import "bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "bootstrap/scss/utilities/api";
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@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";
|
||||
@@ -1,451 +0,0 @@
|
||||
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<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のデータが変更されていない場合、辞書を再構築する必要はありません。
|
||||
const hash = await calculateHash(records, fields);
|
||||
const storageKey = `dropdown_dictionary::${props.dropDownApp.id}_${hash}`;
|
||||
|
||||
const lsDictionary = getFromLocalStorage(storageKey);
|
||||
// ローカルストレージから辞書を取得しようとし、存在しない場合は再構築
|
||||
dictionary =
|
||||
lsDictionary || buildDropdownDictionary(records, props.fieldList);
|
||||
if (!lsDictionary) {
|
||||
saveToLocalStorage(storageKey, dictionary);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"多段ドロップダウンの初期化中にエラーが発生しました:",
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Web Crypto APIを使用してハッシュ値を計算
|
||||
const calculateHash = async (
|
||||
records: KTRecord[],
|
||||
fields: string[]
|
||||
): Promise<string> => {
|
||||
const str = JSON.stringify(records) + JSON.stringify(fields);
|
||||
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)
|
||||
.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);
|
||||
clearUpDictionary(key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
};
|
||||
|
||||
const clearUpDictionary = (key: string) => {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith("dropdown_dictionary::") && !k.endsWith(key))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
};
|
||||
|
||||
// 辞書をローカルストレージに保存
|
||||
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;
|
||||
|
||||
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 = Array.from(headerCells);
|
||||
for (const field of fieldList) {
|
||||
const index = headerCells
|
||||
.map((th) => th.textContent?.trim())
|
||||
.findIndex((label) => label === field.dropDown.label);
|
||||
if (index || index === 0) {
|
||||
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; // 値を状態に同期
|
||||
const currentLevel = getLevel(fieldCode);
|
||||
// すべての下位メニューをリセット
|
||||
columMapArray
|
||||
.filter((_, arrayIndex) => arrayIndex > currentLevel)
|
||||
.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] ?? "";
|
||||
};
|
||||
}
|
||||
18
plugin/kintone-addins/src/actions/cascading-dropdown.scss
Normal file
18
plugin/kintone-addins/src/actions/cascading-dropdown.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.alc-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, .5);
|
||||
display: flex;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.alc-loading > div {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.alc-dnone{
|
||||
display: none;
|
||||
}
|
||||
80
plugin/kintone-addins/src/actions/cascading-dropdown.ts
Normal file
80
plugin/kintone-addins/src/actions/cascading-dropdown.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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 { Snipper } from '../util/ui-helper';
|
||||
import { DropDownManager,ICascadingDropDown, IFieldList} from '../types/CascadingDropDownManager'
|
||||
import "./cascading-dropdown.scss";
|
||||
// import { Record as KTRecord } from "@kintone/rest-api-client/lib/src/client/types";
|
||||
|
||||
// 階層化ドロップダウンメニューのプロパティインターフェース
|
||||
interface ICascadingDropDownProps {
|
||||
displayName: string;
|
||||
cascadingDropDown: ICascadingDropDown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 階層化ドロップダウンのクラス実装
|
||||
*/
|
||||
export class CascadingDropDownAction implements IAction {
|
||||
name: string;
|
||||
actionProps: IActionProperty[];
|
||||
props: ICascadingDropDownProps;
|
||||
|
||||
constructor() {
|
||||
this.name = "階層化ドロップダウン";
|
||||
this.actionProps = [];
|
||||
this.props = {} as ICascadingDropDownProps;
|
||||
this.register();
|
||||
}
|
||||
|
||||
/**
|
||||
* アクションのプロセス実行
|
||||
* @param actionNode
|
||||
* @param event
|
||||
* @param context
|
||||
* @returns
|
||||
*/
|
||||
async process(
|
||||
actionNode: IActionNode,
|
||||
event: any,
|
||||
context: IContext
|
||||
): Promise<IActionResult> {
|
||||
this.actionProps = actionNode.actionProps;
|
||||
this.props = actionNode.ActionValue as ICascadingDropDownProps;
|
||||
|
||||
const result: IActionResult = { canNext: true, result: "" };
|
||||
const snipper = new Snipper("body");
|
||||
const dropDownManager= new DropDownManager(this.props.cascadingDropDown,event);
|
||||
try {
|
||||
if (!this.props) return result;
|
||||
const appId = this.props.cascadingDropDown.dropDownApp.id;
|
||||
//snipper表示
|
||||
snipper.showSpinner();
|
||||
await dropDownManager.handlePageState(appId);
|
||||
snipper.hideSpinner();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"CascadingDropDownAction プロセス中にエラーが発生しました:",
|
||||
error
|
||||
);
|
||||
context.errors.handleError(error, actionNode);
|
||||
return { canNext: false, result: "" };
|
||||
}finally{
|
||||
snipper.removeSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
register(): void {
|
||||
actionAddins[this.name] = this;
|
||||
}
|
||||
}
|
||||
|
||||
new CascadingDropDownAction();
|
||||
@@ -1,2 +1,3 @@
|
||||
import { IAction } from "../types/ActionTypes";
|
||||
import './bootstrap.scss'
|
||||
export const actionAddins :Record<string,IAction>={};
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface IField{
|
||||
type?:string;
|
||||
required?:boolean;
|
||||
options?:string;
|
||||
label?: string;
|
||||
}
|
||||
//変数のインターフェース
|
||||
export interface IVarName{
|
||||
|
||||
353
plugin/kintone-addins/src/types/CascadingDropDownManager.ts
Normal file
353
plugin/kintone-addins/src/types/CascadingDropDownManager.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import {IField,IActionNode} from "../types/ActionTypes";
|
||||
import { KintoneRestAPIClient } from "@kintone/rest-api-client";
|
||||
import { getPageState } from "../util/url";
|
||||
import $ from 'jquery';
|
||||
import { Record as KTRecord } from "@kintone/rest-api-client/lib/src/client/types";
|
||||
|
||||
|
||||
// 階層化ドロップダウンメニューの設定インターフェース
|
||||
export interface ICascadingDropDown {
|
||||
sourceApp: IApp;
|
||||
dropDownApp: IApp;
|
||||
fieldList: IFieldList[];
|
||||
}
|
||||
|
||||
// アプリケーションインターフェース
|
||||
export interface IApp {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
// フィールドリストインターフェース
|
||||
export interface IFieldList {
|
||||
source: IField;
|
||||
dropDown: IField;
|
||||
}
|
||||
|
||||
|
||||
// ドロップダウンメニューの辞書タイプ、ドロップダウンオプションの検索を高速化するために使用
|
||||
type DropdownDictionary = Record<string, string[]>;
|
||||
|
||||
// ドロップダウンメニューの処理クラス
|
||||
export class DropDownManager {
|
||||
private dictionary: DropdownDictionary = {};
|
||||
private state: Record<string, string> = {};
|
||||
private selects: Map<string, HTMLElement> = new Map();
|
||||
private columnMap: Map<HTMLElement, IFieldList> = new Map();
|
||||
private columMapValueArray: IFieldList[] = [];
|
||||
private columMapArray: [HTMLElement, IFieldList][] = [];
|
||||
private props :ICascadingDropDown;
|
||||
private event:Event;
|
||||
|
||||
constructor(props: ICascadingDropDown,event:any){
|
||||
this.props=props;
|
||||
this.event=event;
|
||||
}
|
||||
|
||||
// 初期化メソッド
|
||||
async init(): Promise<void> {
|
||||
try {
|
||||
const client = new KintoneRestAPIClient();
|
||||
const sourceAppId = this.props.sourceApp.id;
|
||||
const fields = this.props.fieldList.map((f) => f.source.code);
|
||||
const records = await client.record.getAllRecords({
|
||||
app: sourceAppId,
|
||||
fields,
|
||||
});
|
||||
this.dictionary =this.buildDropdownDictionary(records, this.props.fieldList);
|
||||
// const hash = await this.calculateHash(records, this.actionNode);
|
||||
// const storageKey = `dropdown_dictionary::${this.props.dropDownApp.id}_${hash}`;
|
||||
|
||||
// const lsDictionary = this.getFromLocalStorage(storageKey);
|
||||
// this.dictionary =
|
||||
// lsDictionary || this.buildDropdownDictionary(records, this.props.fieldList);
|
||||
// if (!lsDictionary) {
|
||||
// this.saveToLocalStorage(storageKey, this.dictionary);
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"階層化ドロップダウンの初期化中にエラーが発生しました:",
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Web Crypto APIを使用してハッシュ値を計算
|
||||
private async calculateHash(
|
||||
records: KTRecord[],
|
||||
actionNode: IActionNode
|
||||
): Promise<string> {
|
||||
const str = JSON.stringify(records) + JSON.stringify(actionNode);
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* ドロップダウンメニューの辞書を構築
|
||||
* @param records データソースのレコード
|
||||
* @param fieldList データソースの階層フィールド
|
||||
* @returns
|
||||
*/
|
||||
private 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
|
||||
.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, index, array) => {
|
||||
const { 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const dictionary: DropdownDictionary = {};
|
||||
for (const [key, set] of Object.entries(tempDictionary)) {
|
||||
dictionary[key] = Array.from(set).sort();
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
// ローカルストレージから辞書を取得
|
||||
private getFromLocalStorage(key: string): DropdownDictionary | null {
|
||||
const data = localStorage.getItem(key);
|
||||
this.clearUpDictionary(key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
// 古い辞書をクリア
|
||||
private clearUpDictionary(key: string): void {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith("dropdown_dictionary::") && !k.endsWith(key))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
}
|
||||
|
||||
// ローカルストレージに辞書を保存
|
||||
private saveToLocalStorage(key: string, data: DropdownDictionary): void {
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ページの状態を処理
|
||||
async handlePageState(appId: string): Promise<void> {
|
||||
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 this.init();
|
||||
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;
|
||||
}
|
||||
if(pageType==="app"){
|
||||
this.initDropdownContainerForList();
|
||||
}else{
|
||||
this.initDropdownContainerforDetail(tableElement);
|
||||
}
|
||||
this.renderDropdownContainer();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ドロップダウンメニューコンテナを初期化(一覧編集画面)
|
||||
* @param tableElement
|
||||
*/
|
||||
private initDropdownContainerForList(){
|
||||
const fieldLists = this.props.fieldList;
|
||||
fieldLists.forEach((fld,index,array)=>{
|
||||
const elems = kintone.app.getFieldElements(fld.dropDown.code);
|
||||
if(elems){
|
||||
const editElem = $(elems).filter(".recordlist-editcell-gaia").get(0);
|
||||
if(editElem!==undefined){
|
||||
this.columnMap.set(editElem, fld);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.columMapArray = Array.from(this.columnMap.entries());
|
||||
this.columMapValueArray = Array.from(this.columnMap.values());
|
||||
}
|
||||
|
||||
// ドロップダウンメニューコンテナを初期化(明細編集画面)
|
||||
private initDropdownContainerforDetail(tableElement: HTMLElement): void {
|
||||
const fieldList = this.props.fieldList;
|
||||
let headerCells = $(tableElement).find(".control-gaia");
|
||||
for (const field of fieldList) {
|
||||
const cell = headerCells.has(`div.control-label-gaia span:contains("${field.dropDown.label}")`).get(0);
|
||||
if(cell!==undefined){
|
||||
const valueElem = $(cell).find(".control-value-gaia").get(0);
|
||||
if(valueElem!==undefined){
|
||||
this.columnMap.set(cell, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.columMapArray = Array.from(this.columnMap.entries());
|
||||
this.columMapValueArray = Array.from(this.columnMap.values());
|
||||
}
|
||||
|
||||
// ドロップダウンメニューをレンダリング
|
||||
private renderDropdownContainer(): void {
|
||||
this.columnMap.forEach((field, cell) => {
|
||||
// const cell = cells[columnIndex];
|
||||
if (!cell) return;
|
||||
|
||||
const input = cell.querySelector<HTMLInputElement>("input");
|
||||
if (!input) return;
|
||||
|
||||
this.createSelect(input, field.dropDown.code);
|
||||
});
|
||||
}
|
||||
|
||||
// ドロップダウンメニューを作成
|
||||
private createSelect(input: HTMLInputElement, fieldCode: string): void {
|
||||
const div = document.createElement("div");
|
||||
div.className = "bs-scope";
|
||||
div.style.margin = "0.12rem";
|
||||
const select = document.createElement("select");
|
||||
select.className = "custom-dropdown form-select";
|
||||
select.dataset.field = fieldCode;
|
||||
select.addEventListener("change", (event) =>{
|
||||
this.selectorChangeHandle(fieldCode, event);
|
||||
input.value=this.state[fieldCode];
|
||||
});
|
||||
div.appendChild(select);
|
||||
this.updateOptions(fieldCode, select, input.value);
|
||||
input.parentNode?.insertBefore(div, input.nextSibling);
|
||||
input.style.display = "none";
|
||||
this.selects.set(fieldCode, div);
|
||||
|
||||
}
|
||||
|
||||
// ドロップダウンメニューのオプションを更新
|
||||
private updateOptions(
|
||||
fieldCode: string,
|
||||
initSelect?: HTMLSelectElement | undefined | null,
|
||||
value?: string
|
||||
): void {
|
||||
let select = initSelect;
|
||||
if (!initSelect) {
|
||||
select = this.selects
|
||||
.get(fieldCode)
|
||||
?.querySelector<HTMLSelectElement>("select");
|
||||
if (!select) {
|
||||
console.error(
|
||||
`フィールド ${fieldCode} のドロップダウンメニュー要素が見つかりません`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.state[fieldCode] = value!;
|
||||
}
|
||||
|
||||
const field = this.props.fieldList.find((f) => f.dropDown.code === fieldCode);
|
||||
if (!field) {
|
||||
console.error(`フィールド ${fieldCode} の設定が見つかりません`);
|
||||
throw new Error(`フィールド ${fieldCode} の設定が見つかりません`);
|
||||
}
|
||||
|
||||
const level = this.getLevel(fieldCode);
|
||||
const previousValue = this.getPreviousValueFromState(fieldCode);
|
||||
const options = this.getOptions(level, previousValue);
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 ?? this.state[fieldCode] ?? "";
|
||||
select.disabled = level > 0 && !previousValue;
|
||||
}
|
||||
|
||||
// ドロップダウンメニューが変更された後にトリガーされる関数
|
||||
private selectorChangeHandle=(fieldCode: string, event: Event)=> {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
this.state[fieldCode] = select.value;
|
||||
const currentLevel = this.getLevel(fieldCode);
|
||||
this.columMapArray
|
||||
.filter((_, arrayIndex) => arrayIndex > currentLevel)
|
||||
.forEach(([_, field]) => {
|
||||
const fieldCode = field.dropDown.code;
|
||||
this.updateOptions(fieldCode);
|
||||
const inputElem = this.selects.get(fieldCode)?.previousElementSibling as HTMLInputElement;
|
||||
if(inputElem){
|
||||
inputElem.value="";
|
||||
}
|
||||
delete this.state[fieldCode];
|
||||
});
|
||||
}
|
||||
|
||||
// フィールドのレベルを取得
|
||||
private getLevel(fieldCode: string): number {
|
||||
return this.columMapValueArray.findIndex(
|
||||
(field) => field.dropDown.code === fieldCode
|
||||
);
|
||||
}
|
||||
|
||||
// 前のレベルのフィールドの値を取得
|
||||
private getPreviousValueFromState(fieldCode: string): string {
|
||||
const currentIndex = this.getLevel(fieldCode);
|
||||
if (currentIndex <= 0) return "";
|
||||
const previousField = this.columMapValueArray[currentIndex - 1];
|
||||
return this.state[previousField.dropDown.code] ?? "";
|
||||
}
|
||||
|
||||
// 指定された階層と値のドロップダウンメニューオプションを取得
|
||||
private getOptions(level: number, value: string): string[] {
|
||||
const key = level === 0 ? "0_TOP" : this.buildKey(level - 1, value);
|
||||
return this.dictionary[key] || [];
|
||||
}
|
||||
|
||||
// ドロップダウンメニューのキーを構築
|
||||
private buildKey(level: number, value: string): string {
|
||||
return `${level}_${value}`;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import '../actions/half-full-conversion';
|
||||
import '../actions/login-user-getter';
|
||||
import '../actions/auto-lookup';
|
||||
import '../actions/field-disable';
|
||||
import '../actions/cascading-dropdown-selectors';
|
||||
import '../actions/cascading-dropdown';
|
||||
import { ActionFlow,ErrorManager,IActionFlow, IActionResult,IContext } from "./ActionTypes";
|
||||
const ShowErrorEvents:string[] = [
|
||||
"app.record.create.submit.success",
|
||||
|
||||
37
plugin/kintone-addins/src/util/ui-helper.ts
Normal file
37
plugin/kintone-addins/src/util/ui-helper.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import $ from 'jquery';
|
||||
/**
|
||||
* 画面処理中のLoadding表示
|
||||
*/
|
||||
export class Snipper {
|
||||
private spinnerElement: JQuery<HTMLElement>;;
|
||||
private container :JQuery<HTMLElement>;
|
||||
constructor(selector: string) {
|
||||
this.container = $(selector??'body');
|
||||
this.spinnerElement=this.createSpinner();
|
||||
}
|
||||
|
||||
createSpinner() {
|
||||
const html =[
|
||||
'<div class="bs-scope alc-loading alc-dnone">',
|
||||
'<div class="spinner-border" style="width: 3rem; height: 3rem;" role="status">',
|
||||
'<span class="visually-hidden"></span>',
|
||||
'</div></div>'
|
||||
].join("");
|
||||
const spinner = $(html);
|
||||
this.container.append(spinner);
|
||||
return spinner;
|
||||
}
|
||||
|
||||
removeSpinner() {
|
||||
this.spinnerElement.remove();
|
||||
}
|
||||
|
||||
showSpinner() {
|
||||
this.spinnerElement.removeClass('alc-dnone');
|
||||
}
|
||||
|
||||
hideSpinner() {
|
||||
this.spinnerElement.addClass('alc-dnone');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user