@eturnity/eturnity_reusable_components 9.28.2 → 9.28.3

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.2",
3
+ "version": "9.28.3",
4
4
  "files": [
5
5
  "dist",
6
6
  "src"
@@ -0,0 +1,174 @@
1
+ /* eslint-disable */
2
+ import { mount } from '@vue/test-utils'
3
+ import Pagination from './index.vue'
4
+ import theme from '@/assets/theme'
5
+
6
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
7
+ fetchIcon: jest.fn(() => Promise.resolve('')),
8
+ }))
9
+
10
+ describe('Pagination', () => {
11
+ const mountPagination = (props = {}) => {
12
+ const fetchPage = jest.fn()
13
+ const wrapper = mount(Pagination, {
14
+ props: {
15
+ fetchPage,
16
+ currentPage: 1,
17
+ paginationParams: { pages: 3, previous: null, next: 2 },
18
+ ...props,
19
+ },
20
+ global: {
21
+ provide: { theme },
22
+ },
23
+ })
24
+ return { wrapper, fetchPage }
25
+ }
26
+
27
+ it('does not render the nav when there is only one page', () => {
28
+ const { wrapper } = mountPagination({
29
+ paginationParams: { pages: 1, previous: null, next: null },
30
+ })
31
+ expect(wrapper.find('nav').exists()).toBe(false)
32
+ })
33
+
34
+ it('does not render the nav when pages is zero', () => {
35
+ const { wrapper } = mountPagination({
36
+ paginationParams: { pages: 0, previous: null, next: null },
37
+ })
38
+ expect(wrapper.find('nav').exists()).toBe(false)
39
+ })
40
+
41
+ it('renders the nav when there is more than one page', () => {
42
+ const { wrapper } = mountPagination()
43
+ expect(wrapper.find('nav').exists()).toBe(true)
44
+ })
45
+
46
+ it('renders the back button and calls fetchPage(previous) on click', async () => {
47
+ const { wrapper, fetchPage } = mountPagination({
48
+ currentPage: 2,
49
+ paginationParams: { pages: 5, previous: 1, next: 3 },
50
+ })
51
+ const back = wrapper.find('[data-id="pagination_back_button"]')
52
+ expect(back.exists()).toBe(true)
53
+ await back.trigger('click')
54
+ expect(fetchPage).toHaveBeenCalledWith(1)
55
+ })
56
+
57
+ it('omits the back button on the first page', () => {
58
+ const { wrapper } = mountPagination({
59
+ currentPage: 1,
60
+ paginationParams: { pages: 5, previous: null, next: 2 },
61
+ })
62
+ expect(wrapper.find('[data-id="pagination_back_button"]').exists()).toBe(
63
+ false
64
+ )
65
+ })
66
+
67
+ it('renders the next button and calls fetchPage(next) on click', async () => {
68
+ const { wrapper, fetchPage } = mountPagination({
69
+ currentPage: 2,
70
+ paginationParams: { pages: 5, previous: 1, next: 3 },
71
+ })
72
+ const next = wrapper.find('[data-id="pagination_next_button"]')
73
+ expect(next.exists()).toBe(true)
74
+ await next.trigger('click')
75
+ expect(fetchPage).toHaveBeenCalledWith(3)
76
+ })
77
+
78
+ it('omits the next button on the last page', () => {
79
+ const { wrapper } = mountPagination({
80
+ currentPage: 5,
81
+ paginationParams: { pages: 5, previous: 4, next: null },
82
+ })
83
+ expect(wrapper.find('[data-id="pagination_next_button"]').exists()).toBe(
84
+ false
85
+ )
86
+ })
87
+
88
+ it('renders the first-page shortcut and calls fetchPage(1) on click', async () => {
89
+ const { wrapper, fetchPage } = mountPagination({
90
+ currentPage: 4,
91
+ paginationParams: { pages: 10, previous: 3, next: 5 },
92
+ })
93
+ const first = wrapper.find('[data-id="pagination_page_button_1"]')
94
+ expect(first.exists()).toBe(true)
95
+ await first.trigger('click')
96
+ expect(fetchPage).toHaveBeenCalledWith(1)
97
+ })
98
+
99
+ it('renders the last-page shortcut and calls fetchPage(pages) on click', async () => {
100
+ const { wrapper, fetchPage } = mountPagination({
101
+ currentPage: 4,
102
+ paginationParams: { pages: 10, previous: 3, next: 5 },
103
+ })
104
+ const last = wrapper.find('[data-id="pagination_page_button_10"]')
105
+ expect(last.exists()).toBe(true)
106
+ await last.trigger('click')
107
+ expect(fetchPage).toHaveBeenCalledWith(10)
108
+ })
109
+
110
+ it('shows both leading and trailing ellipses when the current page is in the middle', () => {
111
+ const { wrapper } = mountPagination({
112
+ currentPage: 5,
113
+ paginationParams: { pages: 10, previous: 4, next: 6 },
114
+ })
115
+ expect(wrapper.findAll('span').filter((s) => s.text() === '...')).toHaveLength(
116
+ 2
117
+ )
118
+ })
119
+
120
+ it('shows no ellipses on a small range', () => {
121
+ const { wrapper } = mountPagination({
122
+ currentPage: 1,
123
+ paginationParams: { pages: 3, previous: null, next: 2 },
124
+ })
125
+ expect(
126
+ wrapper.findAll('span').filter((s) => s.text() === '...')
127
+ ).toHaveLength(0)
128
+ })
129
+
130
+ it('renders the window of page numbers around the current page', () => {
131
+ const { wrapper } = mountPagination({
132
+ currentPage: 4,
133
+ paginationParams: { pages: 10, previous: 3, next: 5 },
134
+ })
135
+ ;[3, 4, 5].forEach((n) => {
136
+ expect(
137
+ wrapper.find(`[data-id="pagination_page_button_${n}"]`).exists()
138
+ ).toBe(true)
139
+ })
140
+ })
141
+
142
+ it('marks the current page button as active', () => {
143
+ const { wrapper } = mountPagination({
144
+ currentPage: 4,
145
+ paginationParams: { pages: 10, previous: 3, next: 5 },
146
+ })
147
+ const current = wrapper.find('[data-id="pagination_page_button_4"]')
148
+ expect(current.classes()).toContain('active')
149
+ })
150
+
151
+ it('calls fetchPage with the clicked page number', async () => {
152
+ const { wrapper, fetchPage } = mountPagination({
153
+ currentPage: 4,
154
+ paginationParams: { pages: 10, previous: 3, next: 5 },
155
+ })
156
+ await wrapper.find('[data-id="pagination_page_button_5"]').trigger('click')
157
+ expect(fetchPage).toHaveBeenCalledWith(5)
158
+ })
159
+
160
+ it('renders exactly two page numbers when there are only two pages', () => {
161
+ const { wrapper } = mountPagination({
162
+ currentPage: 1,
163
+ paginationParams: { pages: 2, previous: null, next: 2 },
164
+ })
165
+ ;[1, 2].forEach((n) => {
166
+ expect(
167
+ wrapper.find(`[data-id="pagination_page_button_${n}"]`).exists()
168
+ ).toBe(true)
169
+ })
170
+ expect(
171
+ wrapper.find('[data-id="pagination_page_button_3"]').exists()
172
+ ).toBe(false)
173
+ })
174
+ })
@@ -1,3 +1,4 @@
1
+ /* eslint-disable */
1
2
  import { mount } from '@vue/test-utils'
