diff --git a/frontend/src/components/CascadingDropDownBox.vue b/frontend/src/components/CascadingDropDownBox.vue new file mode 100644 index 0000000..7e8ccf2 --- /dev/null +++ b/frontend/src/components/CascadingDropDownBox.vue @@ -0,0 +1,271 @@ + + + diff --git a/frontend/src/components/FieldSelect.vue b/frontend/src/components/FieldSelect.vue index 6b77015..4238926 100644 --- a/frontend/src/components/FieldSelect.vue +++ b/frontend/src/components/FieldSelect.vue @@ -37,6 +37,10 @@ export default { updateSelectFields: { type: Function }, + blackListLabel: { + type:Array, + default:()=>[] + } }, setup(props) { const isLoaded = ref(false); @@ -62,16 +66,25 @@ export default { app: props.appId } }); - let fields = res.data.properties; - Object.keys(fields).forEach((key,index) => { - const fld = fields[key]; - if(props.fieldTypes.length===0 || props.fieldTypes.includes(fld.type)){ - rows.push({id:index, name: fld.label || fld.code, ...fld }); - }else if(props.fieldTypes.includes("lookup") && ("lookup" in fld)){ - rows.push({id:index, name: fld.label || fld.code, ...fld }); + let fields = Object.values(res.data.properties); + for (const index in fields) { + const fld = fields[index] + if(props.blackListLabel.length > 0){ + if(!props.blackListLabel.find(blackListItem => blackListItem === fld.label)){ + if(props.fieldTypes.length===0 || props.fieldTypes.includes(fld.type)){ + rows.push({id:index, name: fld.label || fld.code, ...fld }); + }else if(props.fieldTypes.includes("lookup") && ("lookup" in fld)){ + rows.push({id:index, name: fld.label || fld.code, ...fld }); + } + } + } else { + if(props.fieldTypes.length===0 || props.fieldTypes.includes(fld.type)){ + rows.push({id:index, name: fld.label || fld.code, ...fld }); + }else if(props.fieldTypes.includes("lookup") && ("lookup" in fld)){ + rows.push({id:index, name: fld.label || fld.code, ...fld }); + } } - - }); + } isLoaded.value = true; }); diff --git a/frontend/src/components/ShowDialog.vue b/frontend/src/components/ShowDialog.vue index 7cfa524..ab6cd0a 100644 --- a/frontend/src/components/ShowDialog.vue +++ b/frontend/src/components/ShowDialog.vue @@ -11,7 +11,7 @@ - + @@ -29,7 +29,11 @@ export default { width:String, height:String, minWidth:String, - minHeight:String + minHeight:String, + disableBtn:{ + type: Boolean, + default: false + } }, emits: [ 'close' diff --git a/frontend/src/components/right/CascadingDropDown.vue b/frontend/src/components/right/CascadingDropDown.vue new file mode 100644 index 0000000..558be80 --- /dev/null +++ b/frontend/src/components/right/CascadingDropDown.vue @@ -0,0 +1,133 @@ + + + diff --git a/frontend/src/components/right/PropertyList.vue b/frontend/src/components/right/PropertyList.vue index 6519313..02c2219 100644 --- a/frontend/src/components/right/PropertyList.vue +++ b/frontend/src/components/right/PropertyList.vue @@ -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 CascadingDropDown from './CascadingDropDown.vue'; import { IActionNode,IActionProperty,IProp } from 'src/types/ActionTypes'; export default defineComponent({ @@ -41,7 +42,8 @@ export default defineComponent({ NumInput, DataProcessing, DataMapping, - AppSelect + AppSelect, + CascadingDropDown }, props: { nodeProps: { diff --git a/plugin/kintone-addins/src/actions/auto-lookup.scss b/plugin/kintone-addins/src/actions/auto-lookup.scss index dadd822..2727f99 100644 --- a/plugin/kintone-addins/src/actions/auto-lookup.scss +++ b/plugin/kintone-addins/src/actions/auto-lookup.scss @@ -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; diff --git a/plugin/kintone-addins/src/actions/bootstrap.scss b/plugin/kintone-addins/src/actions/bootstrap.scss new file mode 100644 index 0000000..8e43563 --- /dev/null +++ b/plugin/kintone-addins/src/actions/bootstrap.scss @@ -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"; +} \ No newline at end of file diff --git a/plugin/kintone-addins/src/actions/cascading-dropdown.scss b/plugin/kintone-addins/src/actions/cascading-dropdown.scss new file mode 100644 index 0000000..76ca66b --- /dev/null +++ b/plugin/kintone-addins/src/actions/cascading-dropdown.scss @@ -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; +} \ No newline at end of file diff --git a/plugin/kintone-addins/src/actions/cascading-dropdown.ts b/plugin/kintone-addins/src/actions/cascading-dropdown.ts new file mode 100644 index 0000000..089066f --- /dev/null +++ b/plugin/kintone-addins/src/actions/cascading-dropdown.ts @@ -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 { + 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(); diff --git a/plugin/kintone-addins/src/actions/index.ts b/plugin/kintone-addins/src/actions/index.ts index 1667b8e..67c49d3 100644 --- a/plugin/kintone-addins/src/actions/index.ts +++ b/plugin/kintone-addins/src/actions/index.ts @@ -1,2 +1,3 @@ import { IAction } from "../types/ActionTypes"; +import './bootstrap.scss' export const actionAddins :Record={}; diff --git a/plugin/kintone-addins/src/types/ActionTypes.ts b/plugin/kintone-addins/src/types/ActionTypes.ts index e1976c8..2693819 100644 --- a/plugin/kintone-addins/src/types/ActionTypes.ts +++ b/plugin/kintone-addins/src/types/ActionTypes.ts @@ -90,6 +90,7 @@ export interface IField{ type?:string; required?:boolean; options?:string; + label?: string; } //変数のインターフェース export interface IVarName{ diff --git a/plugin/kintone-addins/src/types/CascadingDropDownManager.ts b/plugin/kintone-addins/src/types/CascadingDropDownManager.ts new file mode 100644 index 0000000..5f91e4d --- /dev/null +++ b/plugin/kintone-addins/src/types/CascadingDropDownManager.ts @@ -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; + +// ドロップダウンメニューの処理クラス +export class DropDownManager { + private dictionary: DropdownDictionary = {}; + private state: Record = {}; + private selects: Map = new Map(); + private columnMap: Map = 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 { + 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 { + 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> = { "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 { + 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("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("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 = ''; + 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}`; + } +} \ No newline at end of file diff --git a/plugin/kintone-addins/src/types/action-process.ts b/plugin/kintone-addins/src/types/action-process.ts index acc6a40..1f18472 100644 --- a/plugin/kintone-addins/src/types/action-process.ts +++ b/plugin/kintone-addins/src/types/action-process.ts @@ -26,6 +26,7 @@ import '../actions/style-field'; import '../actions/datetime-calc'; import '../actions/end-of-month'; import '../actions/date-specified'; +import '../actions/cascading-dropdown'; import { ActionFlow,ErrorManager,IActionFlow, IActionResult,IContext } from "./ActionTypes"; const ShowErrorEvents:string[] = [ "app.record.create.submit.success", diff --git a/plugin/kintone-addins/src/util/ui-helper.ts b/plugin/kintone-addins/src/util/ui-helper.ts new file mode 100644 index 0000000..bdb015a --- /dev/null +++ b/plugin/kintone-addins/src/util/ui-helper.ts @@ -0,0 +1,37 @@ +import $ from 'jquery'; +/** + * 画面処理中のLoadding表示 + */ +export class Snipper { + private spinnerElement: JQuery;; + private container :JQuery; + constructor(selector: string) { + this.container = $(selector??'body'); + this.spinnerElement=this.createSpinner(); + } + + createSpinner() { + const html =[ + '
', + '
', + '', + '
' + ].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'); + } + } + \ No newline at end of file diff --git a/plugin/kintone-addins/src/util/url.ts b/plugin/kintone-addins/src/util/url.ts new file mode 100644 index 0000000..d565451 --- /dev/null +++ b/plugin/kintone-addins/src/util/url.ts @@ -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; + } +}; +