Skip to content

Commit 460cb35

Browse files
committed
feat(navigation): add external plugin navigation support
Add support for loading external navigation items from .theme/navi.json, enabling plugins like KlipperFleet to add sidebar navigation. - Add plugins Vuex module for managing external nav points - Add AppNavItemExternal component for external nav links - Integrate with AppNavDrawer to display plugin navigation items - Add unit tests for the plugins store module - Add i18n keys for external navigation tooltip Signed-off-by: changeme <changeme@users.noreply.github.com>
1 parent d293289 commit 460cb35

12 files changed

Lines changed: 317 additions & 5 deletions

File tree

src/components/layout/AppNavDrawer.vue

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@
105105
{{ $t('app.general.title.system') }}
106106
</app-nav-item>
107107

108+
<template v-if="naviPoints.length">
109+
<v-divider class="my-2" />
110+
<app-nav-item-external
111+
v-for="point in naviPoints"
112+
:key="point.href"
113+
:title="point.title"
114+
:href="point.href"
115+
:target="point.target"
116+
:icon="point.icon"
117+
/>
118+
</template>
119+
108120
<app-nav-item
109121
icon="$cog"
110122
to="settings"
@@ -147,12 +159,16 @@
147159
</template>
148160

149161
<script lang="ts">
150-
import { Component, Mixins, VModel } from 'vue-property-decorator'
162+
import { Component, Mixins, VModel, Watch } from 'vue-property-decorator'
151163
152164
import StateMixin from '@/mixins/state'
153165
import BrowserMixin from '@/mixins/browser'
154166
155-
@Component({})
167+
@Component({
168+
components: {
169+
AppNavItemExternal: () => import('@/components/ui/AppNavItemExternal.vue')
170+
}
171+
})
156172
export default class AppNavDrawer extends Mixins(StateMixin, BrowserMixin) {
157173
@VModel({ type: Boolean })
158174
open?: boolean
@@ -188,6 +204,27 @@ export default class AppNavDrawer extends Mixins(StateMixin, BrowserMixin) {
188204
set layoutMode (val: boolean) {
189205
this.$typedCommit('config/setLayoutMode', val)
190206
}
207+
208+
get naviPoints () {
209+
return this.$store.getters['plugins/getNaviPoints'] ?? []
210+
}
211+
212+
get naviPointsLoaded (): boolean {
213+
return this.$store.getters['plugins/getNaviPointsLoaded'] ?? false
214+
}
215+
216+
@Watch('socketConnected')
217+
onSocketConnected (connected: boolean) {
218+
if (connected && !this.naviPointsLoaded) {
219+
this.$store.dispatch('plugins/fetchNaviPoints')
220+
}
221+
}
222+
223+
mounted () {
224+
if (this.socketConnected && !this.naviPointsLoaded) {
225+
this.$store.dispatch('plugins/fetchNaviPoints')
226+
}
227+
}
191228
}
192229
</script>
193230

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<template>
2+
<v-tooltip
3+
right
4+
:disabled="isMobileViewport"
5+
>
6+
<template #activator="{ attrs, on }">
7+
<v-list-item
8+
:href="href"
9+
:target="target"
10+
link
11+
color="secondary"
12+
v-bind="attrs"
13+
v-on="on"
14+
>
15+
<v-list-item-icon>
16+
<v-icon>{{ icon || '$link' }}</v-icon>
17+
</v-list-item-icon>
18+
19+
<v-list-item-content>
20+
<v-list-item-title>{{ title }}</v-list-item-title>
21+
</v-list-item-content>
22+
</v-list-item>
23+
</template>
24+
<span>{{ title }}</span>
25+
</v-tooltip>
26+
</template>
27+
28+
<script lang="ts">
29+
import { Component as VueComponent, Prop } from 'vue-property-decorator'
30+
import { mixins } from 'vue-class-component'
31+
import BrowserMixin from '@/mixins/browser'
32+
33+
@VueComponent({})
34+
export default class AppNavItemExternal extends mixins(BrowserMixin) {
35+
@Prop({ type: String, required: true })
36+
readonly title!: string
37+
38+
@Prop({ type: String, required: true })
39+
readonly href!: string
40+
41+
@Prop({ type: String, default: '_self' })
42+
readonly target!: string
43+
44+
@Prop({ type: String, default: '' })
45+
readonly icon!: string
46+
}
47+
</script>
48+
49+
<style lang="scss" scoped>
50+
@import 'vuetify/src/styles/styles.sass';
51+
</style>

src/locales/en.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,12 +1243,15 @@ app:
12431243
remove_model: Remove %{name} model
12441244
msg:
12451245
hint: >-
1246-
If saving as something other than %{name}, you can choose to also remove
1247-
the %{name} model
1246+
If saving as something other than '%{name}', you can choose to also remove
1247+
the '%{name}' model
12481248
not_found: No existing beacon models found.
12491249
tooltip:
12501250
delete: Delete Model
12511251
load: Load Model
1252+
external_navigation:
1253+
tooltip:
1254+
external_link: External navigation link
12521255
database:
12531256
btn:
12541257
compact_database: Compact Database

src/store/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { sensors } from './sensors'
3232
import { database } from './database'
3333
import { analysis } from './analysis'
3434
import { afc } from './afc'
35+
import { plugins } from './plugins'
3536

3637
Vue.use(Vuex)
3738

@@ -64,7 +65,8 @@ export const storeOptions = {
6465
sensors,
6566
database,
6667
analysis,
67-
afc
68+
afc,
69+
plugins
6870
} satisfies RootModules,
6971
mutations: {},
7072
actions: {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { getters } from '../getters'
2+
import { mutations } from '../mutations'
3+
import { defaultState } from '../state'
4+
5+
const mockServerFilesGet = vi.fn()
6+
7+
vi.mock('@/api/httpClientActions', () => ({
8+
httpClientActions: {
9+
serverFilesGet: (...args: any[]) => mockServerFilesGet(...args)
10+
}
11+
}))
12+
13+
import { actions } from '../actions'
14+
15+
describe('plugins store', () => {
16+
beforeEach(() => {
17+
vi.clearAllMocks()
18+
})
19+
20+
describe('state', () => {
21+
it('returns default state', () => {
22+
const state = defaultState()
23+
expect(state.naviPoints).toEqual([])
24+
expect(state.naviPointsLoaded).toBe(false)
25+
})
26+
})
27+
28+
describe('getters', () => {
29+
it('getNaviPoints returns naviPoints from state', () => {
30+
const state = {
31+
naviPoints: [
32+
{ title: 'Test', href: '/test', target: '_self', icon: 'mdi-test', position: 1 }
33+
],
34+
naviPointsLoaded: true
35+
}
36+
const result = getters.getNaviPoints(state, {} as any, {} as any, {} as any)
37+
expect(result).toEqual(state.naviPoints)
38+
})
39+
40+
it('getNaviPointsLoaded returns loaded status from state', () => {
41+
const state = {
42+
naviPoints: [],
43+
naviPointsLoaded: true
44+
}
45+
const result = getters.getNaviPointsLoaded(state, {} as any, {} as any, {} as any)
46+
expect(result).toBe(true)
47+
})
48+
})
49+
50+
describe('mutations', () => {
51+
it('setReset resets state', () => {
52+
const state = {
53+
naviPoints: [{ title: 'Test', href: '/test', target: '_self', icon: '', position: 1 }],
54+
naviPointsLoaded: true
55+
}
56+
mutations.setReset(state)
57+
expect(state.naviPoints).toEqual([])
58+
expect(state.naviPointsLoaded).toBe(false)
59+
})
60+
61+
it('setNaviPoints sets naviPoints', () => {
62+
const state = defaultState()
63+
const points = [
64+
{ title: 'KlipperFleet', href: '/klipperfleet.html', target: '_self', icon: 'mdi-fleet', position: 86 }
65+
]
66+
mutations.setNaviPoints(state, points)
67+
expect(state.naviPoints).toEqual(points)
68+
})
69+
70+
it('setNaviPointsLoaded sets loaded status', () => {
71+
const state = defaultState()
72+
mutations.setNaviPointsLoaded(state, true)
73+
expect(state.naviPointsLoaded).toBe(true)
74+
})
75+
})
76+
77+
describe('actions', () => {
78+
it('reset dispatches setReset mutation', async () => {
79+
const commit = vi.fn()
80+
await (actions as any).reset({ commit })
81+
expect(commit).toHaveBeenCalledWith('setReset')
82+
})
83+
84+
it('fetchNaviPoints fetches and sets navi points', async () => {
85+
const mockPoints = [
86+
{ title: 'KlipperFleet', href: '/klipperfleet.html', target: '_self', icon: '', position: 86 }
87+
]
88+
89+
mockServerFilesGet.mockResolvedValue({
90+
data: mockPoints
91+
})
92+
93+
const commit = vi.fn()
94+
const rootState = { config: { apiUrl: 'http://localhost' } }
95+
96+
await (actions as any).fetchNaviPoints({ commit, rootState } as any)
97+
98+
expect(commit).toHaveBeenCalledWith('setNaviPoints', expect.any(Array))
99+
expect(commit).toHaveBeenCalledWith('setNaviPointsLoaded', true)
100+
})
101+
102+
it('fetchNaviPoints handles errors gracefully', async () => {
103+
mockServerFilesGet.mockRejectedValue(new Error('Not found'))
104+
105+
const commit = vi.fn()
106+
const rootState = { config: { apiUrl: 'http://localhost' } }
107+
108+
await (actions as any).fetchNaviPoints({ commit, rootState } as any)
109+
110+
expect(commit).toHaveBeenCalledWith('setNaviPointsLoaded', true)
111+
expect(commit).not.toHaveBeenCalledWith('setNaviPoints', expect.any(Array))
112+
})
113+
})
114+
})

src/store/plugins/actions.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { ActionTree } from 'vuex'
2+
import type { PluginsState } from './types'
3+
import type { RootState } from '../types'
4+
import { httpClientActions } from '@/api/httpClientActions'
5+
6+
export const actions: ActionTree<PluginsState, RootState> = {
7+
async reset ({ commit }) {
8+
commit('setReset')
9+
},
10+
11+
async fetchNaviPoints ({ commit }) {
12+
try {
13+
const response = await httpClientActions.serverFilesGet<any>('config/.theme/navi.json')
14+
15+
if (Array.isArray(response?.data)) {
16+
const points = response.data.map((item: any) => ({
17+
title: item.title ?? 'Unknown',
18+
href: item.href ?? '#',
19+
target: item.target ?? '_self',
20+
icon: item.icon ?? '',
21+
position: item.position ?? 999,
22+
visible: item.visible ?? true
23+
}))
24+
25+
commit('setNaviPoints', points)
26+
commit('setNaviPointsLoaded', true)
27+
}
28+
} catch (err) {
29+
console.debug('Unable to fetch .theme/navi.json:', err)
30+
commit('setNaviPointsLoaded', true)
31+
}
32+
}
33+
}

src/store/plugins/getters.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { GetterTree } from 'vuex'
2+
import type { PluginsState } from './types'
3+
import type { RootState } from '../types'
4+
5+
export const getters: GetterTree<PluginsState, RootState> = {
6+
getNaviPoints: (state) => {
7+
return state.naviPoints
8+
},
9+
10+
getNaviPointsLoaded: (state) => {
11+
return state.naviPointsLoaded
12+
}
13+
}

src/store/plugins/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Module } from 'vuex'
2+
import { state } from './state'
3+
import { getters } from './getters'
4+
import { actions } from './actions'
5+
import { mutations } from './mutations'
6+
import type { PluginsState } from './types'
7+
import type { RootState } from '../types'
8+
9+
const namespaced = true
10+
11+
export const plugins: Module<PluginsState, RootState> = {
12+
namespaced,
13+
state,
14+
getters,
15+
actions,
16+
mutations
17+
}

src/store/plugins/mutations.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { MutationTree } from 'vuex'
2+
import type { PluginsState, NaviPoint } from './types'
3+
4+
export const mutations: MutationTree<PluginsState> = {
5+
setReset (state) {
6+
state.naviPoints = []
7+
state.naviPointsLoaded = false
8+
},
9+
10+
setNaviPoints (state, points: NaviPoint[]) {
11+
state.naviPoints = points
12+
},
13+
14+
setNaviPointsLoaded (state, loaded: boolean) {
15+
state.naviPointsLoaded = loaded
16+
}
17+
}

src/store/plugins/state.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { PluginsState } from './types'
2+
3+
export const defaultState = (): PluginsState => {
4+
return {
5+
naviPoints: [],
6+
naviPointsLoaded: false
7+
}
8+
}
9+
10+
export const state = defaultState()

0 commit comments

Comments
 (0)