@eturnity/eturnity_reusable_components 9.25.17-qa-03.0 → 9.28.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 +1 -1
- package/src/components/collapsableInfoText/collapsableInfoText.spec.js +57 -0
- package/src/components/collapsableInfoText/collapsableInfoText.stories.js +8 -0
- package/src/components/filter/Filter.stories.js +177 -0
- package/src/components/filter/filter.spec.js +183 -0
- package/src/components/filterComponent/viewFilter.vue +57 -2
- package/src/components/inputs/select/select.spec.js +154 -0
- package/src/components/inputs/select/select.stories.js +215 -89
- package/src/components/inputs/slider/slider.spec.js +59 -0
- package/src/components/inputs/slider/slider.stories.js +4 -2
- package/src/components/inputs/switchField/switchField.spec.js +88 -0
- package/src/components/inputs/textAreaInput/TextAreaInput.stories.js +78 -74
- package/src/components/inputs/textAreaInput/textAreaInput.spec.js +90 -0
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import CollapsableInfoText from '@/components/collapsableInfoText'
|
|
4
|
+
|
|
5
|
+
// `theme` and the `$gettext` mock are provided globally by jest.setup.js.
|
|
6
|
+
// $gettext returns the message unchanged, so the toggle renders the literal
|
|
7
|
+
// "Show more" / "Show less" labels.
|
|
8
|
+
|
|
9
|
+
// The component only renders the toggle once the text is longer than 170 chars.
|
|
10
|
+
const LONG_TEXT = 'A long info text. '.repeat(20) // > 170 chars
|
|
11
|
+
const SHORT_TEXT = 'A short info text.' // <= 170 chars
|
|
12
|
+
|
|
13
|
+
describe('CollapsableInfoText', () => {
|
|
14
|
+
const mountText = (props = {}) =>
|
|
15
|
+
mount(CollapsableInfoText, { props: { text: LONG_TEXT, ...props } })
|
|
16
|
+
|
|
17
|
+
const findButton = (wrapper, label) =>
|
|
18
|
+
wrapper.findAll('p').find((p) => p.text() === label)
|
|
19
|
+
|
|
20
|
+
it('renders the provided text', () => {
|
|
21
|
+
const wrapper = mountText({ text: 'Hello integrators' })
|
|
22
|
+
expect(wrapper.text()).toContain('Hello integrators')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('shows the "Show more" toggle when the text is long', () => {
|
|
26
|
+
const wrapper = mountText({ text: LONG_TEXT })
|
|
27
|
+
expect(findButton(wrapper, 'Show more')).toBeDefined()
|
|
28
|
+
expect(findButton(wrapper, 'Show less')).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('hides the toggle when the text is short', () => {
|
|
32
|
+
const wrapper = mountText({ text: SHORT_TEXT })
|
|
33
|
+
expect(findButton(wrapper, 'Show more')).toBeUndefined()
|
|
34
|
+
expect(findButton(wrapper, 'Show less')).toBeUndefined()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('expands to "Show less" when the toggle is clicked', async () => {
|
|
38
|
+
const wrapper = mountText({ text: LONG_TEXT })
|
|
39
|
+
|
|
40
|
+
await findButton(wrapper, 'Show more').trigger('click')
|
|
41
|
+
|
|
42
|
+
expect(wrapper.vm.showAll).toBe(true)
|
|
43
|
+
expect(findButton(wrapper, 'Show less')).toBeDefined()
|
|
44
|
+
expect(findButton(wrapper, 'Show more')).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('collapses back to "Show more" on a second click', async () => {
|
|
48
|
+
const wrapper = mountText({ text: LONG_TEXT })
|
|
49
|
+
|
|
50
|
+
await findButton(wrapper, 'Show more').trigger('click')
|
|
51
|
+
await findButton(wrapper, 'Show less').trigger('click')
|
|
52
|
+
|
|
53
|
+
expect(wrapper.vm.showAll).toBe(false)
|
|
54
|
+
expect(findButton(wrapper, 'Show more')).toBeDefined()
|
|
55
|
+
expect(findButton(wrapper, 'Show less')).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import FilterComponent from './index.vue'
|
|
2
|
+
import theme from '@/assets/theme'
|
|
3
|
+
|
|
4
|
+
// To use:
|
|
5
|
+
// import Filter from '@eturnity/eturnity_reusable_components/src/components/filter'
|
|
6
|
+
// <Filter
|
|
7
|
+
// :filter-data="filterData"
|
|
8
|
+
// :filter-views="filterViews"
|
|
9
|
+
// :button-text="buttonText"
|
|
10
|
+
// active-view="Default view"
|
|
11
|
+
// @on-filter-settings-change="..."
|
|
12
|
+
// @on-reset-filters="..."
|
|
13
|
+
// />
|
|
14
|
+
|
|
15
|
+
// The panel (`FilterSettings`) is data-driven: each `filterData` entry is a
|
|
16
|
+
// column of type `columns` (draggable visibility checkboxes) or `filter`
|
|
17
|
+
// (selectable / range filters). `filter_type` drives which control renders.
|
|
18
|
+
const FILTER_DATA = [
|
|
19
|
+
{
|
|
20
|
+
type: 'columns',
|
|
21
|
+
columnName: 'Columns',
|
|
22
|
+
dataOptions: [
|
|
23
|
+
{ choice: 'name', text: 'Name', selected: true },
|
|
24
|
+
{ choice: 'status', text: 'Status', selected: true },
|
|
25
|
+
{ choice: 'created', text: 'Created', selected: false },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
type: 'filter',
|
|
30
|
+
columnName: 'Filters',
|
|
31
|
+
dataOptions: [
|
|
32
|
+
{
|
|
33
|
+
field: 'status',
|
|
34
|
+
label: 'Status',
|
|
35
|
+
filter_type: 'multi_select_string',
|
|
36
|
+
dataId: 'filter_status',
|
|
37
|
+
selectedText: 'All',
|
|
38
|
+
choices: [
|
|
39
|
+
{ text: 'Active', choice: 'active', selected: true },
|
|
40
|
+
{ text: 'Archived', choice: 'archived', selected: false },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
field: 'is_favourite',
|
|
45
|
+
label: 'Favourite',
|
|
46
|
+
filter_type: 'boolean',
|
|
47
|
+
dataId: 'filter_favourite',
|
|
48
|
+
selectedText: 'Any',
|
|
49
|
+
choices: [
|
|
50
|
+
{ text: 'Yes', choice: true, selected: false },
|
|
51
|
+
{ text: 'No', choice: false, selected: false },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
field: 'power',
|
|
56
|
+
label: 'Power (kW)',
|
|
57
|
+
filter_type: 'integer_range',
|
|
58
|
+
dataId: 'filter_power',
|
|
59
|
+
selectedText: '',
|
|
60
|
+
unit: 'kW',
|
|
61
|
+
choices: [],
|
|
62
|
+
range: { start: null, end: null },
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
const FILTER_VIEWS = [{ name: 'Default view' }, { name: 'My projects' }]
|
|
69
|
+
|
|
70
|
+
const BUTTON_TEXT = {
|
|
71
|
+
apply_view: 'Apply view',
|
|
72
|
+
save_view: 'Save view',
|
|
73
|
+
cancel: 'Cancel',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Two applied filters → the trigger badge shows "2".
|
|
77
|
+
const CURRENT_FILTER_DATA = {
|
|
78
|
+
filters: [{ field: 'status' }, { field: 'is_favourite' }],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default {
|
|
82
|
+
title: 'Components/Filter/Filter',
|
|
83
|
+
component: FilterComponent,
|
|
84
|
+
tags: ['autodocs'],
|
|
85
|
+
parameters: {
|
|
86
|
+
docs: {
|
|
87
|
+
description: {
|
|
88
|
+
component:
|
|
89
|
+
'Dropdown filter built from a `ParentDropdown` trigger and a ' +
|
|
90
|
+
'`FilterSettings` panel. Click the trigger to open the panel; the ' +
|
|
91
|
+
'badge shows how many filters are currently applied.',
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
argTypes: {
|
|
96
|
+
dropdownText: { control: 'text' },
|
|
97
|
+
componentName: { control: 'text' },
|
|
98
|
+
showActiveFilter: { control: 'boolean' },
|
|
99
|
+
activeView: { control: 'text' },
|
|
100
|
+
filterData: { control: false },
|
|
101
|
+
filterViews: { control: false },
|
|
102
|
+
buttonText: { control: false },
|
|
103
|
+
currentFilterData: { control: false },
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const Template = (args) => ({
|
|
108
|
+
components: { FilterComponent },
|
|
109
|
+
setup() {
|
|
110
|
+
return { args }
|
|
111
|
+
},
|
|
112
|
+
template: `
|
|
113
|
+
<div data-test-id="filter-story" style="padding: 24px; min-height: 360px;">
|
|
114
|
+
<FilterComponent v-bind="args" />
|
|
115
|
+
</div>
|
|
116
|
+
`,
|
|
117
|
+
provide: { theme },
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
export const Default = Template.bind({})
|
|
121
|
+
Default.args = {
|
|
122
|
+
componentName: 'demo',
|
|
123
|
+
dropdownText: 'Default view',
|
|
124
|
+
filterData: FILTER_DATA,
|
|
125
|
+
filterViews: FILTER_VIEWS,
|
|
126
|
+
buttonText: BUTTON_TEXT,
|
|
127
|
+
activeView: 'Default view',
|
|
128
|
+
showActiveFilter: true,
|
|
129
|
+
currentFilterData: CURRENT_FILTER_DATA,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Without any saved views the top section (active-view select + reset) is
|
|
134
|
+
* hidden and only the filter columns are shown.
|
|
135
|
+
*/
|
|
136
|
+
export const NoSavedViews = Template.bind({})
|
|
137
|
+
NoSavedViews.args = {
|
|
138
|
+
componentName: 'demo',
|
|
139
|
+
dropdownText: 'Filters',
|
|
140
|
+
filterData: FILTER_DATA,
|
|
141
|
+
filterViews: [],
|
|
142
|
+
buttonText: BUTTON_TEXT,
|
|
143
|
+
activeView: null,
|
|
144
|
+
showActiveFilter: true,
|
|
145
|
+
currentFilterData: null,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `showActiveFilter: false` hides the active-view select while keeping the
|
|
150
|
+
* reset action.
|
|
151
|
+
*/
|
|
152
|
+
export const WithoutActiveFilterSelect = Template.bind({})
|
|
153
|
+
WithoutActiveFilterSelect.args = {
|
|
154
|
+
componentName: 'demo',
|
|
155
|
+
dropdownText: 'Default view',
|
|
156
|
+
filterData: FILTER_DATA,
|
|
157
|
+
filterViews: FILTER_VIEWS,
|
|
158
|
+
buttonText: BUTTON_TEXT,
|
|
159
|
+
activeView: 'Default view',
|
|
160
|
+
showActiveFilter: false,
|
|
161
|
+
currentFilterData: CURRENT_FILTER_DATA,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A custom trigger label and a single applied filter (badge shows "1").
|
|
166
|
+
*/
|
|
167
|
+
export const CustomTriggerText = Template.bind({})
|
|
168
|
+
CustomTriggerText.args = {
|
|
169
|
+
componentName: 'demo',
|
|
170
|
+
dropdownText: 'Project filters',
|
|
171
|
+
filterData: FILTER_DATA,
|
|
172
|
+
filterViews: FILTER_VIEWS,
|
|
173
|
+
buttonText: BUTTON_TEXT,
|
|
174
|
+
activeView: 'Default view',
|
|
175
|
+
showActiveFilter: true,
|
|
176
|
+
currentFilterData: { filters: [{ field: 'status' }] },
|
|
177
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
import { flushPromises, mount } from '@vue/test-utils'
|
|
3
|
+
import FilterComponent from '@/components/filter'
|
|
4
|
+
|
|
5
|
+
// The real children pull in heavy deps (date picker, draggable, teleported
|
|
6
|
+
// selects, icon cache) that can't run under Jest. Stub them so we can test the
|
|
7
|
+
// host component's own contract: the applied-filters badge, open/close
|
|
8
|
+
// behaviour, and the emit wiring between the panel and the parent.
|
|
9
|
+
jest.mock('@/components/filter/parentDropdown', () => ({
|
|
10
|
+
__esModule: true,
|
|
11
|
+
default: {
|
|
12
|
+
name: 'ParentDropdown',
|
|
13
|
+
props: ['componentName', 'dropdownText', 'isOpen', 'numberOfFiltersApplied'],
|
|
14
|
+
emits: ['on-toggle'],
|
|
15
|
+
template:
|
|
16
|
+
'<button data-test-id="parent-dropdown" @click="$emit(\'on-toggle\')">{{ numberOfFiltersApplied }}</button>',
|
|
17
|
+
},
|
|
18
|
+
}))
|
|
19
|
+
jest.mock('@/components/filter/filterSettings', () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
default: {
|
|
22
|
+
name: 'FilterSettings',
|
|
23
|
+
template: '<div data-test-id="filter-settings"></div>',
|
|
24
|
+
},
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
// requestIdleCallback is absent in jsdom; stub it as a no-op so the panel only
|
|
28
|
+
// mounts on an explicit toggle (no background prewarm timers in tests).
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
global.requestIdleCallback = jest.fn()
|
|
31
|
+
global.cancelIdleCallback = jest.fn()
|
|
32
|
+
})
|
|
33
|
+
afterAll(() => {
|
|
34
|
+
delete global.requestIdleCallback
|
|
35
|
+
delete global.cancelIdleCallback
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const baseProps = { filterData: [], filterViews: [] }
|
|
39
|
+
|
|
40
|
+
const mountFilter = (props = {}) =>
|
|
41
|
+
mount(FilterComponent, { props: { ...baseProps, ...props } })
|
|
42
|
+
|
|
43
|
+
const parentDropdown = (wrapper) =>
|
|
44
|
+
wrapper.findComponent({ name: 'ParentDropdown' })
|
|
45
|
+
const filterSettings = (wrapper) =>
|
|
46
|
+
wrapper.findComponent({ name: 'FilterSettings' })
|
|
47
|
+
|
|
48
|
+
const badge = (props) =>
|
|
49
|
+
parentDropdown(mountFilter(props)).props('numberOfFiltersApplied')
|
|
50
|
+
|
|
51
|
+
describe('FilterComponent', () => {
|
|
52
|
+
describe('numberOfFiltersApplied badge', () => {
|
|
53
|
+
it('counts live custom_view_filter rows', () => {
|
|
54
|
+
expect(
|
|
55
|
+
badge({
|
|
56
|
+
currentFilterData: {
|
|
57
|
+
filters: [
|
|
58
|
+
{ custom_view_filter: true },
|
|
59
|
+
{ custom_view_filter: false },
|
|
60
|
+
{ custom_view_filter: true },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
).toBe(2)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('counts distinct fields for flat filter rows', () => {
|
|
68
|
+
expect(
|
|
69
|
+
badge({
|
|
70
|
+
currentFilterData: {
|
|
71
|
+
filters: [{ field: 'a' }, { field: 'a' }, { field: 'b' }],
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
).toBe(2)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('falls back to the row count for plain value filters', () => {
|
|
78
|
+
expect(
|
|
79
|
+
badge({ currentFilterData: { filters: ['x', 'y', 'z'] } })
|
|
80
|
+
).toBe(3)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('falls back to the active view filters when no live data is present', () => {
|
|
84
|
+
expect(
|
|
85
|
+
badge({
|
|
86
|
+
currentFilterData: null,
|
|
87
|
+
activeFilterView: {
|
|
88
|
+
filters: [{ field: 'x' }, { field: 'y' }],
|
|
89
|
+
columns: ['a', 'b'],
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
).toBe(2)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('counts custom_view_filter columns of the active view when present', () => {
|
|
96
|
+
expect(
|
|
97
|
+
badge({
|
|
98
|
+
currentFilterData: null,
|
|
99
|
+
activeFilterView: {
|
|
100
|
+
filters: [{ field: 'x' }],
|
|
101
|
+
columns: [
|
|
102
|
+
{ custom_view_filter: true },
|
|
103
|
+
{ custom_view_filter: true },
|
|
104
|
+
{ custom_view_filter: false },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
).toBe(2)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('is zero with no filters applied', () => {
|
|
112
|
+
expect(badge({})).toBe(0)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('open / close behaviour', () => {
|
|
117
|
+
it('mounts the panel and emits on-filter-panel-open when opened', async () => {
|
|
118
|
+
const wrapper = mountFilter()
|
|
119
|
+
expect(filterSettings(wrapper).exists()).toBe(false)
|
|
120
|
+
|
|
121
|
+
await parentDropdown(wrapper).vm.$emit('on-toggle')
|
|
122
|
+
await flushPromises()
|
|
123
|
+
|
|
124
|
+
expect(filterSettings(wrapper).exists()).toBe(true)
|
|
125
|
+
expect(filterSettings(wrapper).isVisible()).toBe(true)
|
|
126
|
+
expect(wrapper.emitted('on-filter-panel-open')).toHaveLength(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('keeps the panel mounted but hidden when toggled closed', async () => {
|
|
130
|
+
const wrapper = mountFilter()
|
|
131
|
+
await parentDropdown(wrapper).vm.$emit('on-toggle')
|
|
132
|
+
await parentDropdown(wrapper).vm.$emit('on-toggle')
|
|
133
|
+
|
|
134
|
+
expect(filterSettings(wrapper).exists()).toBe(true)
|
|
135
|
+
expect(filterSettings(wrapper).isVisible()).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('closes when the closeDropdown prop becomes true', async () => {
|
|
139
|
+
const wrapper = mountFilter()
|
|
140
|
+
await parentDropdown(wrapper).vm.$emit('on-toggle')
|
|
141
|
+
expect(wrapper.vm.isDropdownOpen).toBe(true)
|
|
142
|
+
|
|
143
|
+
await wrapper.setProps({ closeDropdown: true })
|
|
144
|
+
expect(wrapper.vm.isDropdownOpen).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('panel event wiring', () => {
|
|
149
|
+
const openWrapper = async () => {
|
|
150
|
+
const wrapper = mountFilter()
|
|
151
|
+
await parentDropdown(wrapper).vm.$emit('on-toggle')
|
|
152
|
+
return wrapper
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('re-emits a view selection and closes the dropdown', async () => {
|
|
156
|
+
const wrapper = await openWrapper()
|
|
157
|
+
const view = { name: 'My view' }
|
|
158
|
+
|
|
159
|
+
await filterSettings(wrapper).vm.$emit('on-view-select', view)
|
|
160
|
+
|
|
161
|
+
expect(wrapper.emitted('on-filter-view-select')[0]).toEqual([view])
|
|
162
|
+
expect(wrapper.vm.isDropdownOpen).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('re-emits a reset and closes the dropdown', async () => {
|
|
166
|
+
const wrapper = await openWrapper()
|
|
167
|
+
|
|
168
|
+
await filterSettings(wrapper).vm.$emit('on-reset-filters')
|
|
169
|
+
|
|
170
|
+
expect(wrapper.emitted('on-reset-filters')).toHaveLength(1)
|
|
171
|
+
expect(wrapper.vm.isDropdownOpen).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('forwards filter changes as on-filter-settings-change', async () => {
|
|
175
|
+
const wrapper = await openWrapper()
|
|
176
|
+
const change = { field: 'status', value: true }
|
|
177
|
+
|
|
178
|
+
await filterSettings(wrapper).vm.$emit('on-filter-change', change)
|
|
179
|
+
|
|
180
|
+
expect(wrapper.emitted('on-filter-settings-change')[0]).toEqual([change])
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -382,8 +382,9 @@
|
|
|
382
382
|
position: absolute;
|
|
383
383
|
z-index: 99;
|
|
384
384
|
top: 8px;
|
|
385
|
-
left
|
|
386
|
-
|
|
385
|
+
/* Anchor to the trigger's left and grow rightward; clampBoxToViewport()
|
|
386
|
+
shifts it back in when wide content would overflow the viewport. */
|
|
387
|
+
left: 0;
|
|
387
388
|
width: max-content;
|
|
388
389
|
max-width: calc(100vw - 32px);
|
|
389
390
|
background-color: ${(props) => props.theme.colors.white};
|
|
@@ -501,6 +502,7 @@
|
|
|
501
502
|
data() {
|
|
502
503
|
return {
|
|
503
504
|
isOptionsVisible: false,
|
|
505
|
+
boxResizeObserver: null,
|
|
504
506
|
}
|
|
505
507
|
},
|
|
506
508
|
computed: {
|
|
@@ -514,10 +516,63 @@
|
|
|
514
516
|
return getDateFormat(this.activeLanguage)
|
|
515
517
|
},
|
|
516
518
|
},
|
|
519
|
+
watch: {
|
|
520
|
+
// Keep the filter box on screen: clamp on open and whenever its content
|
|
521
|
+
// resizes (e.g. a wide value is selected) or the window resizes.
|
|
522
|
+
isOptionsVisible(visible) {
|
|
523
|
+
if (visible) {
|
|
524
|
+
this.$nextTick(() => {
|
|
525
|
+
this.clampBoxToViewport()
|
|
526
|
+
this.observeBoxResize()
|
|
527
|
+
window.addEventListener('resize', this.clampBoxToViewport)
|
|
528
|
+
})
|
|
529
|
+
} else {
|
|
530
|
+
this.teardownBoxObservers()
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
},
|
|
517
534
|
beforeUnmount() {
|
|
518
535
|
document.removeEventListener('click', this.handleClickOutside)
|
|
536
|
+
this.teardownBoxObservers()
|
|
519
537
|
},
|
|
520
538
|
methods: {
|
|
539
|
+
// Shift the absolutely-positioned filter box left when its right edge
|
|
540
|
+
// would overflow the viewport, then clamp so the left edge stays visible.
|
|
541
|
+
clampBoxToViewport() {
|
|
542
|
+
const el = this.$refs.boxContainer?.$el
|
|
543
|
+
if (!el) {
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
el.style.transform = 'none'
|
|
547
|
+
const rect = el.getBoundingClientRect()
|
|
548
|
+
const margin = 16
|
|
549
|
+
const viewportWidth = window.innerWidth
|
|
550
|
+
let shift = 0
|
|
551
|
+
if (rect.right > viewportWidth - margin) {
|
|
552
|
+
shift = viewportWidth - margin - rect.right
|
|
553
|
+
}
|
|
554
|
+
if (rect.left + shift < margin) {
|
|
555
|
+
shift = margin - rect.left
|
|
556
|
+
}
|
|
557
|
+
el.style.transform = shift !== 0 ? `translateX(${shift}px)` : 'none'
|
|
558
|
+
},
|
|
559
|
+
observeBoxResize() {
|
|
560
|
+
const el = this.$refs.boxContainer?.$el
|
|
561
|
+
if (!el || typeof ResizeObserver === 'undefined') {
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
this.boxResizeObserver = new ResizeObserver(() =>
|
|
565
|
+
this.clampBoxToViewport()
|
|
566
|
+
)
|
|
567
|
+
this.boxResizeObserver.observe(el)
|
|
568
|
+
},
|
|
569
|
+
teardownBoxObservers() {
|
|
570
|
+
if (this.boxResizeObserver) {
|
|
571
|
+
this.boxResizeObserver.disconnect()
|
|
572
|
+
this.boxResizeObserver = null
|
|
573
|
+
}
|
|
574
|
+
window.removeEventListener('resize', this.clampBoxToViewport)
|
|
575
|
+
},
|
|
521
576
|
isDateColumn(column) {
|
|
522
577
|
if (column === 'updated') {
|
|
523
578
|
return true
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
import { flushPromises, mount } from '@vue/test-utils'
|
|
3
|
+
import Select from './index.vue'
|
|
4
|
+
import Option from './option/index.vue'
|
|
5
|
+
|
|
6
|
+
// import.meta in the real iconCache.mjs cannot be parsed by Jest.
|
|
7
|
+
jest.mock('@/components/icon/iconCache.mjs', () => ({
|
|
8
|
+
fetchIcon: jest.fn(() => Promise.resolve('mocked-icon-url.svg')),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
// jsdom does not implement ResizeObserver, which the select observes on mount.
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
global.ResizeObserver = class {
|
|
14
|
+
observe() {}
|
|
15
|
+
unobserve() {}
|
|
16
|
+
disconnect() {}
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Host component mirrors the documented usage (selector + dropdown slots) so the
|
|
21
|
+
// spec exercises the public contract rather than internals.
|
|
22
|
+
const Host = {
|
|
23
|
+
components: { Select, Option },
|
|
24
|
+
props: ['selectProps', 'options'],
|
|
25
|
+
template: `
|
|
26
|
+
<Select v-bind="selectProps">
|
|
27
|
+
<template #selector="{ selectedValue }">
|
|
28
|
+
<span data-test-id="selector">{{ selectedValue }}</span>
|
|
29
|
+
</template>
|
|
30
|
+
<template #dropdown>
|
|
31
|
+
<Option
|
|
32
|
+
v-for="opt in options"
|
|
33
|
+
:key="opt"
|
|
34
|
+
:value="opt"
|
|
35
|
+
:data-id="'opt_' + opt"
|
|
36
|
+
>{{ opt }}</Option>
|
|
37
|
+
</template>
|
|
38
|
+
</Select>
|
|
39
|
+
`,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const THREE_OPTIONS = ['one', 'two', 'three']
|
|
43
|
+
const SIX_OPTIONS = ['one', 'two', 'three', 'four', 'five', 'six']
|
|
44
|
+
|
|
45
|
+
const mountSelect = (selectProps = {}, options = THREE_OPTIONS) =>
|
|
46
|
+
mount(Host, {
|
|
47
|
+
props: {
|
|
48
|
+
// isFixedDropdownPosition avoids the requestAnimationFrame scroll loop,
|
|
49
|
+
// which would otherwise keep running after the test unmounts.
|
|
50
|
+
// shouldUseTeleport keeps the dropdown inside the wrapper so it can be queried.
|
|
51
|
+
selectProps: {
|
|
52
|
+
isFixedDropdownPosition: true,
|
|
53
|
+
shouldUseTeleport: false,
|
|
54
|
+
...selectProps,
|
|
55
|
+
},
|
|
56
|
+
options,
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const selectInstance = (wrapper) => wrapper.findComponent(Select)
|
|
61
|
+
|
|
62
|
+
const openDropdown = async (wrapper) => {
|
|
63
|
+
await wrapper.find('.select-button').trigger('click')
|
|
64
|
+
await flushPromises()
|
|
65
|
+
await wrapper.vm.$nextTick()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('RCselect', () => {
|
|
69
|
+
it('renders the label with the optional hint and required star', () => {
|
|
70
|
+
const wrapper = mountSelect({
|
|
71
|
+
label: 'Service territory',
|
|
72
|
+
labelOptional: true,
|
|
73
|
+
isRequiredLabel: true,
|
|
74
|
+
})
|
|
75
|
+
expect(wrapper.text()).toContain('Service territory')
|
|
76
|
+
expect(wrapper.text()).toContain('optional')
|
|
77
|
+
// IsRequiredLabelStar renders the asterisk character.
|
|
78
|
+
expect(wrapper.text()).toContain('*')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('initialises the selector slot from the value prop', () => {
|
|
82
|
+
const wrapper = mountSelect({ value: 'two' })
|
|
83
|
+
expect(wrapper.find('[data-test-id="selector"]').text()).toBe('two')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('updates the selector when the value prop changes', async () => {
|
|
87
|
+
const wrapper = mountSelect({ value: 'one' })
|
|
88
|
+
await wrapper.setProps({ selectProps: { value: 'three' } })
|
|
89
|
+
expect(wrapper.find('[data-test-id="selector"]').text()).toBe('three')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('opens the dropdown on trigger click and emits on-dropdown-open', async () => {
|
|
93
|
+
const wrapper = mountSelect()
|
|
94
|
+
expect(selectInstance(wrapper).emitted('on-dropdown-open')).toBeUndefined()
|
|
95
|
+
|
|
96
|
+
await openDropdown(wrapper)
|
|
97
|
+
|
|
98
|
+
expect(selectInstance(wrapper).emitted('on-dropdown-open')).toHaveLength(1)
|
|
99
|
+
expect(wrapper.find('.rc-select-dropdown').isVisible()).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('emits input-change with the option value and closes on selection', async () => {
|
|
103
|
+
const wrapper = mountSelect()
|
|
104
|
+
await openDropdown(wrapper)
|
|
105
|
+
|
|
106
|
+
await wrapper.find('[data-id="opt_two"]').trigger('click')
|
|
107
|
+
|
|
108
|
+
const select = selectInstance(wrapper)
|
|
109
|
+
expect(select.emitted('input-change')).toHaveLength(1)
|
|
110
|
+
expect(select.emitted('input-change')[0]).toEqual(['two'])
|
|
111
|
+
expect(select.emitted('on-dropdown-close')).toHaveLength(1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('shows the search field once the option count reaches the threshold', async () => {
|
|
115
|
+
const wrapper = mountSelect({}, SIX_OPTIONS)
|
|
116
|
+
await openDropdown(wrapper)
|
|
117
|
+
expect(wrapper.find('[data-test-id="input"]').exists()).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('hides the search field when there are fewer options than the threshold', async () => {
|
|
121
|
+
const wrapper = mountSelect({}, THREE_OPTIONS)
|
|
122
|
+
await openDropdown(wrapper)
|
|
123
|
+
expect(wrapper.find('[data-test-id="input"]').exists()).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('honours a custom minOptionLength for the search field', async () => {
|
|
127
|
+
const wrapper = mountSelect({ minOptionLength: 3 }, THREE_OPTIONS)
|
|
128
|
+
await openDropdown(wrapper)
|
|
129
|
+
expect(wrapper.find('[data-test-id="input"]').exists()).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('does not show the search field when isSearchable is false', async () => {
|
|
133
|
+
const wrapper = mountSelect({ isSearchable: false }, SIX_OPTIONS)
|
|
134
|
+
await openDropdown(wrapper)
|
|
135
|
+
expect(wrapper.find('[data-test-id="input"]').exists()).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('renders the error message when hasError is set', () => {
|
|
139
|
+
const wrapper = mountSelect({
|
|
140
|
+
hasError: true,
|
|
141
|
+
errorMessage: 'Selection required',
|
|
142
|
+
})
|
|
143
|
+
const error = wrapper.find('[data-test-id="error_message_wrapper"]')
|
|
144
|
+
expect(error.exists()).toBe(true)
|
|
145
|
+
expect(error.text()).toBe('Selection required')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('hides the caret when showCaret is false', () => {
|
|
149
|
+
expect(mountSelect().find('.caret_dropdown').exists()).toBe(true)
|
|
150
|
+
expect(
|
|
151
|
+
mountSelect({ showCaret: false }).find('.caret_dropdown').exists()
|
|
152
|
+
).toBe(false)
|
|
153
|
+
})
|
|
154
|
+
})
|