diff --git a/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.spec.ts b/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.spec.ts new file mode 100644 index 0000000..ceaecda --- /dev/null +++ b/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.spec.ts @@ -0,0 +1,252 @@ +/* eslint-disable vue/one-component-per-file */ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router' +import { defineComponent } from 'vue' +import CommandPaletteComponent from './CommandPaletteComponent.vue' +import { createLibI18n } from '@/locales' + +const StubView = defineComponent({ + template: '
', +}) + +const PrimeDialogStub = defineComponent({ + name: 'PrimeDialogStub', + props: { + visible: { + type: Boolean, + default: false, + }, + }, + emits: ['update:visible'], + template: ` +
+
+
+
+ `, +}) + +const PrimeInputTextStub = defineComponent({ + name: 'PrimeInputTextStub', + inheritAttrs: false, + props: { + modelValue: { + type: String, + default: '', + }, + }, + emits: ['update:modelValue', 'keydown'], + template: ` + + `, +}) + +const PrimeListboxStub = defineComponent({ + name: 'PrimeListboxStub', + inheritAttrs: false, + props: { + options: { + type: Array, + default: () => [], + }, + modelValue: { + type: String, + default: null, + }, + optionLabel: { + type: String, + default: 'label', + }, + optionValue: { + type: String, + default: 'value', + }, + }, + emits: ['update:modelValue'], + template: ` + + `, +}) + +function createTestRouter() { + const routes: RouteRecordRaw[] = [ + { + path: '/', + redirect: '/home', + }, + { + path: '/home', + name: 'home', + component: StubView, + meta: { + wefa: { + title: 'Home', + showInCommandPalette: true, + }, + }, + }, + { + path: '/showcase', + name: 'showcase', + component: StubView, + meta: { + wefa: { + title: 'Showcase', + showInCommandPalette: true, + }, + }, + }, + { + path: '/playground', + name: 'playground', + component: StubView, + meta: { + wefa: { + title: 'Playground', + showInCommandPalette: true, + section: 'Examples', + }, + }, + }, + { + path: '/users/:id', + name: 'userDetails', + component: StubView, + meta: { + wefa: { + title: 'User Details', + showInCommandPalette: true, + }, + }, + }, + { + path: '/hidden', + name: 'hidden', + component: StubView, + meta: { + wefa: { + title: 'Hidden', + }, + }, + }, + ] + + return createRouter({ + history: createMemoryHistory(), + routes, + }) +} + +describe('CommandPaletteComponent', () => { + let router: ReturnType + + beforeEach(async () => { + router = createTestRouter() + await router.push('/home') + await router.isReady() + }) + + function mountComponent() { + return mount(CommandPaletteComponent, { + global: { + plugins: [router, createLibI18n({})], + stubs: { + Dialog: PrimeDialogStub, + InputText: PrimeInputTextStub, + Listbox: PrimeListboxStub, + }, + }, + }) + } + + it('opens with Cmd+K and Ctrl+K', async () => { + const wrapper = mountComponent() + + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(false) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })) + await flushPromises() + + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(true) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })) + await flushPromises() + + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(false) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) + await flushPromises() + + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(true) + }) + + it('filters actions by search query', async () => { + const wrapper = mountComponent() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) + await flushPromises() + + await wrapper.get('[data-test="command-palette-search"]').setValue('play') + + expect(wrapper.text()).toContain('Playground') + expect(wrapper.text()).not.toContain('Home') + }) + + it('navigates with arrow keys and executes selected action with Enter', async () => { + const wrapper = mountComponent() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) + await flushPromises() + + const searchInput = wrapper.get('[data-test="command-palette-search"]') + await searchInput.trigger('keydown', { key: 'ArrowDown' }) + await searchInput.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(router.currentRoute.value.path).toBe('/showcase') + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(false) + }) + + it('closes on Escape', async () => { + const wrapper = mountComponent() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) + await flushPromises() + + await wrapper.get('[data-test="command-palette-search"]').trigger('keydown', { key: 'Escape' }) + await flushPromises() + + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(false) + }) + + it('only shows routes marked for command palette and skips dynamic routes', async () => { + const wrapper = mountComponent() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) + await flushPromises() + + expect(wrapper.text()).toContain('Home') + expect(wrapper.text()).toContain('Showcase') + expect(wrapper.text()).toContain('Playground') + expect(wrapper.text()).not.toContain('Hidden') + expect(wrapper.text()).not.toContain('User Details') + }) +}) diff --git a/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.vue b/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.vue new file mode 100644 index 0000000..0a8a3e5 --- /dev/null +++ b/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.vue @@ -0,0 +1,274 @@ + + + diff --git a/vue/src/components/CommandPaletteComponent/index.ts b/vue/src/components/CommandPaletteComponent/index.ts new file mode 100644 index 0000000..49289d3 --- /dev/null +++ b/vue/src/components/CommandPaletteComponent/index.ts @@ -0,0 +1 @@ +export { default as CommandPaletteComponent } from './CommandPaletteComponent.vue' diff --git a/vue/src/containers/LayoutContainer/LayoutContainer.mdx b/vue/src/containers/LayoutContainer/LayoutContainer.mdx index e7e787b..9dd3210 100644 --- a/vue/src/containers/LayoutContainer/LayoutContainer.mdx +++ b/vue/src/containers/LayoutContainer/LayoutContainer.mdx @@ -12,6 +12,7 @@ import * as LayoutContainerStories from './LayoutContainer.stories' - desktop side navigation - mobile drawer navigation - route-aware breadcrumb +- global command palette (`Cmd/Ctrl + K`) - child route content through `` The stories below focus on router metadata setup, because navigation links are generated directly from the router tree. @@ -20,7 +21,7 @@ The stories below focus on router metadata setup, because navigation links are g ### Wefa Route Metadata -Uses `meta.wefa.showInNavigation` and `meta.wefa.section`. +Uses `meta.wefa.showInNavigation`, `meta.wefa.section`, and `meta.wefa.showInCommandPalette`. @@ -34,6 +35,7 @@ Uses `meta.wefa.showInNavigation` and `meta.wefa.section`. title: 'Dashboard', icon: 'pi pi-chart-line', showInNavigation: true, + showInCommandPalette: true, section: 'Insights', }, }, @@ -109,6 +111,7 @@ const routes = [ title: 'Home', icon: 'pi pi-home', showInNavigation: true, + showInCommandPalette: true, }, }, }, @@ -121,6 +124,7 @@ const routes = [ title: 'Showcase', icon: 'pi pi-bars', showInNavigation: true, + showInCommandPalette: true, }, }, }, diff --git a/vue/src/containers/LayoutContainer/LayoutContainer.spec.ts b/vue/src/containers/LayoutContainer/LayoutContainer.spec.ts index 016db97..f4595c8 100644 --- a/vue/src/containers/LayoutContainer/LayoutContainer.spec.ts +++ b/vue/src/containers/LayoutContainer/LayoutContainer.spec.ts @@ -59,6 +59,11 @@ const ConfirmDialogStub = defineComponent({ template: '
', }) +const CommandPaletteStub = defineComponent({ + name: 'CommandPaletteComponent', + template: '
', +}) + describe('LayoutContainer', () => { it('passes project title to side and mobile navigation components', () => { const wrapper = mount(LayoutContainer, { @@ -73,6 +78,7 @@ describe('LayoutContainer', () => { RouterView: RouterViewStub, Toast: ToastStub, ConfirmDialog: ConfirmDialogStub, + CommandPaletteComponent: CommandPaletteStub, }, }, }) @@ -94,6 +100,7 @@ describe('LayoutContainer', () => { RouterView: RouterViewStub, Toast: ToastStub, ConfirmDialog: ConfirmDialogStub, + CommandPaletteComponent: CommandPaletteStub, }, }, }) @@ -114,11 +121,13 @@ describe('LayoutContainer', () => { RouterView: RouterViewStub, Toast: ToastStub, ConfirmDialog: ConfirmDialogStub, + CommandPaletteComponent: CommandPaletteStub, }, }, }) expect(wrapper.find('[data-test="router-view"]').exists()).toBe(true) + expect(wrapper.find('[data-test="command-palette"]').exists()).toBe(true) expect(wrapper.find('[data-test="toast"]').exists()).toBe(true) expect(wrapper.find('[data-test="confirm-dialog"]').exists()).toBe(true) }) @@ -137,6 +146,7 @@ describe('LayoutContainer', () => { RouterView: RouterViewStub, Toast: ToastStub, ConfirmDialog: ConfirmDialogStub, + CommandPaletteComponent: CommandPaletteStub, }, }, }) diff --git a/vue/src/containers/LayoutContainer/LayoutContainer.vue b/vue/src/containers/LayoutContainer/LayoutContainer.vue index a0b414c..a3fd9f3 100644 --- a/vue/src/containers/LayoutContainer/LayoutContainer.vue +++ b/vue/src/containers/LayoutContainer/LayoutContainer.vue @@ -23,6 +23,7 @@ + @@ -32,6 +33,7 @@ import SideNavigationComponent from '@/containers/LayoutContainer/SideNavigationComponent/SideNavigationComponent.vue' import { AutoroutedBreadcrumb } from '@/components/AutoroutedBreadcrumb' import MobileNavigationComponent from '@/containers/LayoutContainer/MobileNavigationComponent/MobileNavigationComponent.vue' +import { CommandPaletteComponent } from '@/components/CommandPaletteComponent' import { setupDepthTracker } from '../helpers' import Toast from 'primevue/toast' import ConfirmDialog from 'primevue/confirmdialog' diff --git a/vue/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue b/vue/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue index 21d07bc..29fd1a1 100644 --- a/vue/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue +++ b/vue/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue @@ -30,15 +30,10 @@