@nside/wefa 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +46 -3
  2. package/dist/LegalConsent-9nOroDoA.cjs +1 -0
  3. package/dist/LegalConsent-CrPVZOxx.js +151 -0
  4. package/dist/LegalDocument-CVJiGmPJ.cjs +109 -0
  5. package/dist/{LegalDocument-BhoEpJ2O.js → LegalDocument-DwVhwjIf.js} +236 -215
  6. package/dist/{LoginView-kH440cCh.js → LoginView-DUPa_PsC.js} +3 -3
  7. package/dist/{LoginView-IIkXXw3R.cjs → LoginView-Dihs8n_X.cjs} +1 -1
  8. package/dist/{LogoutView-DGqh4bP7.js → LogoutView-DAh7MrFi.js} +3 -3
  9. package/dist/{LogoutView-B90MA-_Q.cjs → LogoutView-Fl3nfeJ0.cjs} +1 -1
  10. package/dist/{apiClient-DJdAL3tN.cjs → apiClient-BUS5ZclN.cjs} +1 -1
  11. package/dist/{apiClient-D-kcx_S1.js → apiClient-BbJl566D.js} +1 -1
  12. package/dist/axios-CZvsFspN.js +1887 -0
  13. package/dist/axios-DMqeKDaq.cjs +6 -0
  14. package/dist/containers.cjs +590 -5
  15. package/dist/containers.d.ts +39 -0
  16. package/dist/containers.js +3803 -977
  17. package/dist/{index-Coos428-.js → index--_rUTrqU.js} +308 -282
  18. package/dist/{index-B4vneBZh.cjs → index-B4oFnh1T.cjs} +6 -6
  19. package/dist/index-BHSxFTgZ.js +49 -0
  20. package/dist/{index-BSfhC_wu.cjs → index-BaA_oL1s.cjs} +1 -1
  21. package/dist/{index-CJmnkrIs.cjs → index-Becfy0pF.cjs} +1 -1
  22. package/dist/{index-Dj5oTSEE.js → index-C09d0pI4.js} +15 -15
  23. package/dist/{index-BXrnPbjr.cjs → index-CbQWytWd.cjs} +4 -4
  24. package/dist/{index-DmVIgb18.js → index-CgAb-gZi.js} +11 -11
  25. package/dist/{index-B53YL3vD.cjs → index-DFOQKDki.cjs} +2 -2
  26. package/dist/index-DFSkcsx-.cjs +943 -0
  27. package/dist/{index-CEz0St1t.js → index-DQFN7qxo.js} +7 -7
  28. package/dist/index-DRozw3P8.js +167 -0
  29. package/dist/index-DfCQoHSf.cjs +146 -0
  30. package/dist/index-DkuJMEY1.js +6731 -0
  31. package/dist/{index-bRjoenrr.js → index-Dv6jyKbT.js} +12 -12
  32. package/dist/{index-Bl3JVLei.cjs → index-EDm9-cRY.cjs} +1 -1
  33. package/dist/index-IGN7_cyg.cjs +2 -0
  34. package/dist/{index-DGvdYnh3.js → index-lFl6UsTa.js} +7 -7
  35. package/dist/index-lQmq7gxp.cjs +54 -0
  36. package/dist/{index-FS8xE7Mo.js → index-xUb0UC07.js} +5 -5
  37. package/dist/lib-C3DWunRS.js +26376 -0
  38. package/dist/lib-COvHzA2Y.cjs +2104 -0
  39. package/dist/lib.cjs +1 -1
  40. package/dist/lib.d.ts +160 -7
  41. package/dist/lib.js +33 -30
  42. package/dist/libRoutes-B-H3e9wZ.js +22 -0
  43. package/dist/libRoutes-Cl3TklhN.cjs +1 -0
  44. package/dist/network.cjs +1 -1
  45. package/dist/network.d.ts +19 -0
  46. package/dist/network.js +3 -3
  47. package/dist/router.cjs +1 -1
  48. package/dist/router.d.ts +26 -4
  49. package/dist/router.js +10 -10
  50. package/package.json +55 -48
  51. package/src/assets/main.css +2 -2
  52. package/src/components/AutoroutedBreadcrumb/AutoroutedBreadcrumb.mdx +8 -8
  53. package/src/components/AutoroutedBreadcrumb/AutoroutedBreadcrumb.spec.ts +86 -45
  54. package/src/components/AutoroutedBreadcrumb/AutoroutedBreadcrumb.vue +29 -21
  55. package/src/components/AvatarComponent/AvatarComponent.mdx +63 -0
  56. package/src/components/AvatarComponent/AvatarComponent.stories.ts +98 -0
  57. package/src/components/AvatarComponent/AvatarComponent.vue +115 -0
  58. package/src/components/GanttChartComponent/GanttChartComponent.mdx +143 -0
  59. package/src/components/GanttChartComponent/GanttChartComponent.spec.ts +257 -0
  60. package/src/components/GanttChartComponent/GanttChartComponent.stories.ts +253 -0
  61. package/src/components/GanttChartComponent/GanttChartComponent.vue +220 -0
  62. package/src/components/GanttChartComponent/GanttChartGrid.vue +66 -0
  63. package/src/components/GanttChartComponent/GanttChartHeaderGrid.vue +167 -0
  64. package/src/components/GanttChartComponent/GanttChartHeaderLabel.vue +23 -0
  65. package/src/components/GanttChartComponent/GanttChartLinksOverlay.vue +105 -0
  66. package/src/components/GanttChartComponent/GanttChartRowGrid.vue +288 -0
  67. package/src/components/GanttChartComponent/GanttChartRowLabel.vue +32 -0
  68. package/src/components/GanttChartComponent/composables/useGanttLinks.ts +212 -0
  69. package/src/components/GanttChartComponent/composables/useGanttSizing.ts +42 -0
  70. package/src/components/GanttChartComponent/ganttChartLayout.ts +211 -0
  71. package/src/components/GanttChartComponent/ganttChartTypes.ts +24 -0
  72. package/src/components/GanttChartComponent/index.ts +1 -0
  73. package/src/components/NetworkButton/ApiMutationButton.vue +7 -5
  74. package/src/components/NetworkButton/ApiQueryButton.vue +6 -4
  75. package/src/components/PlotlyComponent/PlotlyComponent.stories.ts +74 -45
  76. package/src/containers/BareContainer/BareContainer.mdx +1 -1
  77. package/src/containers/LayoutContainer/LayoutContainer.mdx +128 -0
  78. package/src/containers/LayoutContainer/LayoutContainer.spec.ts +151 -0
  79. package/src/containers/LayoutContainer/LayoutContainer.stories.ts +292 -0
  80. package/src/containers/LayoutContainer/LayoutContainer.vue +53 -0
  81. package/src/containers/LayoutContainer/MobileNavigationComponent/MobileNavigationComponent.spec.ts +139 -0
  82. package/src/containers/LayoutContainer/MobileNavigationComponent/MobileNavigationComponent.vue +63 -0
  83. package/src/containers/LayoutContainer/SideNavigationComponent/BottomComponent/BottomComponent.spec.ts +39 -0
  84. package/src/containers/LayoutContainer/SideNavigationComponent/BottomComponent/BottomComponent.vue +9 -0
  85. package/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.spec.ts +175 -0
  86. package/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue +163 -0
  87. package/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/NavigationLinkComponent.spec.ts +105 -0
  88. package/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/NavigationLinkComponent.vue +45 -0
  89. package/src/containers/LayoutContainer/SideNavigationComponent/SideNavigationComponent.spec.ts +78 -0
  90. package/src/containers/LayoutContainer/SideNavigationComponent/SideNavigationComponent.vue +29 -0
  91. package/src/containers/LayoutContainer/SideNavigationComponent/TopComponent/TopComponent.spec.ts +60 -0
  92. package/src/containers/LayoutContainer/SideNavigationComponent/TopComponent/TopComponent.vue +56 -0
  93. package/src/containers/LayoutContainer/UserMenuTriggerComponent/UserMenuTriggerComponent.spec.ts +96 -0
  94. package/src/containers/LayoutContainer/UserMenuTriggerComponent/UserMenuTriggerComponent.vue +80 -0
  95. package/src/containers/LayoutContainer/index.ts +1 -0
  96. package/src/containers/NavbarContainer/NavbarContainer.mdx +1 -1
  97. package/src/containers/RoutedTabsComponent/RoutedTabsComponent.mdx +3 -3
  98. package/src/containers/SideMenuContainer/SideMenuContainer.mdx +1 -1
  99. package/src/containers/helpers.ts +6 -3
  100. package/src/containers/index.ts +2 -0
  101. package/src/containers/storybook/PlaceholderView.vue +1 -1
  102. package/src/containers/storybook/PrimeComponents.stories.ts +17 -0
  103. package/src/containers/storybook/PrimeComponentsShowcase.vue +587 -0
  104. package/src/containers/storybook/overview.mdx +36 -36
  105. package/src/demo/App.vue +7 -0
  106. package/src/{demo.ts → demo/main.ts} +8 -9
  107. package/src/demo/router.ts +65 -19
  108. package/src/demo/views/PlaygroundView.vue +86 -0
  109. package/src/demo/views/ShowcaseView.vue +41 -0
  110. package/src/lib.ts +3 -1
  111. package/src/locales/Translation.mdx +2 -2
  112. package/src/locales/en/avatar.json +3 -0
  113. package/src/locales/en/gantt_chart.json +6 -0
  114. package/src/locales/en/navigation.json +3 -1
  115. package/src/locales/index.ts +0 -4
  116. package/src/plugins/legalConsent/views/__tests__/LegalConsent.test.ts +12 -7
  117. package/src/router/guards.ts +4 -4
  118. package/src/router/libRoutes.ts +6 -2
  119. package/src/router/router.mdx +107 -66
  120. package/src/router/types.ts +24 -3
  121. package/src/stores/__tests__/backend/jwt.test.ts +4 -4
  122. package/src/stores/__tests__/backend/oauth.test.ts +104 -0
  123. package/src/stores/__tests__/backend/token.test.ts +4 -4
  124. package/src/stores/authentication.mdx +138 -0
  125. package/src/stores/backend/common.ts +89 -0
  126. package/src/stores/backend/constants.ts +22 -0
  127. package/src/stores/backend/schemes/jwt.ts +208 -0
  128. package/src/stores/backend/schemes/oauth.ts +142 -0
  129. package/src/stores/backend/schemes/token.ts +122 -0
  130. package/src/stores/backend/types.ts +96 -0
  131. package/src/stores/backend.ts +21 -427
  132. package/src/stores/index.ts +6 -0
  133. package/src/theme/index.ts +2 -0
  134. package/src/theme/nside.ts +157 -0
  135. package/src/utils/color.spec.ts +24 -0
  136. package/src/utils/color.ts +100 -0
  137. package/src/utils/translations.ts +0 -4
  138. package/dist/LegalConsent-CEcXZml6.cjs +0 -1
  139. package/dist/LegalConsent-Dzq3fdnt.js +0 -277
  140. package/dist/LegalDocument-CS3MnOcV.cjs +0 -109
  141. package/dist/axios-ClRPr3Xn.js +0 -1777
  142. package/dist/axios-Dcidtc2l.cjs +0 -6
  143. package/dist/index-Bc699sOR.js +0 -4997
  144. package/dist/index-CL_OJMNr.cjs +0 -55
  145. package/dist/index-CTNsucOq.cjs +0 -147
  146. package/dist/index-CwLAV8WF.js +0 -210
  147. package/dist/index-FrfvunRp.cjs +0 -146
  148. package/dist/lib-BBJH9d11.cjs +0 -2792
  149. package/dist/lib-Y8FPgwH4.js +0 -20886
  150. package/dist/libRoutes-BsneoQ4G.js +0 -18
  151. package/dist/libRoutes-BzeZrIaK.cjs +0 -1
  152. package/src/demo/DemoApp.vue +0 -13
  153. package/src/demo/ShowcaseView.vue +0 -39
  154. package/src/demo/demo.css +0 -15
  155. /package/src/demo/{DemoContent.vue → views/DemoContent.vue} +0 -0
  156. /package/src/demo/{DemoView.vue → views/DemoView.vue} +0 -0
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { defineComponent } from 'vue'
4
+ import BottomComponent from './BottomComponent.vue'
5
+
6
+ const UserMenuTriggerStub = defineComponent({
7
+ name: 'UserMenuTriggerComponent',
8
+ props: {
9
+ username: {
10
+ type: String,
11
+ required: true,
12
+ },
13
+ email: {
14
+ type: String,
15
+ default: '',
16
+ },
17
+ mode: {
18
+ type: String,
19
+ default: 'detailed',
20
+ },
21
+ },
22
+ template: '<div data-test="user-menu">{{ username }}|{{ email }}|{{ mode }}</div>',
23
+ })
24
+
25
+ describe('BottomComponent', () => {
26
+ it('renders UserMenuTriggerComponent with expected static props', () => {
27
+ const wrapper = mount(BottomComponent, {
28
+ global: {
29
+ stubs: {
30
+ UserMenuTriggerComponent: UserMenuTriggerStub,
31
+ },
32
+ },
33
+ })
34
+
35
+ expect(wrapper.get('[data-test="user-menu"]').text()).toContain('John Doe')
36
+ expect(wrapper.get('[data-test="user-menu"]').text()).toContain('jdo@example.com')
37
+ expect(wrapper.get('[data-test="user-menu"]').text()).toContain('detailed')
38
+ })
39
+ })
@@ -0,0 +1,9 @@
1
+ <template>
2
+ <div class="flex flex-col border-t p-4 border-zinc-950/5">
3
+ <UserMenuTriggerComponent username="John Doe" email="jdo@example.com" mode="detailed" />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import UserMenuTriggerComponent from '@/containers/LayoutContainer/UserMenuTriggerComponent/UserMenuTriggerComponent.vue'
9
+ </script>
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { createRouter, createMemoryHistory, type RouteRecordRaw } from 'vue-router'
4
+ import { defineComponent } from 'vue'
5
+ import MainComponent from './MainComponent.vue'
6
+
7
+ const StubView = defineComponent({
8
+ template: '<div></div>',
9
+ })
10
+
11
+ const NavigationLinkStub = defineComponent({
12
+ name: 'NavigationLinkComponent',
13
+ props: {
14
+ route: {
15
+ type: String,
16
+ required: true,
17
+ },
18
+ icon: {
19
+ type: String,
20
+ required: true,
21
+ },
22
+ label: {
23
+ type: String,
24
+ required: true,
25
+ },
26
+ },
27
+ emits: ['navigation-item-click'],
28
+ template:
29
+ '<button class="nav-link" :data-route="route" :data-icon="icon" @click="$emit(\'navigation-item-click\')">{{ label }}</button>',
30
+ })
31
+
32
+ function createTestRouter() {
33
+ const routes: RouteRecordRaw[] = [
34
+ {
35
+ path: '/home',
36
+ name: 'home',
37
+ component: StubView,
38
+ meta: {
39
+ wefa: {
40
+ title: 'Home',
41
+ icon: 'pi pi-home',
42
+ showInNavigation: true,
43
+ },
44
+ },
45
+ },
46
+ {
47
+ path: '/reports/',
48
+ name: 'reports',
49
+ component: StubView,
50
+ meta: {
51
+ wefa: {
52
+ title: 'Reports',
53
+ icon: 'pi pi-chart-line',
54
+ showInNavigation: true,
55
+ },
56
+ },
57
+ },
58
+ {
59
+ path: '/settings',
60
+ name: 'settings',
61
+ component: StubView,
62
+ meta: {
63
+ wefa: {
64
+ title: 'Settings',
65
+ icon: 'pi pi-cog',
66
+ showInNavigation: true,
67
+ section: 'Administration',
68
+ },
69
+ },
70
+ children: [
71
+ {
72
+ path: 'users/',
73
+ name: 'users',
74
+ component: StubView,
75
+ meta: {
76
+ wefa: {
77
+ title: 'Users',
78
+ icon: 'pi pi-users',
79
+ showInNavigation: true,
80
+ section: 'Administration',
81
+ },
82
+ },
83
+ },
84
+ ],
85
+ },
86
+ {
87
+ path: '/hidden',
88
+ name: 'hidden',
89
+ component: StubView,
90
+ meta: {
91
+ wefa: {
92
+ title: 'Hidden',
93
+ },
94
+ },
95
+ },
96
+ ]
97
+
98
+ return createRouter({
99
+ history: createMemoryHistory(),
100
+ routes,
101
+ })
102
+ }
103
+
104
+ describe('MainComponent', () => {
105
+ let router: ReturnType<typeof createTestRouter>
106
+
107
+ beforeEach(async () => {
108
+ router = createTestRouter()
109
+ await router.push('/home')
110
+ await router.isReady()
111
+ })
112
+
113
+ it('renders only routes configured for navigation', () => {
114
+ const wrapper = mount(MainComponent, {
115
+ global: {
116
+ plugins: [router],
117
+ stubs: {
118
+ NavigationLinkComponent: NavigationLinkStub,
119
+ },
120
+ },
121
+ })
122
+
123
+ expect(wrapper.text()).toContain('Home')
124
+ expect(wrapper.text()).toContain('Reports')
125
+ expect(wrapper.text()).toContain('Settings')
126
+ expect(wrapper.text()).toContain('Users')
127
+ expect(wrapper.text()).not.toContain('Hidden')
128
+ })
129
+
130
+ it('groups section-based routes and renders section headers', () => {
131
+ const wrapper = mount(MainComponent, {
132
+ global: {
133
+ plugins: [router],
134
+ stubs: {
135
+ NavigationLinkComponent: NavigationLinkStub,
136
+ },
137
+ },
138
+ })
139
+
140
+ expect(wrapper.text()).toContain('Administration')
141
+ })
142
+
143
+ it('normalizes route paths for navigation entries', () => {
144
+ const wrapper = mount(MainComponent, {
145
+ global: {
146
+ plugins: [router],
147
+ stubs: {
148
+ NavigationLinkComponent: NavigationLinkStub,
149
+ },
150
+ },
151
+ })
152
+
153
+ const renderedPaths = wrapper
154
+ .findAll('.nav-link')
155
+ .map((element) => element.attributes('data-route'))
156
+
157
+ expect(renderedPaths).toContain('/reports')
158
+ expect(renderedPaths).toContain('/settings/users')
159
+ })
160
+
161
+ it('forwards child navigation-item-click events', async () => {
162
+ const wrapper = mount(MainComponent, {
163
+ global: {
164
+ plugins: [router],
165
+ stubs: {
166
+ NavigationLinkComponent: NavigationLinkStub,
167
+ },
168
+ },
169
+ })
170
+
171
+ await wrapper.get('.nav-link').trigger('click')
172
+
173
+ expect(wrapper.emitted('navigation-item-click')).toBeTruthy()
174
+ })
175
+ })
@@ -0,0 +1,163 @@
1
+ <template>
2
+ <div class="flex flex-col grow p-4 gap-4">
3
+ <section v-if="topLevelEntries.length > 0">
4
+ <NavigationLinkComponent
5
+ v-for="entry in topLevelEntries"
6
+ :key="entry.path"
7
+ :route="entry.path"
8
+ :icon="entry.icon"
9
+ :label="entry.label"
10
+ @navigation-item-click="emitNavigationItemClick"
11
+ />
12
+ </section>
13
+
14
+ <section v-for="section in sectionEntries" :key="section.label" class="flex flex-col gap-2">
15
+ <h3 class="mb-1 px-2 text-sm font-medium text-zinc-500">{{ section.label }}</h3>
16
+ <div>
17
+ <NavigationLinkComponent
18
+ v-for="entry in section.entries"
19
+ :key="entry.path"
20
+ :route="entry.path"
21
+ :icon="entry.icon"
22
+ :label="entry.label"
23
+ @navigation-item-click="emitNavigationItemClick"
24
+ />
25
+ </div>
26
+ </section>
27
+ </div>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import { computed } from 'vue'
32
+ import { type RouteRecordRaw, useRouter } from 'vue-router'
33
+ import type { WeFaRouteMeta } from '@/router'
34
+ import NavigationLinkComponent from '@/containers/LayoutContainer/SideNavigationComponent/MainComponent/NavigationLinkComponent.vue'
35
+
36
+ interface NavigationEntry {
37
+ path: string
38
+ label: string
39
+ icon?: string
40
+ section?: string
41
+ }
42
+
43
+ interface SectionNavigationEntries {
44
+ label: string
45
+ entries: NavigationEntry[]
46
+ }
47
+
48
+ const router = useRouter()
49
+ const emit = defineEmits<{
50
+ (event: 'navigation-item-click'): void
51
+ }>()
52
+
53
+ /**
54
+ * Normalizes route paths to avoid duplicate slashes and trailing slash noise.
55
+ * @param path Raw route path
56
+ * @returns Normalized absolute path
57
+ */
58
+ function normalizePath(path: string): string {
59
+ if (!path) {
60
+ return '/'
61
+ }
62
+
63
+ const normalizedPath = path.replace(/\/{2,}/g, '/')
64
+ if (normalizedPath === '/') {
65
+ return normalizedPath
66
+ }
67
+
68
+ return normalizedPath.replace(/\/$/, '')
69
+ }
70
+
71
+ /**
72
+ * Resolves a route path against its parent route path.
73
+ * @param parentPath Parent route path
74
+ * @param routePath Child route path
75
+ * @returns Resolved absolute path
76
+ */
77
+ function resolvePath(parentPath: string, routePath: string): string {
78
+ if (routePath.startsWith('/')) {
79
+ return normalizePath(routePath)
80
+ }
81
+
82
+ if (!routePath) {
83
+ return normalizePath(parentPath)
84
+ }
85
+
86
+ if (parentPath === '/') {
87
+ return normalizePath(`/${routePath}`)
88
+ }
89
+
90
+ return normalizePath(`${parentPath}/${routePath}`)
91
+ }
92
+
93
+ /**
94
+ * Extracts navigation entries from the router tree based on showInNavigation metadata.
95
+ * @param routes Router records to inspect
96
+ * @param parentPath Parent path used to resolve relative child paths
97
+ * @returns Flattened navigation entries in declaration order
98
+ */
99
+ function routeNavigationEntries(
100
+ routes: RouteRecordRaw[],
101
+ parentPath: string = '/'
102
+ ): NavigationEntry[] {
103
+ const entries: NavigationEntry[] = []
104
+
105
+ for (const route of routes) {
106
+ const fullPath = resolvePath(parentPath, route.path)
107
+ const routeMeta = route.meta?.wefa as WeFaRouteMeta | undefined
108
+ const showInNavigation = routeMeta?.showInNavigation ?? false
109
+ const section = routeMeta?.section
110
+
111
+ if (showInNavigation === true) {
112
+ entries.push({
113
+ path: fullPath,
114
+ label: routeMeta?.title ?? String(route.name ?? fullPath),
115
+ icon: routeMeta?.icon,
116
+ section: section?.trim() || undefined,
117
+ })
118
+ }
119
+
120
+ if (route.children?.length) {
121
+ entries.push(...routeNavigationEntries(route.children, fullPath))
122
+ }
123
+ }
124
+
125
+ return entries
126
+ }
127
+
128
+ const navigationEntries = computed(() => {
129
+ return routeNavigationEntries(router.options.routes as RouteRecordRaw[])
130
+ })
131
+
132
+ const topLevelEntries = computed(() => {
133
+ return navigationEntries.value.filter((entry) => !entry.section)
134
+ })
135
+
136
+ const sectionEntries = computed<SectionNavigationEntries[]>(() => {
137
+ const groupedEntries = new Map<string, NavigationEntry[]>()
138
+
139
+ for (const entry of navigationEntries.value) {
140
+ if (!entry.section) {
141
+ continue
142
+ }
143
+
144
+ if (!groupedEntries.has(entry.section)) {
145
+ groupedEntries.set(entry.section, [])
146
+ }
147
+
148
+ groupedEntries.get(entry.section)?.push(entry)
149
+ }
150
+
151
+ return Array.from(groupedEntries.entries()).map(([label, entries]) => ({
152
+ label,
153
+ entries,
154
+ }))
155
+ })
156
+
157
+ /**
158
+ * Forwards click events from individual navigation links.
159
+ */
160
+ function emitNavigationItemClick() {
161
+ emit('navigation-item-click')
162
+ }
163
+ </script>
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { defineComponent, h } from 'vue'
4
+ import NavigationLinkComponent from './NavigationLinkComponent.vue'
5
+
6
+ function createRouterLinkStub(isActive: boolean, isExactActive: boolean) {
7
+ return defineComponent({
8
+ name: 'RouterLink',
9
+ props: {
10
+ to: {
11
+ type: [String, Object],
12
+ required: true,
13
+ },
14
+ },
15
+ emits: ['click'],
16
+ setup(props, { emit, slots }) {
17
+ return () =>
18
+ h(
19
+ 'a',
20
+ {
21
+ 'data-test': 'router-link',
22
+ href: typeof props.to === 'string' ? props.to : '#',
23
+ onClick: (event: MouseEvent) => emit('click', event),
24
+ },
25
+ slots.default?.({
26
+ isActive,
27
+ isExactActive,
28
+ })
29
+ )
30
+ },
31
+ })
32
+ }
33
+
34
+ describe('NavigationLinkComponent', () => {
35
+ it('renders label and icon', () => {
36
+ const wrapper = mount(NavigationLinkComponent, {
37
+ props: {
38
+ route: '/products',
39
+ icon: 'pi pi-box',
40
+ label: 'Products',
41
+ },
42
+ global: {
43
+ stubs: {
44
+ RouterLink: createRouterLinkStub(false, false),
45
+ },
46
+ },
47
+ })
48
+
49
+ expect(wrapper.text()).toContain('Products')
50
+ expect(wrapper.find('i.pi-box').exists()).toBe(true)
51
+ })
52
+
53
+ it('shows active indicator for active links', () => {
54
+ const wrapper = mount(NavigationLinkComponent, {
55
+ props: {
56
+ route: '/products',
57
+ icon: 'pi pi-box',
58
+ label: 'Products',
59
+ },
60
+ global: {
61
+ stubs: {
62
+ RouterLink: createRouterLinkStub(true, false),
63
+ },
64
+ },
65
+ })
66
+
67
+ expect(wrapper.find('.absolute.inset-y-2.-left-4').exists()).toBe(true)
68
+ })
69
+
70
+ it('does not show active indicator for inactive links', () => {
71
+ const wrapper = mount(NavigationLinkComponent, {
72
+ props: {
73
+ route: '/products',
74
+ icon: 'pi pi-box',
75
+ label: 'Products',
76
+ },
77
+ global: {
78
+ stubs: {
79
+ RouterLink: createRouterLinkStub(false, false),
80
+ },
81
+ },
82
+ })
83
+
84
+ expect(wrapper.find('.absolute.inset-y-2.-left-4').exists()).toBe(false)
85
+ })
86
+
87
+ it('emits navigation-item-click when clicked', async () => {
88
+ const wrapper = mount(NavigationLinkComponent, {
89
+ props: {
90
+ route: '/products',
91
+ icon: 'pi pi-box',
92
+ label: 'Products',
93
+ },
94
+ global: {
95
+ stubs: {
96
+ RouterLink: createRouterLinkStub(false, false),
97
+ },
98
+ },
99
+ })
100
+
101
+ await wrapper.get('[data-test="router-link"]').trigger('click')
102
+
103
+ expect((wrapper.emitted('navigation-item-click') ?? []).length).toBeGreaterThan(0)
104
+ })
105
+ })
@@ -0,0 +1,45 @@
1
+ <template>
2
+ <router-link v-slot="{ isActive, isExactActive }" :to="route" @click="emitNavigationClick">
3
+ <div class="relative" @click="emitNavigationClick">
4
+ <Transition
5
+ enter-active-class="transition opacity-0 scale-y-75 duration-200 ease-out"
6
+ enter-to-class="opacity-100 scale-y-100"
7
+ leave-active-class="transition opacity-100 scale-y-100 duration-200 ease-in"
8
+ leave-to-class="opacity-0 scale-y-75"
9
+ >
10
+ <span
11
+ v-if="isActive || isExactActive"
12
+ class="absolute inset-y-2 -left-4 rounded-full bg-zinc-950 w-1"
13
+ ></span>
14
+ </Transition>
15
+ <div
16
+ :class="[isExactActive ? 'bg-zinc-500/5' : '']"
17
+ class="flex w-full items-center rounded-lg cursor-pointer text-base px-2 py-2 gap-3 hover:bg-zinc-950/5"
18
+ @click="emitNavigationClick"
19
+ >
20
+ <i v-if="icon" class="pi" :class="icon" style="font-size: 1.5rem"></i>
21
+ <span class="block truncate text-zinc-950">{{ label }}</span>
22
+ </div>
23
+ </div>
24
+ </router-link>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ export interface NavigationLinkComponentProps {
29
+ route: string
30
+ icon?: string
31
+ label: string
32
+ }
33
+
34
+ const { route, icon, label } = defineProps<NavigationLinkComponentProps>()
35
+ const emit = defineEmits<{
36
+ (event: 'navigation-item-click'): void
37
+ }>()
38
+
39
+ /**
40
+ * Emits a navigation click event so parents can react (e.g. close a mobile drawer).
41
+ */
42
+ function emitNavigationClick() {
43
+ emit('navigation-item-click')
44
+ }
45
+ </script>
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { defineComponent } from 'vue'
4
+ import SideNavigationComponent from './SideNavigationComponent.vue'
5
+
6
+ const TopComponentStub = defineComponent({
7
+ name: 'TopComponent',
8
+ props: {
9
+ projectTitle: {
10
+ type: String,
11
+ required: true,
12
+ },
13
+ projectLogo: {
14
+ type: String,
15
+ default: undefined,
16
+ },
17
+ },
18
+ template: '<div data-test="top">{{ projectTitle }}|{{ projectLogo }}</div>',
19
+ })
20
+
21
+ const MainComponentStub = defineComponent({
22
+ name: 'MainComponent',
23
+ template: '<div data-test="main">Main Navigation</div>',
24
+ })
25
+
26
+ describe('SideNavigationComponent', () => {
27
+ it('renders top and main sections', () => {
28
+ const wrapper = mount(SideNavigationComponent, {
29
+ props: {
30
+ projectTitle: 'WeFa',
31
+ },
32
+ global: {
33
+ stubs: {
34
+ TopComponent: TopComponentStub,
35
+ MainComponent: MainComponentStub,
36
+ },
37
+ },
38
+ })
39
+
40
+ expect(wrapper.find('[data-test="top"]').exists()).toBe(true)
41
+ expect(wrapper.find('[data-test="main"]').exists()).toBe(true)
42
+ })
43
+
44
+ it('passes project title to TopComponent', () => {
45
+ const wrapper = mount(SideNavigationComponent, {
46
+ props: {
47
+ projectTitle: 'Energy Forecast',
48
+ },
49
+ global: {
50
+ stubs: {
51
+ TopComponent: TopComponentStub,
52
+ MainComponent: MainComponentStub,
53
+ },
54
+ },
55
+ })
56
+
57
+ expect(wrapper.get('[data-test="top"]').text()).toBe('Energy Forecast|')
58
+ })
59
+
60
+ it('passes custom logo to TopComponent', () => {
61
+ const wrapper = mount(SideNavigationComponent, {
62
+ props: {
63
+ projectTitle: 'Energy Forecast',
64
+ projectLogo: 'https://example.test/logo.svg',
65
+ },
66
+ global: {
67
+ stubs: {
68
+ TopComponent: TopComponentStub,
69
+ MainComponent: MainComponentStub,
70
+ },
71
+ },
72
+ })
73
+
74
+ expect(wrapper.get('[data-test="top"]').text()).toBe(
75
+ 'Energy Forecast|https://example.test/logo.svg'
76
+ )
77
+ })
78
+ })
@@ -0,0 +1,29 @@
1
+ <template>
2
+ <div class="fixed inset-y-0 left-0 w-64 max-lg:hidden">
3
+ <nav class="flex h-full min-h-0 flex-col">
4
+ <TopComponent
5
+ :project-title="projectTitle"
6
+ :project-logo="projectLogo"
7
+ :project-logo-alt="projectLogoAlt"
8
+ />
9
+ <MainComponent />
10
+ </nav>
11
+ </div>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import TopComponent from '@/containers/LayoutContainer/SideNavigationComponent/TopComponent/TopComponent.vue'
16
+ import MainComponent from '@/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue'
17
+
18
+ export interface SideNavigationComponentProps {
19
+ projectTitle: string
20
+ projectLogo?: string
21
+ projectLogoAlt?: string
22
+ }
23
+
24
+ const {
25
+ projectTitle,
26
+ projectLogo = undefined,
27
+ projectLogoAlt = undefined,
28
+ } = defineProps<SideNavigationComponentProps>()
29
+ </script>