From e817d7392f630e4f2efd3a98ff3050732f89cd0c Mon Sep 17 00:00:00 2001 From: pa Date: Thu, 12 Mar 2026 23:23:27 +0900 Subject: [PATCH] feat: add dashboard --- .gitignore | 1 + src/components/NavMenu.vue | 833 ------------------ src/components/dialogs/CustomNavDialog.vue | 156 +++- src/components/dialogs/SortableTreeNode.vue | 18 +- .../dialogs/__tests__/CustomNavDialog.test.js | 12 + src/components/nav-menu/NavMenu.vue | 443 ++++++++++ src/components/nav-menu/NavMenuFolderItem.vue | 228 +++++ src/components/nav-menu/NavMenuFooter.vue | 270 ++++++ .../{ => nav-menu}/__tests__/NavMenu.test.js | 102 ++- .../__tests__/NavMenuFolderItem.test.js | 97 ++ .../nav-menu/__tests__/NavMenuFooter.test.js | 77 ++ .../__tests__/navMenuUtils.test.js | 0 .../__tests__/useNavLayout.test.js | 95 ++ .../composables/__tests__/useNavTheme.test.js | 57 ++ .../nav-menu/composables/useNavLayout.js | 466 ++++++++++ .../nav-menu/composables/useNavTheme.js | 70 ++ src/components/{ => nav-menu}/navMenuUtils.js | 9 + src/localization/en.json | 37 + src/plugins/router.js | 8 + src/shared/constants/dashboard.js | 3 + src/shared/constants/index.js | 1 + src/stores/dashboard.js | 209 +++++ src/stores/index.js | 3 + src/views/Dashboard/Dashboard.vue | 186 ++++ .../components/DashboardEditToolbar.vue | 31 + .../Dashboard/components/DashboardPanel.vue | 89 ++ .../Dashboard/components/DashboardRow.vue | 69 ++ .../Dashboard/components/PanelSelector.vue | 52 ++ .../Dashboard/components/panelRegistry.js | 33 + src/views/Layout/MainLayout.vue | 2 +- src/views/Layout/__tests__/MainLayout.test.js | 2 +- 31 files changed, 2765 insertions(+), 894 deletions(-) delete mode 100644 src/components/NavMenu.vue create mode 100644 src/components/nav-menu/NavMenu.vue create mode 100644 src/components/nav-menu/NavMenuFolderItem.vue create mode 100644 src/components/nav-menu/NavMenuFooter.vue rename src/components/{ => nav-menu}/__tests__/NavMenu.test.js (72%) create mode 100644 src/components/nav-menu/__tests__/NavMenuFolderItem.test.js create mode 100644 src/components/nav-menu/__tests__/NavMenuFooter.test.js rename src/components/{ => nav-menu}/__tests__/navMenuUtils.test.js (100%) create mode 100644 src/components/nav-menu/composables/__tests__/useNavLayout.test.js create mode 100644 src/components/nav-menu/composables/__tests__/useNavTheme.test.js create mode 100644 src/components/nav-menu/composables/useNavLayout.js create mode 100644 src/components/nav-menu/composables/useNavTheme.js rename src/components/{ => nav-menu}/navMenuUtils.js (93%) create mode 100644 src/shared/constants/dashboard.js create mode 100644 src/stores/dashboard.js create mode 100644 src/views/Dashboard/Dashboard.vue create mode 100644 src/views/Dashboard/components/DashboardEditToolbar.vue create mode 100644 src/views/Dashboard/components/DashboardPanel.vue create mode 100644 src/views/Dashboard/components/DashboardRow.vue create mode 100644 src/views/Dashboard/components/PanelSelector.vue create mode 100644 src/views/Dashboard/components/panelRegistry.js diff --git a/.gitignore b/.gitignore index 02c3910a..4c317921 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ bun.lock AGENTS.md AI_GUIDE.md CLAUDE.md +.coverage/ \ No newline at end of file diff --git a/src/components/NavMenu.vue b/src/components/NavMenu.vue deleted file mode 100644 index b0d98926..00000000 --- a/src/components/NavMenu.vue +++ /dev/null @@ -1,833 +0,0 @@ - - - - - diff --git a/src/components/dialogs/CustomNavDialog.vue b/src/components/dialogs/CustomNavDialog.vue index 66b6e947..ea88232f 100644 --- a/src/components/dialogs/CustomNavDialog.vue +++ b/src/components/dialogs/CustomNavDialog.vue @@ -30,6 +30,8 @@ :drag-state="dragState" @edit-folder="openFolderEditor" @delete-folder="handleDeleteFolder" + @edit-dashboard="openDashboardEditor" + @delete-dashboard="handleDeleteDashboard" @hide="handleHideItem" @toggle="handleTreeToggle(item)" /> @@ -73,6 +75,9 @@ + @@ -93,7 +98,13 @@ - {{ t('nav_menu.custom_nav.edit_folder') }} + + {{ + folderEditor.editorType === 'dashboard' + ? t('nav_menu.custom_nav.edit_dashboard') + : t('nav_menu.custom_nav.edit_folder') + }} +
[] + }, + definitions: { + type: Array, + default: () => [] } }); - const emit = defineEmits(['update:visible', 'save']); + const emit = defineEmits(['update:visible', 'save', 'dashboard-created']); const { t } = useI18n(); + const dashboardStore = useDashboardStore(); + const modalStore = useModalStore(); const cloneLayout = (source) => { if (!Array.isArray(source)) return []; @@ -210,6 +229,7 @@ visible: false, isEditing: false, editingId: null, + editorType: 'folder', data: { id: '', name: '', icon: '' } }); @@ -243,49 +263,53 @@ const definitionsMap = computed(() => { const map = new Map(); - navDefinitions.forEach((def) => { + const source = props.definitions?.length ? props.definitions : navDefinitions; + source.forEach((def) => { if (def?.key) map.set(def.key, def); }); return map; }); const treeItems = computed(() => { - return localLayout.value.map((entry) => { - if (entry.type === 'folder') { - const children = (entry.items || []) - .map((key) => { - const def = definitionsMap.value.get(key); - if (!def) return null; - return { id: key, type: 'item', key, level: 1, parentId: entry.id }; - }) - .filter(Boolean); + return localLayout.value + .map((entry) => { + if (entry.type === 'folder') { + const children = (entry.items || []) + .map((key) => { + const def = definitionsMap.value.get(key); + if (!def) return null; + return { id: key, type: 'item', key, level: 1, parentId: entry.id }; + }) + .filter(Boolean); - const folderChildren = children.length - ? children - : [{ id: `${entry.id}__placeholder`, _placeholder: true, level: 1 }]; + const folderChildren = children.length + ? children + : [{ id: `${entry.id}__placeholder`, _placeholder: true, level: 1 }]; - return { - id: entry.id, - type: 'folder', - name: entry.name, - icon: entry.icon, - level: 0, - children: folderChildren - }; - } - return { id: entry.key, type: 'item', key: entry.key, level: 0 }; - }); + return { + id: entry.id, + type: 'folder', + name: entry.name, + icon: entry.icon, + level: 0, + children: folderChildren + }; + } + if (!definitionsMap.value.has(entry.key)) return null; + return { id: entry.key, type: 'item', key: entry.key, level: 0 }; + }) + .filter(Boolean); }); const expandedKeys = ref([]); const hiddenItems = computed(() => - navDefinitions + (props.definitions?.length ? props.definitions : navDefinitions) .filter((def) => hiddenKeySet.value.has(def.key)) .map((def) => ({ key: def.key, icon: def.icon, - label: t(def.labelKey) + label: def.isDashboard ? def.labelKey : t(def.labelKey) })) ); @@ -646,13 +670,31 @@ if (!entry) return; folderEditor.isEditing = true; folderEditor.editingId = folderId; + folderEditor.editorType = 'folder'; folderEditor.data = { id: entry.id, name: entry.name, icon: entry.icon }; folderEditor.visible = true; }; + const openDashboardEditor = (dashboardKey) => { + const dashboardId = String(dashboardKey || '').replace(DASHBOARD_NAV_KEY_PREFIX, ''); + const dashboard = dashboardStore.getDashboard(dashboardId); + if (!dashboard) return; + + folderEditor.isEditing = true; + folderEditor.editingId = dashboardKey; + folderEditor.editorType = 'dashboard'; + folderEditor.data = { + id: dashboardKey, + name: dashboard.name, + icon: dashboard.icon || '' + }; + folderEditor.visible = true; + }; + const handleAddFolder = () => { folderEditor.isEditing = false; folderEditor.editingId = null; + folderEditor.editorType = 'folder'; folderEditor.data = { id: createFolderId(), name: '', @@ -661,10 +703,27 @@ folderEditor.visible = true; }; - const handleFolderEditorSave = () => { + const handleAddDashboard = async () => { + const dashboard = await dashboardStore.createDashboard(t('dashboard.default_name')); + dashboardStore.setEditingDashboardId(dashboard.id); + localLayout.value.push({ + type: 'item', + key: `${DASHBOARD_NAV_KEY_PREFIX}${dashboard.id}` + }); + localLayout.value = [...localLayout.value]; + emit('dashboard-created', dashboard.id, cloneLayout(localLayout.value), [...hiddenKeySet.value]); + }; + + const handleFolderEditorSave = async () => { if (!folderEditor.data.name?.trim()) return; - if (folderEditor.isEditing) { + if (folderEditor.editorType === 'dashboard') { + const dashboardId = String(folderEditor.editingId || '').replace(DASHBOARD_NAV_KEY_PREFIX, ''); + await dashboardStore.updateDashboard(dashboardId, { + name: folderEditor.data.name.trim(), + icon: folderEditor.data.icon?.trim() || DEFAULT_DASHBOARD_ICON + }); + } else if (folderEditor.isEditing) { const entry = localLayout.value.find((e) => e.type === 'folder' && e.id === folderEditor.editingId); if (entry) { entry.name = folderEditor.data.name.trim(); @@ -689,6 +748,43 @@ folderEditor.visible = false; }; + const removeFromLayout = (key) => { + let removed = false; + for (let i = 0; i < localLayout.value.length; i++) { + const entry = localLayout.value[i]; + if (entry.type === 'item' && entry.key === key) { + localLayout.value.splice(i, 1); + removed = true; + break; + } + if (entry.type === 'folder') { + const idx = entry.items?.indexOf(key); + if (idx !== undefined && idx >= 0) { + entry.items.splice(idx, 1); + removed = true; + } + } + } + if (removed) { + hiddenKeySet.value.delete(key); + hiddenPlacement.value.delete(key); + localLayout.value = [...localLayout.value]; + } + }; + + const handleDeleteDashboard = async (dashboardKey) => { + const dashboardId = String(dashboardKey || '').replace(DASHBOARD_NAV_KEY_PREFIX, ''); + const { ok } = await modalStore.confirm({ + title: t('dashboard.confirmations.delete_title'), + description: t('dashboard.confirmations.delete_description') + }); + if (!ok) { + return; + } + await dashboardStore.deleteDashboard(dashboardId); + removeFromLayout(dashboardKey); + }; + const handleSave = () => { const cleanedLayout = localLayout.value.filter( (entry) => !(entry.type === 'folder' && (!entry.items || entry.items.length === 0)) diff --git a/src/components/dialogs/SortableTreeNode.vue b/src/components/dialogs/SortableTreeNode.vue index 7f22ba4d..1aae50af 100644 --- a/src/components/dialogs/SortableTreeNode.vue +++ b/src/components/dialogs/SortableTreeNode.vue @@ -19,7 +19,7 @@ dragState: { type: Object, default: () => ({}) } }); - const emit = defineEmits(['editFolder', 'deleteFolder', 'hide', 'toggle']); + const emit = defineEmits(['editFolder', 'deleteFolder', 'editDashboard', 'deleteDashboard', 'hide', 'toggle']); const { t } = useI18n(); @@ -27,6 +27,9 @@ const nodeValue = computed(() => props.item.value); const isFolder = computed(() => nodeValue.value?.type === 'folder'); + const isDashboard = computed(() => { + return !isFolder.value && nodeValue.value?.key?.startsWith('dashboard-'); + }); const hasChildren = computed(() => props.item.hasChildren); const level = computed(() => nodeValue.value?.level ?? 0); const nodeId = computed(() => (isFolder.value ? nodeValue.value?.id : nodeValue.value?.key)); @@ -43,7 +46,10 @@ return nodeValue.value.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder'); } const def = props.definitionsMap.get(nodeValue.value?.key); - return def ? t(def.labelKey) : nodeValue.value?.key || ''; + if (!def) { + return nodeValue.value?.key || ''; + } + return def.isDashboard ? def.labelKey : t(def.labelKey); }); const displayIcon = computed(() => { @@ -111,6 +117,14 @@ {{ t('nav_menu.custom_nav.delete_folder') }} +