@eturnity/eturnity_reusable_components 9.28.3 → 9.31.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eturnity/eturnity_reusable_components",
3
- "version": "9.28.3",
3
+ "version": "9.31.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "src"
@@ -0,0 +1,145 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import NavigationSideMenu from '@/components/navigationSideMenu'
3
+
4
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
5
+ // need to mock this due to how jest handles import.meta
6
+ fetchIcon: jest.fn(() => Promise.resolve('mocked-icon-url.svg')),
7
+ }))
8
+
9
+ const menuData = [
10
+ {
11
+ groupName: 'Project',
12
+ children: [
13
+ { id: 'technologies', name: 'Technologies', dataId: 'nav_technologies' },
14
+ { id: 'finance', name: 'Finance', dataId: 'nav_finance' },
15
+ ],
16
+ },
17
+ {
18
+ groupName: 'Workflow',
19
+ children: [
20
+ {
21
+ id: 'workflow_template',
22
+ name: 'Workflow template',
23
+ dataId: 'nav_workflow_template',
24
+ },
25
+ ],
26
+ },
27
+ ]
28
+
29
+ const mountMenu = (props = {}) =>
30
+ mount(NavigationSideMenu, { props: { menuData, ...props } })
31
+
32
+ const item = (wrapper, dataId) => wrapper.find(`[data-id="${dataId}"]`)
33
+ const section = (wrapper, key) => wrapper.find(`[data-id="nav_section_${key}"]`)
34
+
35
+ describe('navigationSideMenu/index.vue', () => {
36
+ describe('default state', () => {
37
+ it('renders a header for each non-empty group', () => {
38
+ const wrapper = mountMenu()
39
+
40
+ expect(section(wrapper, 'Project').exists()).toBe(true)
41
+ expect(section(wrapper, 'Workflow').exists()).toBe(true)
42
+ })
43
+
44
+ it('shows every group item when nothing is collapsed', () => {
45
+ const wrapper = mountMenu()
46
+
47
+ expect(item(wrapper, 'nav_technologies').exists()).toBe(true)
48
+ expect(item(wrapper, 'nav_finance').exists()).toBe(true)
49
+ expect(item(wrapper, 'nav_workflow_template').exists()).toBe(true)
50
+ })
51
+
52
+ it('reports no group as collapsed initially', () => {
53
+ const wrapper = mountMenu()
54
+
55
+ expect(wrapper.vm.isGroupCollapsed('Project')).toBe(false)
56
+ expect(wrapper.vm.isGroupCollapsed('Workflow')).toBe(false)
57
+ })
58
+ })
59
+
60
+ describe('toggling a section', () => {
61
+ it("hides that section's items when its header is clicked, and shows them again on a second click", async () => {
62
+ const wrapper = mountMenu()
63
+
64
+ await section(wrapper, 'Project').trigger('click')
65
+ expect(item(wrapper, 'nav_technologies').exists()).toBe(false)
66
+ expect(item(wrapper, 'nav_finance').exists()).toBe(false)
67
+
68
+ await section(wrapper, 'Project').trigger('click')
69
+ expect(item(wrapper, 'nav_technologies').exists()).toBe(true)
70
+ expect(item(wrapper, 'nav_finance').exists()).toBe(true)
71
+ })
72
+
73
+ it('collapses sections independently — collapsing one leaves the others expanded', async () => {
74
+ const wrapper = mountMenu()
75
+
76
+ await section(wrapper, 'Project').trigger('click')
77
+
78
+ expect(item(wrapper, 'nav_technologies').exists()).toBe(false)
79
+ expect(item(wrapper, 'nav_workflow_template').exists()).toBe(true)
80
+ expect(wrapper.vm.isGroupCollapsed('Project')).toBe(true)
81
+ expect(wrapper.vm.isGroupCollapsed('Workflow')).toBe(false)
82
+ })
83
+
84
+ it('emits section-collapse-toggle with the key and post-toggle collapsed state', async () => {
85
+ const wrapper = mountMenu()
86
+
87
+ await section(wrapper, 'Project').trigger('click')
88
+ await section(wrapper, 'Project').trigger('click')
89
+
90
+ const events = wrapper.emitted('section-collapse-toggle')
91
+ expect(events).toHaveLength(2)
92
+ expect(events[0]).toEqual([{ key: 'Project', collapsed: true }])
93
+ expect(events[1]).toEqual([{ key: 'Project', collapsed: false }])
94
+ })
95
+ })
96
+
97
+ describe('groupKey', () => {
98
+ it('prefers an explicit id over the group name', () => {
99
+ const wrapper = mountMenu()
100
+
101
+ expect(wrapper.vm.groupKey({ id: 'a', groupName: 'B' }, 0)).toBe('a')
102
+ })
103
+
104
+ it('falls back to the group name when there is no id', () => {
105
+ const wrapper = mountMenu()
106
+
107
+ expect(wrapper.vm.groupKey({ groupName: 'B' }, 0)).toBe('B')
108
+ })
109
+
110
+ it('falls back to the index (as a string) when neither id nor name is present', () => {
111
+ const wrapper = mountMenu()
112
+
113
+ expect(wrapper.vm.groupKey({}, 3)).toBe('3')
114
+ })
115
+ })
116
+
117
+ describe('edge cases', () => {
118
+ it('mounts without error and renders no sections when menuData is empty', () => {
119
+ const wrapper = mount(NavigationSideMenu, { props: { menuData: [] } })
120
+
121
+ expect(wrapper.find('[data-id^="nav_section_"]').exists()).toBe(false)
122
+ })
123
+
124
+ it('treats groups that share a groupName as one section (name is the key)', async () => {
125
+ const dupData = [
126
+ {
127
+ groupName: 'Other',
128
+ children: [{ id: 'a', name: 'A', dataId: 'nav_a' }],
129
+ },
130
+ {
131
+ groupName: 'Other',
132
+ children: [{ id: 'b', name: 'B', dataId: 'nav_b' }],
133
+ },
134
+ ]
135
+ const wrapper = mount(NavigationSideMenu, {
136
+ props: { menuData: dupData },
137
+ })
138
+
139
+ await wrapper.findAll('[data-id="nav_section_Other"]')[0].trigger('click')
140
+
141
+ expect(item(wrapper, 'nav_a').exists()).toBe(false)
142
+ expect(item(wrapper, 'nav_b').exists()).toBe(false)
143
+ })
144
+ })
145
+ })
@@ -120,8 +120,31 @@
120
120
  v-if="group.children?.length"