2
3
  import RCProgressBar from '@/components/progressBar'
3
4
  import defaultProps from './defaultProps'
@@ -9,44 +10,53 @@ jest.mock('@/components/icon/iconCache.mjs', () => ({
9
10
  }))
10
11
 
11
12
  describe('progressBar/index.vue', () => {
12
- it('progress bar is rendered with correct props', async () => {
13
- const labelText = 'test_label_text'
14
-
15
- const wrapper = mount(RCProgressBar, {
16
- props: { ...defaultProps, labelText },
17
- global: {
18
- provide: {
19
- theme,
20
- },
21
- },
13
+ const mountBar = (props = {}) =>
14
+ mount(RCProgressBar, {
15
+ props,
16
+ global: { provide: { theme } },
22
17
  })
23
18
 
24
- const progressBarProgress = wrapper.find(
25
- '[data-test-id="progress_bar_progress"]'
26
- )
27
- const progressBarLabel = wrapper.find('[data-test-id="progress_bar_label"]')
19
+ it('renders the progress container and fill', () => {
20
+ const wrapper = mountBar()
21
+ expect(
22
+ wrapper.find('[data-test-id="progress_bar_container"]').exists()
23
+ ).toBe(true)
24
+ expect(
25
+ wrapper.find('[data-test-id="progress_bar_progress"]').exists()
26
+ ).toBe(true)
27
+ })
28
+
29
+ it('renders the label when labelText is provided', () => {
30
+ const wrapper = mountBar({ labelText: 'test_label_text' })
31
+ const label = wrapper.find('[data-test-id="progress_bar_label"]')
32
+ expect(label.exists()).toBe(true)
33
+ expect(label.text()).toBe('test_label_text')
34
+ })
28
35
 
29
- expect(wrapper.vm.fillProgress).toBe(defaultProps.fillProgress)
30
- expect(progressBarProgress.exists()).toBe(true)
31
- expect(progressBarLabel.exists()).toBe(true)
36
+ it('omits the label when labelText is empty (the default)', () => {
37
+ const wrapper = mountBar()
38
+ expect(wrapper.find('[data-test-id="progress_bar_label"]').exists()).toBe(
39
+ false
40
+ )
41
+ })
32
42
 
33
- expect(progressBarLabel.text()).toContain(labelText)
43
+ it('defaults fillProgress to 0', () => {
44
+ expect(mountBar().props('fillProgress')).toBe(0)
34
45
  })
35
46
 
36
- it('progress bar progress changes', async () => {
37
- const wrapper = mount(RCProgressBar, {
38
- props: {},
39
- global: {
40
- provide: {
41
- theme,
42
- },
43
- },
44
- })
47
+ it('accepts fillProgress as a string or a number', async () => {
48
+ const wrapper = mountBar({ fillProgress: defaultProps.fillProgress })
49
+ expect(wrapper.props('fillProgress')).toBe(defaultProps.fillProgress)
45
50
 
46
- expect(wrapper.vm.fillProgress).toBe(0)
51
+ await wrapper.setProps({ fillProgress: 40 })
52
+ expect(wrapper.props('fillProgress')).toBe(40)
53
+ })
47
54
 
48
- await wrapper.setProps({ fillProgress: defaultProps.fillProgress })
55
+ it('defaults appTheme to light and accepts dark', async () => {
56
+ const wrapper = mountBar()
57
+ expect(wrapper.props('appTheme')).toBe('light')
49
58
 
50
- expect(wrapper.vm.fillProgress).toBe(defaultProps.fillProgress)
59
+ await wrapper.setProps({ appTheme: 'dark' })
60
+ expect(wrapper.props('appTheme')).toBe('dark')
51
61
  })
52
62
  })
@@ -0,0 +1,154 @@
1
+ /* eslint-disable */
2
+ import { mount, DOMWrapper } from '@vue/test-utils'
3
+ import ProjectMarker from './index.vue'
4
+ import Icon from '@/components/icon'
5
+ import MainButton from '@/components/buttons/mainButton'
6
+
7
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
8
+ fetchIcon: jest.fn(() => Promise.resolve('')),
9
+ }))
10
+
11
+ const marker = (overrides = {}) => ({
12
+ choice: null,
13
+ color: '#9C27B0',
14
+ can_be_deleted: true,
15
+ translations: { 'en-us': { name: 'In progress' } },
16
+ ...overrides,
17
+ })
18
+
19
+ const mountMarker = (props = {}) =>
20
+ mount(ProjectMarker, {
21
+ props: { markerData: marker(), activeLanguage: 'en-us', ...props },
22
+ })
23
+
24
+ /** The clickable pill is the first element child of the root container. */
25
+ const clickPill = (wrapper) =>
26
+ new DOMWrapper(wrapper.element.children[0]).trigger('click')
27
+
28
+ describe('ProjectMarker', () => {
29
+ it('does not render the pill when there is no translation for the active language', () => {
30
+ const wrapper = mountMarker({ activeLanguage: 'de-de' })
31
+ expect(wrapper.text()).not.toContain('In progress')
32
+ })
33
+
34
+ it('renders the marker name from the active language translation', () => {
35
+ expect(mountMarker().text()).toContain('In progress')
36
+ })
37
+
38
+ it('renders a color dot and no status icon for a plain marker', () => {
39
+ const wrapper = mountMarker()
40
+ expect(wrapper.findComponent(Icon).exists()).toBe(false)
41
+ })
42
+
43
+ it('renders the checkmark icon for a sold marker', () => {
44
+ const wrapper = mountMarker({
45
+ markerData: marker({
46
+ choice: 'sold',
47
+ translations: { 'en-us': { name: 'Sold' } },
48
+ }),
49
+ })
50
+ const icon = wrapper.findComponent(Icon)
51
+ expect(icon.exists()).toBe(true)
52
+ expect(icon.attributes('name')).toBe('checkmark')
53
+ })
54
+
55
+ it('renders the cross_filled icon for a lost marker', () => {
56
+ const wrapper = mountMarker({
57
+ markerData: marker({
58
+ choice: 'lost',
59
+ translations: { 'en-us': { name: 'Lost' } },
60
+ }),
61
+ })
62
+ const icon = wrapper.findComponent(Icon)
63
+ expect(icon.exists()).toBe(true)
64
+ expect(icon.attributes('name')).toBe('cross_filled')
65
+ })
66
+
67
+ it('renders the date only when a date is provided', () => {
68
+ expect(mountMarker({ date: '23.07.2022' }).text()).toContain('23.07.2022')
69
+ expect(mountMarker().text()).not.toContain('23.07.2022')
70
+ })
71
+
72
+ describe('edition', () => {
73
+ const editableProps = { isEditable: true }
74
+
75
+ it('shows the three-dots affordance when edition is allowed', () => {
76
+ const wrapper = mountMarker(editableProps)
77
+ expect(wrapper.find('.dotContainer').exists()).toBe(true)
78
+ })
79
+
80
+ it('hides the affordance when isEditable is false', () => {
81
+ expect(mountMarker().find('.dotContainer').exists()).toBe(false)
82
+ })
83
+
84
+ it('hides the affordance when the marker cannot be deleted', () => {
85
+ const wrapper = mountMarker({
86
+ ...editableProps,
87
+ markerData: marker({ can_be_deleted: false }),
88
+ })
89
+ expect(wrapper.find('.dotContainer').exists()).toBe(false)
90
+ })
91
+
92
+ it('hides the affordance for a limited partner without group support', () => {
93
+ const wrapper = mountMarker({ ...editableProps, isLimitedPartner: true })
94
+ expect(wrapper.find('.dotContainer').exists()).toBe(false)
95
+ })
96
+
97
+ it('restores the affordance for a limited partner with group support', () => {
98
+ const wrapper = mountMarker({
99
+ ...editableProps,
100
+ isLimitedPartner: true,
101
+ isGroupSupport: true,
102
+ })
103
+ expect(wrapper.find('.dotContainer').exists()).toBe(true)
104
+ })
105
+
106
+ it('toggles the edit menu when the pill is clicked', async () => {
107
+ const wrapper = mountMarker(editableProps)
108
+ expect(wrapper.text()).not.toContain('Edit')
109
+
110
+ await clickPill(wrapper)
111
+ expect(wrapper.text()).toContain('Edit')
112
+ expect(wrapper.text()).toContain('Delete')
113
+
114
+ await clickPill(wrapper)
115
+ expect(wrapper.text()).not.toContain('Edit')
116
+ })
117
+
118
+ it('does not open the menu when edition is not allowed', async () => {
119
+ const wrapper = mountMarker()
120
+ await clickPill(wrapper)
121
+ expect(wrapper.text()).not.toContain('Edit')
122
+ })
123
+
124
+ it('emits editHandler when the Edit action is clicked', async () => {
125
+ const wrapper = mountMarker(editableProps)
126
+ await clickPill(wrapper)
127
+
128
+ const editItem = wrapper
129
+ .findAll('div')
130
+ .find((d) => d.text() === 'Edit')
131
+ await editItem.trigger('click')
132
+
133
+ expect(wrapper.emitted('editHandler')).toHaveLength(1)
134
+ })
135
+
136
+ it('opens the delete confirmation modal and emits deleteHandler on confirm', async () => {
137
+ const wrapper = mountMarker(editableProps)
138
+ await clickPill(wrapper)
139
+
140
+ const deleteItem = wrapper
141
+ .findAll('div')
142
+ .find((d) => d.text() === 'Delete')
143
+ await deleteItem.trigger('click')
144
+ expect(wrapper.text()).toContain('delete_confirm_text')
145
+
146
+ const confirm = wrapper
147
+ .findAllComponents(MainButton)
148
+ .find((b) => b.props('text') === 'Delete')
149
+ await confirm.trigger('click')
150
+
151
+ expect(wrapper.emitted('deleteHandler')).toHaveLength(1)
152
+ })
153
+ })
154
+ })
@@ -0,0 +1,193 @@
1
+ /* eslint-disable */
2
+ import { mount } from '@vue/test-utils'
3
+ import TableDropdown from './index.vue'
4
+ import SearchInput from '@/components/inputs/searchInput'
5
+ import InputText from '@/components/inputs/inputText'
6
+ import MainButton from '@/components/buttons/mainButton'
7
+ import Spinner from '@/components/spinner'
8
+
9
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
10
+ fetchIcon: jest.fn(() => Promise.resolve('')),
11
+ }))
12
+
13
+ // Jest config maps .svg but not .png; stub the raster asset the component imports.
14
+ jest.mock('@/assets/icons/file_icon.png', () => 'file-icon-mock', {
15
+ virtual: true,
16
+ })
17
+
18
+ beforeAll(() => {
19
+ global.ResizeObserver = jest.fn().mockImplementation(() => ({
20
+ observe: jest.fn(),
21
+ unobserve: jest.fn(),
22
+ disconnect: jest.fn(),
23
+ }))
24
+ })
25
+
26
+ const mountDropdown = (props = {}) =>
27
+ mount(TableDropdown, {
28
+ props: {
29
+ colSpan: 2,
30
+ tableItems: [{ type: 'text', value: 'Selected Item' }],
31
+ isOpen: false,
32
+ optionItems: [
33
+ { display_name: 'Item 1', company_item_number: '123' },
34
+ { display_name: 'Item 2', company_item_number: '456' },
35
+ ],
36
+ optionsDisplay: ['display_name', 'company_item_number'],
37
+ ...props,
38
+ },
39
+ })
40
+
41
+ const findByText = (wrapper, selector, text) =>
42
+ wrapper.findAll(selector).find((el) => el.text() === text)
43
+
44
+ describe('TableDropdown', () => {
45
+ describe('rendering table items', () => {
46
+ it('renders a plain text item value', () => {
47
+ expect(mountDropdown().text()).toContain('Selected Item')
48
+ })
49
+
50
+ it('renders the "Use template..." button for an empty template item', () => {
51
+ const wrapper = mountDropdown({
52
+ tableItems: [{ type: 'template', value: '', row: { id: 1 } }],
53
+ })
54
+ expect(wrapper.findComponent(MainButton).props('text')).toBe(
55
+ 'Use template...'
56
+ )
57
+ })
58
+
59
+ it('renders a template link with the value for a filled template item', () => {
60
+ const wrapper = mountDropdown({
61
+ tableItems: [{ type: 'template', value: 'My Template', row: { id: 2 } }],
62
+ })
63
+ expect(wrapper.text()).toContain('My Template')
64
+ expect(wrapper.findComponent(MainButton).exists()).toBe(false)
65
+ })
66
+
67
+ it('renders the no-template placeholder text', () => {
68
+ const wrapper = mountDropdown({
69
+ tableItems: [{ type: 'no-template' }],
70
+ })
71
+ expect(wrapper.text()).toContain('No main component template')
72
+ })
73
+
74
+ it('renders an editable input when customInputDisabled is false', () => {
75
+ const wrapper = mountDropdown({
76
+ tableItems: [{ type: 'input', value: 'abc' }],
77
+ customInputDisabled: false,
78
+ })
79
+ expect(wrapper.findComponent(InputText).exists()).toBe(true)
80
+ })
81
+
82
+ it('renders a read-only placeholder when customInputDisabled is true', () => {
83
+ const wrapper = mountDropdown({
84
+ tableItems: [{ type: 'input', value: 'abc' }],
85
+ customInputDisabled: true,
86
+ })
87
+ expect(wrapper.findComponent(InputText).exists()).toBe(false)
88
+ expect(wrapper.find('.input-placeholder').text()).toContain('abc')
89
+ })
90
+
91
+ it('applies rowDataId as the data-id on the matching row', () => {
92
+ const wrapper = mountDropdown({
93
+ tableItems: [{ type: 'input', value: 'x', inputType: 'price' }],
94
+ rowDataId: [{ inputType: 'price', dataId: 'row_price' }],
95
+ })
96
+ expect(wrapper.find('[data-id="row_price"]').exists()).toBe(true)
97
+ })
98
+ })
99
+
100
+ describe('open state', () => {
101
+ it('does not render the options container when closed', () => {
102
+ const wrapper = mountDropdown({ isOpen: false })
103
+ expect(wrapper.findComponent(SearchInput).exists()).toBe(false)
104
+ })
105
+
106
+ it('renders the search input and options when open', () => {
107
+ const wrapper = mountDropdown({ isOpen: true })
108
+ expect(wrapper.findComponent(SearchInput).exists()).toBe(true)
109
+ expect(wrapper.text()).toContain('Item 1')
110
+ expect(wrapper.text()).toContain('Item 2')
111
+ })
112
+
113
+ it('shows the spinner while options are loading', () => {
114
+ const wrapper = mountDropdown({ isOpen: true, optionsLoading: true })
115
+ expect(wrapper.findComponent(Spinner).exists()).toBe(true)
116
+ })
117
+
118
+ it('shows the default empty text when there are no options', () => {
119
+ const wrapper = mountDropdown({ isOpen: true, optionItems: [] })
120
+ expect(wrapper.text()).toContain('No components found.')
121
+ })
122
+
123
+ it('shows a custom empty text when provided', () => {
124
+ const wrapper = mountDropdown({
125
+ isOpen: true,
126
+ optionItems: [],
127
+ emptyText: 'Nothing here',
128
+ })
129
+ expect(wrapper.text()).toContain('Nothing here')
130
+ })
131
+ })
132
+
133
+ describe('interactions', () => {
134
+ it('emits toggle-dropdown-open with close:true when an open row is clicked', async () => {
135
+ const wrapper = mountDropdown({ isOpen: true })
136
+ await wrapper.trigger('click')
137
+ expect(wrapper.emitted('toggle-dropdown-open')).toEqual([
138
+ [{ close: true }],
139
+ ])
140
+ })
141
+
142
+ it('does not emit toggle when the row is disabled', async () => {
143
+ const wrapper = mountDropdown({ isOpen: false, disabled: true })
144
+ await wrapper.trigger('click')
145
+ expect(wrapper.emitted('toggle-dropdown-open')).toBeUndefined()
146
+ })
147
+
148
+ it('emits dropdown-search when the search input changes', async () => {
149
+ const wrapper = mountDropdown({ isOpen: true })
150
+ await wrapper.findComponent(SearchInput).vm.$emit('on-change', 'panel')
151
+ expect(wrapper.emitted('dropdown-search').at(-1)).toEqual(['panel'])
152
+ })
153
+
154
+ it('emits item-selected with the option when an option is clicked', async () => {
155
+ const wrapper = mountDropdown({ isOpen: true })
156
+ await findByText(wrapper, 'span', 'Item 1').trigger('click')
157
+ expect(wrapper.emitted('item-selected')[0][0]).toMatchObject({
158
+ display_name: 'Item 1',
159
+ })
160
+ })
161
+
162
+ it('emits on-template-click with the row when the template button is clicked', async () => {
163
+ const wrapper = mountDropdown({
164
+ isOpen: true,
165
+ tableItems: [{ type: 'template', value: '', row: { id: 7 } }],
166
+ })
167
+ await wrapper.findComponent(MainButton).trigger('click')
168
+ expect(wrapper.emitted('on-template-click')).toEqual([[{ id: 7 }]])
169
+ })
170
+
171
+ it('emits on-selected-template-click with the row when the template link is clicked', async () => {
172
+ const wrapper = mountDropdown({
173
+ isOpen: true,
174
+ tableItems: [{ type: 'template', value: 'T1', row: { id: 8 } }],
175
+ })
176
+ // Click the innermost value div so the event bubbles to the link's handler.
177
+ const links = wrapper.findAll('div').filter((d) => d.text() === 'T1')
178
+ await links.at(-1).trigger('click')
179
+ expect(wrapper.emitted('on-selected-template-click')).toEqual([
180
+ [{ id: 8 }],
181
+ ])
182
+ })
183
+
184
+ it('emits custom-input-change when the editable input changes', async () => {
185
+ const wrapper = mountDropdown({
186
+ tableItems: [{ type: 'input', value: 'abc' }],
187
+ customInputDisabled: false,
188
+ })
189
+ await wrapper.findComponent(InputText).vm.$emit('input-change', 'xyz')
190
+ expect(wrapper.emitted('custom-input-change')).toEqual([['xyz']])
191
+ })
192
+ })
193
+ })
@@ -0,0 +1,101 @@
1
+ /* eslint-disable */
2
+ import { mount } from '@vue/test-utils'
3
+ import MainTable from './index.vue'
4
+ import Spinner from '@/components/spinner'
5
+
6
+ beforeAll(() => {
7
+ global.ResizeObserver = jest.fn().mockImplementation(() => ({
8
+ observe: jest.fn(),
9
+ unobserve: jest.fn(),
10
+ disconnect: jest.fn(),
11
+ }))
12
+ })
13
+
14
+ const SLOT = `
15
+ <tbody>
16
+ <tr>
17
+ <td data-test-id="r1c1">A</td>
18
+ <td data-test-id="r1c2">B</td>
19
+ </tr>
20
+ <tr>
21
+ <td data-test-id="r2c1">C</td>
22
+ <td data-test-id="r2c2">D</td>
23
+ </tr>
24
+ </tbody>
25
+ `
26
+
27
+ const mountTable = (props = {}, options = {}) =>
28
+ mount(MainTable, {
29
+ props,
30
+ slots: { default: SLOT },
31
+ ...options,
32
+ })
33
+
34
+ describe('MainTable', () => {
35
+ describe('title', () => {
36
+ it('renders the title when titleText is provided', () => {
37
+ expect(mountTable({ titleText: 'projects' }).text()).toContain('projects')
38
+ })
39
+
40
+ it('does not render a title by default', () => {
41
+ const wrapper = mountTable()
42
+ expect(wrapper.text()).not.toContain('projects')
43
+ })
44
+ })
45
+
46
+ describe('loading', () => {
47
+ it('shows the spinner and hides the table while loading', () => {
48
+ const wrapper = mountTable({ isLoading: true })
49
+ expect(wrapper.findComponent(Spinner).exists()).toBe(true)
50
+ expect(wrapper.find('table').exists()).toBe(false)
51
+ })
52
+
53
+ it('renders the table with slot content when not loading', () => {
54
+ const wrapper = mountTable()
55
+ expect(wrapper.findComponent(Spinner).exists()).toBe(false)
56
+ expect(wrapper.find('table').exists()).toBe(true)
57
+ expect(wrapper.find('[data-test-id="r1c1"]').text()).toBe('A')
58
+ })
59
+ })
60
+
61
+ describe('column hover (hasAimHover)', () => {
62
+ it('highlights the whole column on mouseover and clears it on mouseleave', async () => {
63
+ const wrapper = mountTable(
64
+ { hasAimHover: true },
65
+ { attachTo: document.body }
66
+ )
67
+
68
+ await wrapper.find('[data-test-id="r1c1"]').trigger('mouseover')
69
+ expect(
70
+ wrapper.find('[data-test-id="r1c1"]').classes()
71
+ ).toContain('hovered-column')
72
+ expect(
73
+ wrapper.find('[data-test-id="r2c1"]').classes()
74
+ ).toContain('hovered-column')
75
+ expect(
76
+ wrapper.find('[data-test-id="r1c2"]').classes()
77
+ ).not.toContain('hovered-column')
78
+
79
+ await wrapper.find('table').trigger('mouseleave')
80
+ expect(
81
+ wrapper.find('[data-test-id="r2c1"]').classes()
82
+ ).not.toContain('hovered-column')
83
+
84
+ wrapper.unmount()
85
+ })
86
+
87
+ it('does not highlight columns when hasAimHover is false', async () => {
88
+ const wrapper = mountTable(
89
+ { hasAimHover: false },
90
+ { attachTo: document.body }
91
+ )
92
+
93
+ await wrapper.find('[data-test-id="r1c1"]').trigger('mouseover')
94
+ expect(
95
+ wrapper.find('[data-test-id="r2c1"]').classes()
96
+ ).not.toContain('hovered-column')
97
+
98
+ wrapper.unmount()
99
+ })
100
+ })
101
+ })
@@ -0,0 +1,121 @@
1
+ /* eslint-disable */
2
+ import { mount, DOMWrapper } from '@vue/test-utils'
3
+ import ThreeDots from './index.vue'
4
+
5
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
6
+ fetchIcon: jest.fn(() => Promise.resolve('')),
7
+ }))
8
+
9
+ const options = [
10
+ { value: 'edit', name: 'Edit' },
11
+ { value: 'del', name: 'Delete', disabled: true },
12
+ {
13
+ value: 'more',
14
+ name: 'More',
15
+ children: [
16
+ { value: 'c1', name: 'Child 1' },
17
+ { value: 'c2', name: 'Child 2' },
18
+ ],
19
+ },
20
+ ]
21
+
22
+ // The dropdown is teleported to <body>, so query it there rather than in the wrapper.
23
+ const inBody = (selector) => document.body.querySelector(selector)
24
+ const optionEl = (value) =>
25
+ inBody(`[data-id="three_dots_option_item_0_${value}"]`)
26
+ const click = (el) => new DOMWrapper(el).trigger('click')
27
+
28
+ let wrapper
29
+ const mountDots = (props = {}) => {
30
+ wrapper = mount(ThreeDots, {
31
+ props: { options, ...props },
32
+ attachTo: document.body,
33
+ })
34
+ return wrapper
35
+ }
36
+
37
+ afterEach(() => {
38
+ wrapper?.unmount()
39
+ wrapper = null
40
+ })
41
+
42
+ describe('ThreeDots', () => {
43
+ it('exposes dataId and dataQaId on the trigger', () => {
44
+ const w = mountDots({ dataId: 'row_actions', dataQaId: 'qa_actions' })
45
+ expect(w.find('[data-id="row_actions"]').exists()).toBe(true)
46
+ expect(w.find('[data-qa-id="qa_actions"]').exists()).toBe(true)
47
+ })
48
+
49
+ it('does not render the dropdown until the trigger is clicked', async () => {
50
+ mountDots()
51
+ expect(optionEl('edit')).toBeNull()
52
+ })
53
+
54
+ it('opens the dropdown and lists the options on click', async () => {
55
+ const w = mountDots()
56
+ await w.trigger('click')
57
+
58
+ expect(optionEl('edit')).not.toBeNull()
59
+ expect(optionEl('del')).not.toBeNull()
60
+ expect(optionEl('more')).not.toBeNull()
61
+ expect(document.body.textContent).toContain('Edit')
62
+ })
63
+
64
+ it('shows the spinner and no options while loading', async () => {
65
+ const w = mountDots({ isLoading: true })
66
+ await w.trigger('click')
67
+
68
+ expect(inBody('[data-test-id="spinner_container"]')).not.toBeNull()
69
+ expect(optionEl('edit')).toBeNull()
70
+ })
71
+
72
+ it('emits on-select and closes when a plain option is clicked', async () => {
73
+ const w = mountDots()
74
+ await w.trigger('click')
75
+
76
+ await click(optionEl('edit'))
77
+
78
+ expect(w.emitted('on-select')).toHaveLength(1)
79
+ expect(w.emitted('on-select')[0][0]).toMatchObject({ value: 'edit' })
80
+ expect(w.emitted('on-select')[0][0].name).toBe('Edit')
81
+ expect(optionEl('edit')).toBeNull()
82
+ })
83
+
84
+ it('emits on-click (not on-select) and stays open for a disabled option', async () => {
85
+ const w = mountDots()
86
+ await w.trigger('click')
87
+
88
+ await click(optionEl('del'))
89
+
90
+ expect(w.emitted('on-click')[0][0]).toMatchObject({ value: 'del' })
91
+ expect(w.emitted('on-select')).toBeUndefined()
92
+ expect(optionEl('del')).not.toBeNull()
93
+ })
94
+
95
+ it('emits on-click and stays open for an option with children', async () => {
96
+ const w = mountDots()
97
+ await w.trigger('click')
98
+
99
+ await click(optionEl('more'))
100
+
101
+ expect(w.emitted('on-click')[0][0]).toMatchObject({ value: 'more' })
102
+ expect(w.emitted('on-select')).toBeUndefined()
103
+ expect(optionEl('more')).not.toBeNull()
104
+ })
105
+
106
+ it('reveals children on hover and emits on-select when a child is clicked', async () => {
107
+ const w = mountDots()
108
+ await w.trigger('click')
109
+
110
+ await new DOMWrapper(optionEl('more')).trigger('mouseover')
111
+ expect(document.body.textContent).toContain('Child 1')
112
+
113
+ const child = new DOMWrapper(document.body)
114
+ .findAll('div')
115
+ .find((d) => d.text() === 'Child 1')
116
+ await child.trigger('click')
117
+
118
+ expect(w.emitted('on-select')).toHaveLength(1)
119
+ expect(w.emitted('on-select')[0][0]).toMatchObject({ value: 'c1' })
120
+ })
121
+ })
@@ -7,6 +7,25 @@ const basicOptions = [
7
7
  { value: 'delete', name: 'Delete' },
8
8
  ]
