階層化ドロップダウンの不具合修正、実装完了

This commit is contained in:
xiaozhe.ma
2024-09-24 01:01:35 +09:00
parent 843db5f10c
commit 58bf916810
13 changed files with 638 additions and 553 deletions

View File

@@ -1,17 +1,16 @@
<template> <template>
<div> <div>
<q-stepper v-model="step" ref="stepper" color="primary" animated flat> <q-stepper v-model="step" ref="stepper" color="primary" animated flat>
<q-step :name="1" title="データソースの設定" icon="app_registration" active-icon="app_registration" <q-step :name="1" title="データソースの設定" icon="app_registration" :done="step > 1">
done-icon="app_registration" :done="step > 1">
<div class="row justify-between items-center"> <div class="row justify-between items-center">
<div>アプリの選択 :</div> <div>アプリの選択 :</div>
<div> <div>
<a v-if="data.sourceApp?.name" class="q-mr-xs" <a v-if="data.sourceApp?.name" class="q-mr-xs"
:href="data.sourceApp ? `${authStore.currentDomain.kintoneUrl}/k/${data.sourceApp.appId}` : ''" :href="data.sourceApp ? `${authStore.currentDomain.kintoneUrl}/k/${data.sourceApp.id}` : ''"
target="_blank" title="Kiontoneへ"> target="_blank" title="Kiontoneへ">
{{ data.sourceApp?.name }} {{ data.sourceApp?.name }}
</a> </a>
<div v-else>APP選択されていな</div> <div v-else class="text-red">APP選択してください</div>
<q-btn v-if="data.sourceApp?.name" flat color="grey" icon="clear" size="sm" padding="none" <q-btn v-if="data.sourceApp?.name" flat color="grey" icon="clear" size="sm" padding="none"
@click="clearSelectedApp" /> @click="clearSelectedApp" />
</div> </div>
@@ -22,17 +21,17 @@
<template v-if="data.sourceApp?.name"> <template v-if="data.sourceApp?.name">
<q-separator class="q-mt-md" /> <q-separator class="q-mt-md" />
<div class="q-my-md row justify-between items-center"> <div class="q-my-md row justify-between items-center">
データソースにフィールドを設定する : データ階層を設定する :
<q-btn icon="add" size="sm" padding="xs" outline color="primary" @click="addRow" /> <q-btn icon="add" size="sm" padding="xs" outline color="primary" @click="addRow" />
</div> </div>
<q-virtual-scroll style="max-height: 13.5rem;" :items="data.fieldList" separator v-slot="{ item, index }"> <q-virtual-scroll style="max-height: 13.5rem;" :items="data.fieldList" separator v-slot="{ item, index }">
<div class="row justify-between items-center q-my-md"> <div class="row justify-between items-center q-my-md">
<div>レイヤー{{ index + 1 }}データ :</div> <div>{{ index + 1 }}階層 :</div>
<div>{{ item.source?.name }}</div> <div>{{ item.source?.name }}</div>
<q-btn-group outline> <q-btn-group outline>
<q-btn outline dense label="変更" padding="xs sm" color="primary" <q-btn outline dense label="変更" padding="xs sm" color="primary"
@click="() => showFieldDialog(item, 'source')" /> @click="() => showFieldDialog(item, 'source')" />
<q-btn outline dense label="消去" padding="xs sm" color="primary" @click="() => delRow(index)" /> <q-btn outline dense label="削除" padding="xs sm" color="primary" @click="() => delRow(index)" />
</q-btn-group> </q-btn-group>
</div> </div>
</q-virtual-scroll> </q-virtual-scroll>
@@ -52,12 +51,11 @@
</ShowDialog> </ShowDialog>
</q-step> </q-step>
<q-step :name="2" title="ドロップダウンコンポーネントの設定" icon="multiple_stop" active-icon="multiple_stop" <q-step :name="2" title="ドロップダウンフィールドの設定" icon="multiple_stop" :done="step > 2">
done-icon="multiple_stop" :done="step > 2">
<div class="row q-pa-sm q-col-gutter-x-sm flex-center"> <div class="row q-pa-sm q-col-gutter-x-sm flex-center">
<div class="col-grow row q-col-gutter-x-sm"> <div class="col-grow row q-col-gutter-x-sm">
<div class="col-6">データソース</div> <div class="col-6">データソース</div>
<div class="col-6">ドロップダウン選択</div> <div class="col-6">ドロップダウンフィールド</div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<div style="width: 88px; height: 1px;"></div> <div style="width: 88px; height: 1px;"></div>
@@ -72,9 +70,9 @@
<div class="col-auto"> <div class="col-auto">
<div class="row justify-end"> <div class="row justify-end">
<q-btn-group outline> <q-btn-group outline>
<q-btn outline dense label="変更" padding="xs sm" color="primary" <q-btn outline dense label="設定" padding="xs sm" color="primary"
@click="() => showFieldDialog(item, 'dropDown')" /> @click="() => showFieldDialog(item, 'dropDown')" />
<q-btn outline dense label="消去" padding="xs sm" color="primary" <q-btn outline dense label="クリア" padding="xs sm" color="primary"
@click="() => item.dropDown = undefined" /> @click="() => item.dropDown = undefined" />
</q-btn-group> </q-btn-group>
</div> </div>
@@ -87,7 +85,7 @@
<q-stepper-navigation> <q-stepper-navigation>
<div class="row justify-end q-mt-md"> <div class="row justify-end q-mt-md">
<q-btn v-if="step > 1" flat color="primary" @click="$refs.stepper.previous()" label="戻る" class="q-ml-sm" /> <q-btn v-if="step > 1" flat color="primary" @click="$refs.stepper.previous()" label="戻る" class="q-ml-sm" />
<q-btn @click="stepperNext" color="primary" :label="step === 2 ? '保存' : '次のステップ'" <q-btn @click="stepperNext" color="primary" :label="step === 2 ? '保存' : '次'"
:disable="nextBtnCheck()" /> :disable="nextBtnCheck()" />
</div> </div>
</q-stepper-navigation> </q-stepper-navigation>
@@ -126,7 +124,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, ref, watchEffect } from 'vue'; import { defineComponent, reactive, ref, watchEffect,watch } from 'vue';
import ShowDialog from './ShowDialog.vue'; import ShowDialog from './ShowDialog.vue';
import AppSelectBox from './AppSelectBox.vue'; import AppSelectBox from './AppSelectBox.vue';
import FieldSelect from './FieldSelect.vue'; import FieldSelect from './FieldSelect.vue';
@@ -155,22 +153,23 @@ export default defineComponent({
const stepper = ref(); const stepper = ref();
const step = ref(1); const step = ref(1);
const data = reactive({ const data =ref(props.modelValue);
sourceApp: props.modelValue.sourceApp ?? { appFilter: '', showSelectApp: false }, // const data = ref({
dropDownApp: props.modelValue.dropDownApp, // sourceApp: props.modelValue.sourceApp ?? { appFilter: '', showSelectApp: false },
fieldList: props.modelValue.fieldList ?? [], // dropDownApp: props.modelValue.dropDownApp,
}); // fieldList: props.modelValue.fieldList ?? [],
// });
// アプリ関連の関数 // アプリ関連の関数
const showAppDialog = () => data.sourceApp.showSelectApp = true; const showAppDialog = () => data.value.sourceApp.showSelectApp = true;
const clearSelectedApp = () => { const clearSelectedApp = () => {
data.sourceApp = { appFilter: '', showSelectApp: false }; data.value.sourceApp = { appFilter: '', showSelectApp: false };
data.fieldList = []; data.value.fieldList = [];
}; };
const closeAppDialog = (val: 'OK' | 'Cancel') => { const closeAppDialog = (val: 'OK' | 'Cancel') => {
data.sourceApp.showSelectApp = false; data.value.sourceApp.showSelectApp = false;
const selected = appDg.value?.selected[0]; const selected = appDg.value?.selected[0];
if (val === 'OK' && selected) { if (val === 'OK' && selected) {
if (flowStore.appInfo?.appId === selected.id) { if (flowStore.appInfo?.appId === selected.id) {
@@ -179,9 +178,9 @@ export default defineComponent({
caption: "エラー", caption: "エラー",
message: 'データソースを現在のアプリにすることはできません。' message: 'データソースを現在のアプリにすることはできません。'
}); });
} else if (selected.id !== data.sourceApp.id) { } else if (selected.id !== data.value.sourceApp.id) {
clearSelectedApp(); clearSelectedApp();
Object.assign(data.sourceApp, { id: selected.id, name: selected.name }); Object.assign(data.value.sourceApp, { id: selected.id, name: selected.name });
} }
} }
}; };
@@ -195,14 +194,14 @@ export default defineComponent({
dropDownDg: { show: false, filter: '' }, dropDownDg: { show: false, filter: '' },
}); });
const addRow = () => data.fieldList.push(defaultRow()); const addRow = () => data.value.fieldList.push(defaultRow());
const delRow = (index: number) => data.fieldList.splice(index, 1); const delRow = (index: number) => data.value.fieldList.splice(index, 1);
const showFieldDialog = (item: any, keyName: string) => item[`${keyName}Dg`].show = true; const showFieldDialog = (item: any, keyName: string) => item[`${keyName}Dg`].show = true;
const updateSelectField = (f: any, item: any, index: number, keyName: 'source' | 'dropDown') => { const updateSelectField = (f: any, item: any, index: number, keyName: 'source' | 'dropDown') => {
const [selected] = f.value; const [selected] = f.value;
const isDuplicate = data.fieldList.some((field, idx) => const isDuplicate = data.value.fieldList.some((field, idx) =>
idx !== index && (field[keyName]?.code === selected.code || field[keyName]?.label === selected.label) idx !== index && (field[keyName]?.code === selected.code || field[keyName]?.label === selected.label)
); );
@@ -221,33 +220,34 @@ export default defineComponent({
const nextBtnCheck = () => { const nextBtnCheck = () => {
const s = step.value const s = step.value
if (s === 1) { if (s === 1) {
return !(data.sourceApp?.id && data.fieldList?.length > 0 && data.fieldList?.every(f => f.source?.name)); return !(data.value.sourceApp?.id && data.value.fieldList?.length > 0 && data.value.fieldList?.every(f => f.source?.name));
} else if (s === 2) { } else if (s === 2) {
return !data.fieldList?.every(f => f.dropDown?.name); return !data.value.fieldList?.every(f => f.dropDown?.name);
} }
return true; return true;
}; };
const stepperNext = () => { const stepperNext = () => {
if (step.value === 2) { if (step.value === 2) {
props.finishDialogHandler?.(data); props.finishDialogHandler?.(data.value);
} else { } else {
data.dropDownApp = { name: flowStore.appInfo?.name, id: flowStore.appInfo?.appId }; data.value.dropDownApp = { name: flowStore.appInfo?.name, id: flowStore.appInfo?.appId };
stepper.value?.next(); stepper.value?.next();
} }
}; };
// データ変更の監視 // // データ変更の監視
watchEffect(() => emit('update:modelValue', data)); // watchEffect(() =>{
// emit('update:modelValue', data.value);
// });
return { return {
// 状態と参照 // 状態と参照
authStore, authStore,
data,
step, step,
stepper, stepper,
appDg, appDg,
data,
// アプリ関連の関数 // アプリ関連の関数
showAppDialog, showAppDialog,
closeAppDialog, closeAppDialog,

View File

@@ -4,7 +4,7 @@
<template v-slot:control> <template v-slot:control>
<q-card flat class="full-width"> <q-card flat class="full-width">
<q-card-actions vertical> <q-card-actions vertical>
<q-btn color="grey-3" text-color="black" @click="() => { dgIsShow = true }">設定ドロップダウンメニュー</q-btn> <q-btn color="grey-3" text-color="black" @click="() => { dgIsShow = true }">クリックで設定</q-btn>
</q-card-actions> </q-card-actions>
<q-card-section class="text-caption"> <q-card-section class="text-caption">
<div v-if="data.dropDownApp?.name"> <div v-if="data.dropDownApp?.name">
@@ -17,7 +17,23 @@
</q-field> </q-field>
</div> </div>
<ShowDialog v-model:visible="dgIsShow" name="設定ドロップダウンメニュー" @close="closeDg" min-width="50vw" min-height="20vh" disableBtn> <ShowDialog v-model:visible="dgIsShow" name="ドロップダウン階層化設定" @close="closeDg" min-width="50vw" min-height="20vh" disableBtn>
<template v-slot:toolbar>
<q-btn flat round dense icon="more_vert" >
<q-menu auto-close anchor="bottom start">
<q-list>
<q-item clickable @click="copySetting()">
<q-item-section avatar><q-icon name="content_copy" ></q-icon></q-item-section>
<q-item-section >コピー</q-item-section>
</q-item>
<q-item clickable @click="pasteSetting()">
<q-item-section avatar><q-icon name="content_paste" ></q-icon></q-item-section>
<q-item-section >貼り付け</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</template>
<div class="q-mb-md q-ml-md q-mr-md"> <div class="q-mb-md q-ml-md q-mr-md">
<CascadingDropDownBox v-model:model-value="data" :finishDialogHandler="finishDialogHandler" /> <CascadingDropDownBox v-model:model-value="data" :finishDialogHandler="finishDialogHandler" />
</div> </div>
@@ -52,8 +68,12 @@ export default defineComponent({
}, },
setup(props, { emit }) { setup(props, { emit }) {
const dgIsShow = ref(false); const dgIsShow = ref(false);
const data = ref(props.modelValue); // const data = ref(props.modelValue);
const data = ref({
sourceApp: props.modelValue.sourceApp ?? { appFilter: '', showSelectApp: false },
dropDownApp: props.modelValue.dropDownApp,
fieldList: props.modelValue.fieldList ?? [],
});
const closeDg = (state: string) => { const closeDg = (state: string) => {
dgIsShow.value = false; dgIsShow.value = false;
}; };
@@ -64,15 +84,49 @@ export default defineComponent({
emit('update:modelValue', data.value); emit('update:modelValue', data.value);
} }
watchEffect(() => { //設定をコピーする
emit('update:modelValue', data.value); const copySetting=()=>{
}); if (navigator.clipboard) {
const jsonData= JSON.stringify(data.value);
navigator.clipboard.writeText(jsonData).then(() => {
console.log('Text successfully copied to clipboard');
},
(err) => {
console.error('Error in copying text: ', err);
});
} else {
console.log('Clipboard API not available');
}
};
//設定を貼り付ける
const pasteSetting=async ()=>{
try {
const text = await navigator.clipboard.readText();
console.log('Text from clipboard:', text);
const jsonData=JSON.parse(text);
if('sourceApp' in jsonData && 'dropDownApp' in jsonData && 'fieldList' in jsonData){
const {sourceApp,dropDownApp, fieldList}=jsonData;
data.value.sourceApp=sourceApp;
data.value.dropDownApp=dropDownApp;
data.value.fieldList=fieldList;
}
} catch (err) {
console.error('Failed to read text from clipboard: ', err);
throw err;
}
}
// watchEffect(() => {
// emit('update:modelValue', data.value);
// });
return { return {
dgIsShow, dgIsShow,
closeDg, closeDg,
data, data,
finishDialogHandler, finishDialogHandler,
copySetting,
pasteSetting
}; };
} }
}); });

View File

@@ -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 { .modal-backdrop {
--bs-backdrop-zindex: 1050; --bs-backdrop-zindex: 1050;
--bs-backdrop-bg: #000; --bs-backdrop-bg: #000;

View 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";
}

View File

@@ -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";

View File

@@ -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] ?? "";
};
}

