カスケード式ドロップダウンメニューを追加するコード
This commit is contained in:
261
frontend/src/components/MuiltDropDownBox.vue
Normal file
261
frontend/src/components/MuiltDropDownBox.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div>
|
||||
<q-stepper v-model="step" ref="stepper" color="primary" animated flat>
|
||||
<q-step :name="1" title="データソースの設定" icon="app_registration" active-icon="app_registration"
|
||||
done-icon="app_registration" :done="step > 1">
|
||||
<div class="row justify-between items-center">
|
||||
<div>アプリの選択 :</div>
|
||||
<div>
|
||||
<a v-if="data.sourceApp?.name" class="q-mr-xs"
|
||||
:href="data.sourceApp ? `${authStore.currentDomain.kintoneUrl}/k/${data.sourceApp.appId}` : ''"
|
||||
target="_blank" title="Kiontoneへ">
|
||||
{{ data.sourceApp?.name }}
|
||||
</a>
|
||||
<div v-else>APPが選択されていない</div>
|
||||
<q-btn v-if="data.sourceApp?.name" flat color="grey" icon="clear" size="sm" padding="none"
|
||||
@click="clearSelectedApp" />
|
||||
</div>
|
||||
<q-btn outline dense label="変更" padding="xs sm" color="primary" @click="showAppDialog" />
|
||||
</div>
|
||||
|
||||
<!-- フィールド設定部分 -->
|
||||
<template v-if="data.sourceApp?.name">
|
||||
<q-separator class="q-mt-md" />
|
||||
<div class="q-my-md row justify-between items-center">
|
||||
データソースにフィールドを設定する :
|
||||
<q-btn icon="add" size="sm" padding="xs" outline color="primary"
|
||||
@click="addRow" />
|
||||
</div>
|
||||
<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>レイヤー{{ index + 1 }}データ :</div>
|
||||
<div>{{ item.source?.name }}</div>
|
||||
<q-btn-group outline>
|
||||
<q-btn outline dense label="変更" padding="xs sm" color="primary"
|
||||
@click="() => showFieldDialog(item, 'source')" />
|
||||
<q-btn outline dense label="消去" padding="xs sm" color="primary" @click="() => delRow(index)" />
|
||||
</q-btn-group>
|
||||
</div>
|
||||
</q-virtual-scroll>
|
||||
</template>
|
||||
|
||||
<!-- アプリ選択ダイアログ -->
|
||||
<ShowDialog v-model:visible="data.sourceApp.showSelectApp" name="アプリ選択" @close="closeAppDialog" min-width="50vw"
|
||||
min-height="50vh">
|
||||
<template v-slot:toolbar>
|
||||
<q-input dense debounce="300" v-model="data.sourceApp.appFilter" placeholder="検索" clearable>
|
||||
<template v-slot:before>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
<AppSelectBox ref="appDg" name="アプリ" type="single" :filter="data.sourceApp.appFilter" />
|
||||
</ShowDialog>
|
||||
</q-step>
|
||||
|
||||
<q-step :name="2" title="ドロップダウンコンポーネントの設定" icon="multiple_stop" active-icon="multiple_stop"
|
||||
done-icon="multiple_stop" :done="step > 2">
|
||||
<div class="row q-pa-sm q-col-gutter-x-md flex-center">
|
||||
<div class="col-5">データソース</div>
|
||||
<div class="col-5">ドロップダウン選択</div>
|
||||
<div class="col-2"></div>
|
||||
</div>
|
||||
<div v-for="(item) in data.fieldList" :key="item.id" class="row q-pa-sm q-col-gutter-x-md flex-center">
|
||||
<div class="col-5">{{ item.source.name }}</div>
|
||||
<div class="col-5">{{ item.dropDown?.name }}</div>
|
||||
<div class="col-2">
|
||||
<div class="row justify-end">
|
||||
<q-btn outline dense label="変更" padding="xs sm" color="primary"
|
||||
@click="() => showFieldDialog(item, 'dropDown')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<!-- ステップナビゲーション -->
|
||||
<template v-slot:navigation>
|
||||
<q-stepper-navigation>
|
||||
<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 @click="stepperNext" color="primary" :label="step === 2 ? '保存' : '次のステップ'"
|
||||
:disable="nextBtnCheck()" />
|
||||
</div>
|
||||
</q-stepper-navigation>
|
||||
</template>
|
||||
</q-stepper>
|
||||
|
||||
<!-- フィールド選択ダイアログ -->
|
||||
<template v-for="(item, index) in data.fieldList" :key="`dg${item.id}`">
|
||||
<show-dialog v-model:visible="item.sourceDg.show" name="フィールド一覧" min-width="400px">
|
||||
<template v-slot:toolbar>
|
||||
<q-input dense debounce="300" v-model="item.sourceDg.filter" placeholder="検索" clearable>
|
||||
<template v-slot:before>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
<FieldSelect name="フィールド" :appId="data.sourceApp.id" :selectedFields="item.source"
|
||||
:filter="item.sourceDg.filter" :updateSelectFields="(f) => updateSelectField(f, item, index, 'source')"
|
||||
:blackListLabel="blackListLabel" />
|
||||
</show-dialog>
|
||||
|
||||
<show-dialog v-model:visible="item.dropDownDg.show" name="フィールド一覧" min-width="400px">
|
||||
<template v-slot:toolbar>
|
||||
<q-input dense debounce="300" v-model="item.dropDownDg.filter" placeholder="検索" clearable>
|
||||
<template v-slot:before>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
<FieldSelect name="フィールド" :appId="data.dropDownApp.id" :selectedFields="item.source"
|
||||
:filter="item.dropDownDg.filter" :updateSelectFields="(f) => updateSelectField(f, item, index, 'dropDown')"
|
||||
:blackListLabel="blackListLabel" />
|
||||
</show-dialog>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, ref, watchEffect } from 'vue';
|
||||
import ShowDialog from './ShowDialog.vue';
|
||||
import AppSelectBox from './AppSelectBox.vue';
|
||||
import FieldSelect from './FieldSelect.vue';
|
||||
import { useAuthStore } from 'src/stores/useAuthStore';
|
||||
import { useFlowEditorStore } from 'src/stores/flowEditor';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MuiltDropDownBox',
|
||||
inheritAttrs: false,
|
||||
components: { ShowDialog, AppSelectBox, FieldSelect },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
finishDialogHandler: Function,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const authStore = useAuthStore();
|
||||
const flowStore = useFlowEditorStore();
|
||||
const $q = useQuasar();
|
||||
const appDg = ref();
|
||||
const stepper = ref();
|
||||
const step = ref(1);
|
||||
|
||||
const data = reactive({
|
||||
sourceApp: props.modelValue.sourceApp ?? { appFilter: '', showSelectApp: false },
|
||||
dropDownApp: props.modelValue.dropDownApp,
|
||||
fieldList: props.modelValue.fieldList ?? [],
|
||||
});
|
||||
|
||||
// アプリ関連の関数
|
||||
const showAppDialog = () => data.sourceApp.showSelectApp = true;
|
||||
|
||||
const clearSelectedApp = () => {
|
||||
data.sourceApp = { appFilter: '', showSelectApp: false };
|
||||
data.fieldList = [];
|
||||
};
|
||||
|
||||
const closeAppDialog = (val: 'OK' | 'Cancel') => {
|
||||
data.sourceApp.showSelectApp = false;
|
||||
const selected = appDg.value?.selected[0];
|
||||
if (val === 'OK' && selected) {
|
||||
if (flowStore.appInfo?.appId === selected.id) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
caption: "エラー",
|
||||
message: 'データソースを現在のアプリにすることはできません。'
|
||||
});
|
||||
} else if (selected.id !== data.sourceApp.id) {
|
||||
clearSelectedApp();
|
||||
Object.assign(data.sourceApp, { id: selected.id, name: selected.name });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// フィールド関連の関数
|
||||
const defaultRow = () => ({
|
||||
id: uuidv4(),
|
||||
source: undefined,
|
||||
dropDown: undefined,
|
||||
sourceDg: { show: false, filter: '' },
|
||||
dropDownDg: { show: false, filter: '' },
|
||||
});
|
||||
|
||||
const addRow = () => data.fieldList.push(defaultRow());
|
||||
const delRow = (index: number) => data.fieldList.splice(index, 1);
|
||||
|
||||
const showFieldDialog = (item: any, keyName: string) => item[`${keyName}Dg`].show = true;
|
||||
|
||||
const updateSelectField = (f: any, item: any, index: number, keyName: 'source' | 'dropDown') => {
|
||||
const [selected] = f.value;
|
||||
const isDuplicate = data.fieldList.some((field, idx) =>
|
||||
idx !== index && field[keyName]?.code === selected.code
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
caption: "エラー",
|
||||
message: '重複したフィールドは選択できません'
|
||||
});
|
||||
} else {
|
||||
item[keyName] = selected;
|
||||
}
|
||||
};
|
||||
|
||||
// ステッパー関連の関数
|
||||
const nextBtnCheck = () => {
|
||||
const s = step.value
|
||||
if (s === 1) {
|
||||
return !(data.sourceApp?.id && data.fieldList?.length > 0 && data.fieldList?.every(f => f.source?.name));
|
||||
} else if (s === 2) {
|
||||
return !data.fieldList?.every(f => f.dropDown?.name);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const stepperNext = () => {
|
||||
if (step.value === 2) {
|
||||
props.finishDialogHandler?.(data);
|
||||
} else {
|
||||
data.dropDownApp = { name: flowStore.appInfo?.name, id: flowStore.appInfo?.appId };
|
||||
stepper.value?.next();
|
||||
}
|
||||
};
|
||||
|
||||
// データ変更の監視
|
||||
watchEffect(() => emit('update:modelValue', data));
|
||||
|
||||
return {
|
||||
// 状態と参照
|
||||
authStore,
|
||||
data,
|
||||
step,
|
||||
stepper,
|
||||
appDg,
|
||||
|
||||
// アプリ関連の関数
|
||||
showAppDialog,
|
||||
closeAppDialog,
|
||||
clearSelectedApp,
|
||||
|
||||
// フィールド関連の関数
|
||||
addRow,
|
||||
delRow,
|
||||
showFieldDialog,
|
||||
updateSelectField,
|
||||
|
||||
// ステッパー関連の関数
|
||||
nextBtnCheck,
|
||||
stepperNext,
|
||||
|
||||
// 定数
|
||||
blackListLabel: ['レコード番号', '作業者', '更新者', '更新日時', '作成日時', '作成者', 'カテゴリー', 'ステータス'],
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
79
frontend/src/components/right/MultiDropDown.vue
Normal file
79
frontend/src/components/right/MultiDropDown.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div v-bind="$attrs">
|
||||
<q-field :label="displayName" labelColor="primary" stack-label lazy-rules="ondemand" ref="fieldRef">
|
||||
<template v-slot:control>
|
||||
<q-card flat class="full-width">
|
||||
<q-card-actions vertical>
|
||||
<q-btn color="grey-3" text-color="black" @click="() => { dgIsShow = true }">設定ドロップダウンメニュー</q-btn>
|
||||
</q-card-actions>
|
||||
<q-card-section class="text-caption">
|
||||
<div v-if="data.dropDownApp?.name">
|
||||
{{ `${data.sourceApp?.name} -> ${data.dropDownApp?.name}` }}
|
||||
</div>
|
||||
<div v-else>{{ placeholder }}</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
</q-field>
|
||||
</div>
|
||||
|
||||
<ShowDialog v-model:visible="dgIsShow" name="設定ドロップダウンメニュー" @close="closeDg" min-width="50vw" min-height="20vh" disableBtn>
|
||||
<div class="q-mb-md q-ml-md q-mr-md">
|
||||
<MultiDropDownBox v-model:model-value="data" :finishDialogHandler="finishDialogHandler" />
|
||||
</div>
|
||||
</ShowDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watchEffect } from 'vue';
|
||||
import ShowDialog from '../ShowDialog.vue';
|
||||
import MultiDropDownBox from '../MuiltDropDownBox.vue';
|
||||
|
||||
export default defineComponent({
|
||||
inheritAttrs: false,
|
||||
name: 'MultiDropDown',
|
||||
components: {
|
||||
ShowDialog,
|
||||
MultiDropDownBox
|
||||
},
|
||||
props: {
|
||||
displayName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => { ({}) }
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const dgIsShow = ref(false);
|
||||
const data = ref(props.modelValue);
|
||||
|
||||
const closeDg = (state: string) => {
|
||||
dgIsShow.value = false;
|
||||
};
|
||||
|
||||
const finishDialogHandler = (boxData) => {
|
||||
data.value = boxData
|
||||
dgIsShow.value = false
|
||||
emit('update:modelValue', data.value);
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
emit('update:modelValue', data.value);
|
||||
});
|
||||
|
||||
return {
|
||||
dgIsShow,
|
||||
closeDg,
|
||||
data,
|
||||
finishDialogHandler,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -24,6 +24,7 @@ import NumInput from './NumInput.vue';
|
||||
import DataProcessing from './DataProcessing.vue';
|
||||
import DataMapping from './DataMapping.vue';
|
||||
import AppSelect from './AppSelect.vue';
|
||||
import MultiDropDown from './MultiDropDown.vue';
|
||||
import { IActionNode,IActionProperty,IProp } from 'src/types/ActionTypes';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -41,7 +42,8 @@ export default defineComponent({
|
||||
NumInput,
|
||||
DataProcessing,
|
||||
DataMapping,
|
||||
AppSelect
|
||||
AppSelect,
|
||||
MultiDropDown
|
||||
},
|
||||
props: {
|
||||
nodeProps: {
|
||||
|
||||
7
plugin/kintone-addins/src/actions/muilt-dropdown.scss
Normal file
7
plugin/kintone-addins/src/actions/muilt-dropdown.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@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";
|
||||
438
plugin/kintone-addins/src/actions/muilt-dropdown.ts
Normal file
438
plugin/kintone-addins/src/actions/muilt-dropdown.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
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 "./muilt-dropdown.scss";
|
||||
import { Record as KTRecord } from "@kintone/rest-api-client/lib/src/client/types";
|
||||
|
||||
// 多段ドロップダウンメニューのプロパティインターフェース
|
||||
interface IMuiltDropDownProps {
|
||||
displayName: string;
|
||||
multiDropDown: IMultiDropDown;
|
||||
}
|
||||
|
||||
// 多段ドロップダウンメニューの設定インターフェース
|
||||
interface IMultiDropDown {
|
||||
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: IMultiDropDown) => {
|
||||
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)
|
||||
.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 MuiltDropDownAction implements IAction {
|
||||
name: string;
|
||||
actionProps: IActionProperty[];
|
||||
props: IMuiltDropDownProps;
|
||||
|
||||
constructor() {
|
||||
this.name = "TestDropDown";
|
||||
this.actionProps = [];
|
||||
this.props = {} as IMuiltDropDownProps;
|
||||
this.register();
|
||||
}
|
||||
|
||||
async process(
|
||||
actionNode: IActionNode,
|
||||
event: any,
|
||||
context: IContext
|
||||
): Promise<IActionResult> {
|
||||
this.actionProps = actionNode.actionProps;
|
||||
this.props = actionNode.ActionValue as IMuiltDropDownProps;
|
||||
|
||||
const result: IActionResult = { canNext: true, result: "" };
|
||||
|
||||
try {
|
||||
if (!this.props) return result;
|
||||
|
||||
const appId = this.props.multiDropDown.dropDownApp.id;
|
||||
await this.#handlePageState(appId);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"MuiltDropDownAction プロセス中にエラーが発生しました:",
|
||||
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.multiDropDown);
|
||||
this.#setupMultiDropdown(currentState.type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 多段ドロップダウンメニューを設定
|
||||
#setupMultiDropdown(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.multiDropDown.fieldList
|
||||
);
|
||||
DropdownContainer.addSaveBtnEvent(
|
||||
pageType === "app"
|
||||
? tableElement
|
||||
: document.getElementById("appForm-gaia")
|
||||
);
|
||||
DropdownContainer.render(tableElement);
|
||||
}
|
||||
|
||||
register(): void {
|
||||
actionAddins[this.name] = this;
|
||||
}
|
||||
}
|
||||
|
||||
new MuiltDropDownAction();
|
||||
|
||||
// ドロップダウンメニューコンテナの名前空間
|
||||
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] ?? "";
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import '../actions/half-full-conversion';
|
||||
import '../actions/login-user-getter';
|
||||
import '../actions/auto-lookup';
|
||||
import '../actions/field-disable';
|
||||
import '../actions/muilt-dropdown';
|
||||
import { ActionFlow,ErrorManager,IActionFlow, IActionResult,IContext } from "./ActionTypes";
|
||||
const ShowErrorEvents:string[] = [
|
||||
"app.record.create.submit.success",
|
||||
|
||||
86
plugin/kintone-addins/src/util/url.ts
Normal file
86
plugin/kintone-addins/src/util/url.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* ページの種類を表す型
|
||||
*/
|
||||
export type PageType = 'app' | 'show' | 'edit' | 'other';
|
||||
|
||||
/**
|
||||
* showページのモードを表す型
|
||||
*/
|
||||
export type ShowPageMode = 'show' | 'edit' | null;
|
||||
|
||||
/**
|
||||
* ページの状態を表すインターフェース
|
||||
*/
|
||||
export interface PageState {
|
||||
type: PageType;
|
||||
mode: ShowPageMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* URLの特定のセグメントを取得する
|
||||
* @param url 分析対象のURL
|
||||
* @param index 取得したいセグメントのインデックス(負の数は末尾からのインデックス)
|
||||
*/
|
||||
const getSegment = (url: URL, index: number): string => {
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
return segments.at(index) ?? '';
|
||||
};
|
||||
|
||||
/**
|
||||
* URLとアプリIDに基づいて現在のページの状態を判断する
|
||||
* @param url 分析対象のURL
|
||||
* @param appId 確認するアプリID
|
||||
*/
|
||||
export const getPageState = (url: string, appId: string): PageState => {
|
||||
const parsedUrl = new URL(url);
|
||||
const lastSegment = getSegment(parsedUrl, -1);
|
||||
const secondLastSegment = getSegment(parsedUrl, -2);
|
||||
|
||||
if (lastSegment === appId && /^\d+$/.test(appId)) {
|
||||
return { type: 'app', mode: null };
|
||||
}
|
||||
|
||||
if (lastSegment.toLowerCase() === 'show' && secondLastSegment === appId && /^\d+$/.test(appId)) {
|
||||
const mode = parsedUrl.searchParams.get('mode');
|
||||
return {
|
||||
type: 'show',
|
||||
mode: (mode === 'show' || mode === 'edit') ? mode : null
|
||||
};
|
||||
}
|
||||
|
||||
if (lastSegment.toLowerCase() === 'edit' && secondLastSegment === appId && /^\d+$/.test(appId)) {
|
||||
return { type: 'edit', mode: null };
|
||||
}
|
||||
|
||||
return { type: 'other', mode: null };
|
||||
};
|
||||
|
||||
/**
|
||||
* 現在のページの状態を判断する(現在のURLを使用)
|
||||
* @param appId 確認するアプリID
|
||||
*/
|
||||
export const getCurrentPageState = (appId: string): PageState =>
|
||||
getPageState(window.location.href, appId);
|
||||
|
||||
/**
|
||||
* URLを正規化する(末尾のスラッシュを削除)
|
||||
* @param url 正規化対象のURL
|
||||
*/
|
||||
export const normalizeUrl = (url: string): string => {
|
||||
const parsedUrl = new URL(url);
|
||||
parsedUrl.pathname = parsedUrl.pathname.replace(/\/$/, '');
|
||||
return parsedUrl.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* URLを安全に解析する
|
||||
* @param url 解析対象のURL文字列
|
||||
*/
|
||||
export const safeParseUrl = (url: string): URL | null => {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user