9
9
 
10
+ const nestedOptions = [
11
+ { value: 'edit', name: 'Edit' },
12
+ {
13
+ value: 'move',
14
+ name: 'Move to',
15
+ children: [
16
+ { value: 'folder_a', name: 'Folder A' },
17
+ { value: 'folder_b', name: 'Folder B' },
18
+ ],
19
+ },
20
+ { value: 'delete', name: 'Delete' },
21
+ ]
22
+
23
+ const withDisabledOptions = [
24
+ { value: 'edit', name: 'Edit' },
25
+ { value: 'duplicate', name: 'Duplicate', disabled: true },
26
+ { value: 'delete', name: 'Delete' },
27
+ ]
28
+
10
29
  export default {
11
30
  title: 'Components/ThreeDots/ThreeDots',
12
31
  component: ThreeDots,
@@ -57,3 +76,33 @@ Loading.args = {
57
76
  ...Default.args,
58
77
  isLoading: true,
59
78
  }
79
+
80
+ export const WithNestedChildren = Template.bind({})
81
+ WithNestedChildren.args = {
82
+ ...Default.args,
83
+ options: nestedOptions,
84
+ }
85
+
86
+ export const WithDisabledOption = Template.bind({})
87
+ WithDisabledOption.args = {
88
+ ...Default.args,
89
+ options: withDisabledOptions,
90
+ }
91
+
92
+ export const DarkTheme = Template.bind({})
93
+ DarkTheme.args = {
94
+ ...Default.args,
95
+ colorTheme: 'dark',
96
+ }
97
+
98
+ export const NoTextWrap = Template.bind({})
99
+ NoTextWrap.args = {
100
+ ...Default.args,
101
+ textWrap: false,
102
+ }
103
+
104
+ export const CustomIconColor = Template.bind({})
105
+ CustomIconColor.args = {
106
+ ...Default.args,
107
+ iconColor: '#2196F3',
108
+ }
@@ -0,0 +1,46 @@
1
+ /* eslint-disable */
2
+ import { mount } from '@vue/test-utils'
3
+ import VideoThumbnail from './index.vue'
4
+ import Icon from '@/components/icon'
5
+
6
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
7
+ fetchIcon: jest.fn(() => Promise.resolve('')),
8
+ }))
9
+
10
+ const SRC = 'https://example.com/thumb.jpg'
11
+
12
+ const mountThumbnail = (props = {}) =>
13
+ mount(VideoThumbnail, { props: { src: SRC, ...props } })
14
+
15
+ describe('VideoThumbnail', () => {
16
+ it('renders the image with the provided src', () => {
17
+ expect(mountThumbnail().find('img').attributes('src')).toBe(SRC)
18
+ })
19
+
20
+ it('updates the image when the src prop changes', async () => {
21
+ const wrapper = mountThumbnail()
22
+ await wrapper.setProps({ src: 'https://example.com/other.jpg' })
23
+ expect(wrapper.find('img').attributes('src')).toBe(
24
+ 'https://example.com/other.jpg'
25
+ )
26
+ })
27
+
28
+ it('renders the play icon', () => {
29
+ expect(mountThumbnail().findComponent(Icon).attributes('name')).toBe('play')
30
+ })
31
+
32
+ it('uses the default play icon color and size', () => {
33
+ const icon = mountThumbnail().findComponent(Icon)
34
+ expect(icon.attributes('color')).toBe('blue')
35
+ expect(icon.attributes('size')).toBe('50px')
36
+ })
37
+
38
+ it('forwards playIconColor and playIconSize to the icon', () => {
39
+ const icon = mountThumbnail({
40
+ playIconColor: 'red',
41
+ playIconSize: '20px',
42
+ }).findComponent(Icon)
43
+ expect(icon.attributes('color')).toBe('red')
44
+ expect(icon.attributes('size')).toBe('20px')
45
+ })
46
+ })