View 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;
}

View 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();

View File

@@ -1,2 +1,3 @@
import { IAction } from "../types/ActionTypes"; import { IAction } from "../types/ActionTypes";
import './bootstrap.scss'
export const actionAddins :Record<string,IAction>={}; export const actionAddins :Record<string,IAction>={};

View File

@@ -90,6 +90,7 @@ export interface IField{
type?:string; type?:string;
required?:boolean; required?:boolean;
options?:string; options?:string;
label?: string;
} }
//変数のインターフェース //変数のインターフェース
export interface IVarName{ export interface IVarName{

View 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}`;
}
}

View File

@@ -22,7 +22,7 @@ import '../actions/half-full-conversion';
import '../actions/login-user-getter'; import '../actions/login-user-getter';
import '../actions/auto-lookup'; import '../actions/auto-lookup';
import '../actions/field-disable'; import '../actions/field-disable';
import '../actions/cascading-dropdown-selectors'; import '../actions/cascading-dropdown';
import { ActionFlow,ErrorManager,IActionFlow, IActionResult,IContext } from "./ActionTypes"; import { ActionFlow,ErrorManager,IActionFlow, IActionResult,IContext } from "./ActionTypes";
const ShowErrorEvents:string[] = [ const ShowErrorEvents:string[] = [
"app.record.create.submit.success", "app.record.create.submit.success",

View 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');
}
}