diff --git a/src/components/layout/AppNavDrawer.vue b/src/components/layout/AppNavDrawer.vue index 61b93a8d08..1e0d420e8d 100644 --- a/src/components/layout/AppNavDrawer.vue +++ b/src/components/layout/AppNavDrawer.vue @@ -105,6 +105,18 @@ {{ $t('app.general.title.system') }} + + diff --git a/src/components/ui/AppNavItemExternal.vue b/src/components/ui/AppNavItemExternal.vue new file mode 100644 index 0000000000..fd39ace912 --- /dev/null +++ b/src/components/ui/AppNavItemExternal.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/locales/en.yaml b/src/locales/en.yaml index 81116bccbb..e6d2eeaba0 100644 --- a/src/locales/en.yaml +++ b/src/locales/en.yaml @@ -1243,12 +1243,15 @@ app: remove_model: Remove %{name} model msg: hint: >- - If saving as something other than %{name}, you can choose to also remove - the %{name} model + If saving as something other than '%{name}', you can choose to also remove + the '%{name}' model not_found: No existing beacon models found. tooltip: delete: Delete Model load: Load Model + external_navigation: + tooltip: + external_link: External navigation link database: btn: compact_database: Compact Database diff --git a/src/store/index.ts b/src/store/index.ts index 6f167b22d9..65cb4bb7e5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -32,6 +32,7 @@ import { sensors } from './sensors' import { database } from './database' import { analysis } from './analysis' import { afc } from './afc' +import { plugins } from './plugins' Vue.use(Vuex) @@ -64,7 +65,8 @@ export const storeOptions = { sensors, database, analysis, - afc + afc, + plugins } satisfies RootModules, mutations: {}, actions: { diff --git a/src/store/plugins/__tests__/plugins.spec.ts b/src/store/plugins/__tests__/plugins.spec.ts new file mode 100644 index 0000000000..87876dde8f --- /dev/null +++ b/src/store/plugins/__tests__/plugins.spec.ts @@ -0,0 +1,114 @@ +import { getters } from '../getters' +import { mutations } from '../mutations' +import { defaultState } from '../state' + +const mockServerFilesGet = vi.fn() + +vi.mock('@/api/httpClientActions', () => ({ + httpClientActions: { + serverFilesGet: (...args: any[]) => mockServerFilesGet(...args) + } +})) + +import { actions } from '../actions' + +describe('plugins store', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('state', () => { + it('returns default state', () => { + const state = defaultState() + expect(state.naviPoints).toEqual([]) + expect(state.naviPointsLoaded).toBe(false) + }) + }) + + describe('getters', () => { + it('getNaviPoints returns naviPoints from state', () => { + const state = { + naviPoints: [ + { title: 'Test', href: '/test', target: '_self', icon: 'mdi-test', position: 1 } + ], + naviPointsLoaded: true + } + const result = getters.getNaviPoints(state, {} as any, {} as any, {} as any) + expect(result).toEqual(state.naviPoints) + }) + + it('getNaviPointsLoaded returns loaded status from state', () => { + const state = { + naviPoints: [], + naviPointsLoaded: true + } + const result = getters.getNaviPointsLoaded(state, {} as any, {} as any, {} as any) + expect(result).toBe(true) + }) + }) + + describe('mutations', () => { + it('setReset resets state', () => { + const state = { + naviPoints: [{ title: 'Test', href: '/test', target: '_self', icon: '', position: 1 }], + naviPointsLoaded: true + } + mutations.setReset(state) + expect(state.naviPoints).toEqual([]) + expect(state.naviPointsLoaded).toBe(false) + }) + + it('setNaviPoints sets naviPoints', () => { + const state = defaultState() + const points = [ + { title: 'KlipperFleet', href: '/klipperfleet.html', target: '_self', icon: 'mdi-fleet', position: 86 } + ] + mutations.setNaviPoints(state, points) + expect(state.naviPoints).toEqual(points) + }) + + it('setNaviPointsLoaded sets loaded status', () => { + const state = defaultState() + mutations.setNaviPointsLoaded(state, true) + expect(state.naviPointsLoaded).toBe(true) + }) + }) + + describe('actions', () => { + it('reset dispatches setReset mutation', async () => { + const commit = vi.fn() + await (actions as any).reset({ commit }) + expect(commit).toHaveBeenCalledWith('setReset') + }) + + it('fetchNaviPoints fetches and sets navi points', async () => { + const mockPoints = [ + { title: 'KlipperFleet', href: '/klipperfleet.html', target: '_self', icon: '', position: 86 } + ] + + mockServerFilesGet.mockResolvedValue({ + data: mockPoints + }) + + const commit = vi.fn() + const rootState = { config: { apiUrl: 'http://localhost' } } + + await (actions as any).fetchNaviPoints({ commit, rootState } as any) + + expect(commit).toHaveBeenCalledWith('setNaviPoints', expect.any(Array)) + expect(commit).toHaveBeenCalledWith('setNaviPointsLoaded', true) + }) + + it('fetchNaviPoints handles errors gracefully', async () => { + mockServerFilesGet.mockRejectedValue(new Error('Not found')) + + const commit = vi.fn() + const rootState = { config: { apiUrl: 'http://localhost' } } + + await (actions as any).fetchNaviPoints({ commit, rootState } as any) + + expect(commit).toHaveBeenCalledWith('setNaviPointsLoaded', true) + expect(commit).not.toHaveBeenCalledWith('setNaviPoints', expect.any(Array)) + }) + }) +}) diff --git a/src/store/plugins/actions.ts b/src/store/plugins/actions.ts new file mode 100644 index 0000000000..4c6219d176 --- /dev/null +++ b/src/store/plugins/actions.ts @@ -0,0 +1,33 @@ +import type { ActionTree } from 'vuex' +import type { PluginsState } from './types' +import type { RootState } from '../types' +import { httpClientActions } from '@/api/httpClientActions' + +export const actions: ActionTree = { + async reset ({ commit }) { + commit('setReset') + }, + + async fetchNaviPoints ({ commit }) { + try { + const response = await httpClientActions.serverFilesGet('config/.theme/navi.json') + + if (Array.isArray(response?.data)) { + const points = response.data.map((item: any) => ({ + title: item.title ?? 'Unknown', + href: item.href ?? '#', + target: item.target ?? '_self', + icon: item.icon ?? '', + position: item.position ?? 999, + visible: item.visible ?? true + })) + + commit('setNaviPoints', points) + commit('setNaviPointsLoaded', true) + } + } catch (err) { + console.debug('Unable to fetch .theme/navi.json:', err) + commit('setNaviPointsLoaded', true) + } + } +} diff --git a/src/store/plugins/getters.ts b/src/store/plugins/getters.ts new file mode 100644 index 0000000000..e70e4a4021 --- /dev/null +++ b/src/store/plugins/getters.ts @@ -0,0 +1,13 @@ +import type { GetterTree } from 'vuex' +import type { PluginsState } from './types' +import type { RootState } from '../types' + +export const getters: GetterTree = { + getNaviPoints: (state) => { + return state.naviPoints + }, + + getNaviPointsLoaded: (state) => { + return state.naviPointsLoaded + } +} diff --git a/src/store/plugins/index.ts b/src/store/plugins/index.ts new file mode 100644 index 0000000000..d6a83e6382 --- /dev/null +++ b/src/store/plugins/index.ts @@ -0,0 +1,17 @@ +import type { Module } from 'vuex' +import { state } from './state' +import { getters } from './getters' +import { actions } from './actions' +import { mutations } from './mutations' +import type { PluginsState } from './types' +import type { RootState } from '../types' + +const namespaced = true + +export const plugins: Module = { + namespaced, + state, + getters, + actions, + mutations +} diff --git a/src/store/plugins/mutations.ts b/src/store/plugins/mutations.ts new file mode 100644 index 0000000000..77a7048f0b --- /dev/null +++ b/src/store/plugins/mutations.ts @@ -0,0 +1,17 @@ +import type { MutationTree } from 'vuex' +import type { PluginsState, NaviPoint } from './types' + +export const mutations: MutationTree = { + setReset (state) { + state.naviPoints = [] + state.naviPointsLoaded = false + }, + + setNaviPoints (state, points: NaviPoint[]) { + state.naviPoints = points + }, + + setNaviPointsLoaded (state, loaded: boolean) { + state.naviPointsLoaded = loaded + } +} diff --git a/src/store/plugins/state.ts b/src/store/plugins/state.ts new file mode 100644 index 0000000000..3a3c8d16e0 --- /dev/null +++ b/src/store/plugins/state.ts @@ -0,0 +1,10 @@ +import type { PluginsState } from './types' + +export const defaultState = (): PluginsState => { + return { + naviPoints: [], + naviPointsLoaded: false + } +} + +export const state = defaultState() diff --git a/src/store/plugins/types.ts b/src/store/plugins/types.ts new file mode 100644 index 0000000000..8f58869e61 --- /dev/null +++ b/src/store/plugins/types.ts @@ -0,0 +1,13 @@ +export interface NaviPoint { + title: string; + href: string; + target?: string; + icon: string; + position: number; + visible?: boolean; +} + +export interface PluginsState { + naviPoints: NaviPoint[]; + naviPointsLoaded: boolean; +} diff --git a/src/store/types.ts b/src/store/types.ts index 2f3924695f..7393bf3caf 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -26,6 +26,7 @@ import type { sensors } from './sensors' import type { database } from './database' import type { analysis } from './analysis' import type { afc } from './afc' +import type { plugins } from './plugins' import type { storeOptions } from '.' type RootModulesType = { @@ -55,7 +56,8 @@ type RootModulesType = { sensors: typeof sensors, database: typeof database, analysis: typeof analysis, - afc: typeof afc + afc: typeof afc, + plugins: typeof plugins, } type RootStateType = { diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json index bceb5bda63..c531bd5809 100644 --- a/tsconfig.vitest.json +++ b/tsconfig.vitest.json @@ -5,7 +5,9 @@ "src/**/__tests__/*", "env.d.ts", ], - "exclude": [], + "exclude": [ + "src/store/**/__tests__/*" + ], "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",