121
121
  :is-collapsed="isCollapsed"
122
122
  >
123
- <GroupName v-if="!isCollapsed">{{ group.groupName }}</GroupName>
124
- <ItemList>
123
+ <GroupHeader
124
+ v-if="!isCollapsed"
125
+ :data-id="`nav_section_${groupKey(group, groupIdx)}`"
126
+ :data-qa-id="`nav_section_${groupKey(group, groupIdx)}`"
127
+ @click="toggleGroup(groupKey(group, groupIdx))"
128
+ >
129
+ <GroupName>{{ group.groupName }}</GroupName>
130
+ <GroupChevron>
131
+ <IconComponent
132
+ :color="theme.semanticColors.grey[700]"
133
+ :hovered-color="theme.semanticColors.purple[500]"
134
+ :name="
135
+ isGroupCollapsed(groupKey(group, groupIdx))
136
+ ? 'chevron_down'
137
+ : 'chevron_up'
138
+ "
139
+ size="10px"
140
+ />
141
+ </GroupChevron>
142
+ </GroupHeader>
143
+ <ItemList
144
+ v-if="
145
+ isCollapsed || !isGroupCollapsed(groupKey(group, groupIdx))
146
+ "
147
+ >
125
148
  <template
126
149
  v-for="(item, itemIdx) in group.children"
127
150
  :key="item.id ?? itemIdx"
@@ -137,7 +160,7 @@
137
160
  :is-disabled="item.isDisabled"
138
161
  @click="
139
162
  !item.isDisabled &&
140
- $emit('on-menu-item-click', { item })
163
+ $emit('on-menu-item-click', { item })
141
164
  "
142
165
  >
143
166
  <IconCell :is-collapsed="isCollapsed">
@@ -174,7 +197,7 @@
174
197
  type="tertiary"
175
198
  @click="
176
199
  !item.isDisabled &&
177
- $emit('on-menu-item-click', { item })
200
+ $emit('on-menu-item-click', { item })
178
201
  "
179
202
  />
180
203
  </template>
@@ -244,7 +267,7 @@
244
267
  :is-disabled="sub.isDisabled"
245
268
  @click="
246
269
  !sub.isDisabled &&
247
- $emit('on-menu-item-click', { item: sub })
270
+ $emit('on-menu-item-click', { item: sub })
248
271
  "
249
272
  >
250
273
  <span>{{ sub.name }}</span>
@@ -307,8 +330,8 @@
307
330
  p.isCollapsed
308
331
  ? `${NAV_SIDEBAR_COLLAPSED_OUTER_WIDTH_PX}px`
309
332
  : p.expandedWidth
310
- ? `${p.expandedWidth}px`
311
- : 'max-content'};
333
+ ? `${p.expandedWidth}px`
334
+ : 'max-content'};
312
335
  min-width: ${(p) =>
313
336
  p.isCollapsed ? `${NAV_SIDEBAR_COLLAPSED_OUTER_WIDTH_PX}px` : '0'};
314
337
  border-right: 1px solid ${(p) => p.theme.semanticColors.grey[300]};
@@ -418,6 +441,18 @@
418
441
  padding: ${(p) => (p.isCollapsed ? '10px 8px' : '10px 16px')};
419
442
  `
420
443
 
444
+ const GroupHeader = styled.div`
445
+ display: flex;
446
+ align-items: center;
447
+ justify-content: space-between;
448
+ gap: 6px;
449
+ cursor: pointer;
450
+
451
+ &:hover > div:first-child {
452
+ color: ${(p) => p.theme.semanticColors.purple[500]};
453
+ }
454
+ `
455
+
421
456
  const GroupName = styled.div`
422
457
  font-size: 10px;
423
458
  font-weight: 400;
@@ -426,6 +461,12 @@
426
461
  line-height: 150%;
427
462
  `
428
463
 
464
+ const GroupChevron = styled.div`
465
+ display: flex;
466
+ align-items: center;
467
+ flex-shrink: 0;
468
+ `
469
+
429
470
  const ItemList = styled.div`
430
471
  display: flex;
431
472
  flex-direction: column;
@@ -758,7 +799,9 @@
758
799
  ScrollbarThumb,
759
800
  GroupsList,
760
801
  GroupSection,
802
+ GroupHeader,
761
803
  GroupName,
804
+ GroupChevron,
762
805
  ItemList,
763
806
  MenuItem,
764
807
  IconCell,
@@ -837,6 +880,7 @@
837
880
  'on-menu-item-click',
838
881
  'on-footer-button-click',
839
882
  'sidebar-dimensions',
883
+ 'section-collapse-toggle',
840
884
  ],
841
885
  data() {
842
886
  return {
@@ -844,6 +888,7 @@
844
888
  isCollapsed: false,
845
889
  responsibleSelectSurfaceOpen: false,
846
890
  openDropdownId: null,
891
+ collapsedGroups: {},
847
892
  isWrapperHovered: false,
848
893
  expandedWidth: null,
849
894
  scrollThumbStyle: {},
@@ -1120,6 +1165,27 @@
1120
1165
  this.openDropdownId = this.openDropdownId === id ? null : id
1121
1166
  }
1122
1167
  },
1168
+ groupKey(group, idx) {
1169
+ return group.id ?? group.groupName ?? String(idx)
1170
+ },
1171
+ isGroupCollapsed(key) {
1172
+ return !!this.collapsedGroups[key]
1173
+ },
1174
+ toggleGroup(key) {
1175
+ if (this.collapsedGroups[key]) {
1176
+ delete this.collapsedGroups[key]
1177
+ } else {
1178
+ this.collapsedGroups[key] = true
1179
+ }
1180
+ this.$emit('section-collapse-toggle', {
1181
+ key,
1182
+ collapsed: this.isGroupCollapsed(key),
1183
+ })
1184
+ this.$nextTick(() => {
1185
+ this.updateScrollThumb()
1186
+ this.updateExpandedWidth()
1187
+ })
1188
+ },
1123
1189
  },
1124
1190
  }
1125
1191
  </script>
@@ -0,0 +1,133 @@
1
+ import { action } from '@storybook/addon-actions'
2
+ import NavigationSideMenu from './index.vue'
3
+ import theme from '@/assets/theme'
4
+
5
+ // Grouped menu mirroring the beta settings navigation: each top-level entry is a
6
+ // section ({ groupName, children }). Click a section header (e.g. "OFFER") to
7
+ // collapse/expand its items — sections collapse independently.
8
+ const settingsMenu = [
9
+ {
10
+ groupName: 'Project',
11
+ children: [
12
+ {
13
+ id: 'technologies',
14
+ name: 'Technologies',
15
+ dataId: 'nav_technologies',
16
+ icon: 'settings',
17
+ },
18
+ {
19
+ id: 'finance',
20
+ name: 'Finance',
21
+ dataId: 'nav_finance',
22
+ icon: 'documents',
23
+ },
24
+ ],
25
+ },
26
+ {
27
+ groupName: 'Workflow',
28
+ children: [
29
+ {
30
+ id: 'workflow_template',
31
+ name: 'Workflow template',
32
+ dataId: 'nav_workflow_template',
33
+ icon: 'settings',
34
+ },
35
+ {
36
+ id: 'activity_type',
37
+ name: 'Activity type definition',
38
+ dataId: 'nav_activity_type',
39
+ icon: 'documents',
40
+ },
41
+ ],
42
+ },
43
+ {
44
+ groupName: 'Offer',
45
+ children: [
46
+ {
47
+ id: 'branding',
48
+ name: 'Branding',
49
+ dataId: 'nav_branding',
50
+ icon: 'settings',
51
+ },
52
+ {
53
+ id: 'document_templates',
54
+ name: 'Document templates',
55
+ dataId: 'nav_document_templates',
56
+ icon: 'documents',
57
+ // An item-level dropdown (its own children) — orthogonal to section collapse.
58
+ children: [
59
+ { id: 'quotation', name: 'Quotation', dataId: 'nav_quotation' },
60
+ { id: 'invoice', name: 'Invoice', dataId: 'nav_invoice' },
61
+ ],
62
+ },
63
+ ],
64
+ },
65
+ ]
66
+
67
+ const projectManagerOptions = [
68
+ { id: 1, full_name: 'Firstname Lastname' },
69
+ { id: 2, full_name: 'Alex Meyer' },
70
+ ]
71
+
72
+ export default {
73
+ title: 'Components/Navigation/NavigationSideMenu',
74
+ component: NavigationSideMenu,
75
+ tags: ['autodocs'],
76
+ parameters: {
77
+ layout: 'fullscreen',
78
+ },
79
+ }
80
+
81
+ const Template = (args) => ({
82
+ components: { NavigationSideMenu },
83
+ setup() {
84
+ const onSectionToggle = (payload) =>
85
+ action('section-collapse-toggle')(payload)
86
+ const onItemClick = (payload) => action('on-menu-item-click')(payload)
87
+ return { args, onSectionToggle, onItemClick }
88
+ },
89
+ template: `
90
+ <div data-test-id="navigation-side-menu-story" style="display: flex; min-height: 100vh; background: #f5f5f5;">
91
+ <NavigationSideMenu
92
+ v-bind="args"
93
+ @section-collapse-toggle="onSectionToggle"
94
+ @on-menu-item-click="onItemClick"
95
+ />
96
+ <main style="flex: 1; padding: 24px;">Main content area</main>
97
+ </div>
98
+ `,
99
+ provide: {
100
+ theme,
101
+ },
102
+ })
103
+
104
+ // Default: all sections expanded. Click a section header to collapse/expand it;
105
+ // multiple sections can be collapsed at once (independent, not accordion).
106
+ export const CollapsibleSections = Template.bind({})
107
+ CollapsibleSections.args = {
108
+ menuData: settingsMenu,
109
+ headerTitle: 'Settings',
110
+ activeItemId: 'technologies',
111
+ }
112
+
113
+ // An item that has its own children ("Document templates") renders an item-level
114
+ // dropdown, which behaves independently of section collapse.
115
+ export const WithNestedDropdown = Template.bind({})
116
+ WithNestedDropdown.args = {
117
+ menuData: settingsMenu,
118
+ headerTitle: 'Settings',
119
+ activeItemId: 'quotation',
120
+ activeParentId: 'document_templates',
121
+ }
122
+
123
+ // The project-view placement of the same component: project-manager select in the
124
+ // header plus a footer button. Section collapse works identically here.
125
+ export const ProjectPlacement = Template.bind({})
126
+ ProjectPlacement.args = {
127
+ menuData: settingsMenu,
128
+ showProjectManagerSelect: true,
129
+ projectManagerOptions,
130
+ projectManagerId: 1,
131
+ footerButtonText: 'Documents',
132
+ activeItemId: 'finance',
133
+ }