@eturnity/eturnity_reusable_components 9.28.0 → 9.28.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eturnity/eturnity_reusable_components",
3
- "version": "9.28.0",
3
+ "version": "9.28.1",
4
4
  "files": [
5
5
  "dist",
6
6
  "src"
@@ -123,3 +123,12 @@ DropdownBackgroundColor.args = {
123
123
  ...defaultDropdownProps,
124
124
  backgroundColor: theme.colors.red,
125
125
  }
126
+
127
+ // Long, unbroken option names should wrap inside the dropdown instead of
128
+ // overflowing horizontally (EPDM-11708).
129
+ export const DropdownLongContent = Template.bind({})
130
+ DropdownLongContent.args = {
131
+ ...defaultDropdownProps,
132
+ openingMode: 'click',
133
+ dropdown: `<div>ThisIsAnExtremelyLongVariantNameWithoutAnySpacesThatUsedToCauseHorizontalScroll</div><div>Another duplicated variant with a very long descriptive name that keeps going</div>`,
134
+ }
@@ -8,48 +8,121 @@ jest.mock('@/components/icon/iconCache.mjs', () => ({
8
8
  fetchIcon: jest.fn(() => Promise.resolve('mocked-icon-url.svg')),
9
9
  }))
10
10
 
11
+ const mountDropdown = (props = {}) =>
12
+ mount(RCDropdown, {
13
+ props: { ...defaultProps, ...props },
14
+ slots: { ...defaultProps },
15
+ global: { provide: { theme } },
16
+ })
17
+
11
18
  describe('dropdown/index.vue', () => {
12
- it('dropdown is rendered with correct trigger text', async () => {
13
- const wrapper = mount(RCDropdown, {
14
- props: { ...defaultProps },
15
- slots: { ...defaultProps },
16
- global: {
17
- provide: {
18
- theme,
19
- },
20
- },
21
- })
19
+ it('renders the trigger and dropdown slots', () => {
20
+ const wrapper = mountDropdown()
21
+
22
+ const trigger = wrapper.find('[data-test-id="dropdown_trigger"]')
23
+ const content = wrapper.find('[data-test-id="dropdown_dropdown_content"]')
24
+
25
+ expect(trigger.exists()).toBe(true)
26
+ expect(trigger.text()).toContain(defaultProps.trigger)
27
+ expect(content.html()).toContain('DROPDOWN OPTION 1')
28
+ expect(content.html()).toContain('DROPDOWN OPTION 2')
29
+ })
30
+
31
+ it('opens on click only when opening-mode is "click"', async () => {
32
+ const wrapper = mountDropdown({ openingMode: 'click' })
33
+
34
+ const trigger = wrapper.find('[data-test-id="dropdown_trigger"]')
35
+ const dropdownWrapper = wrapper.find(
36
+ '[data-test-id="dropdown_dropdown_wrapper"]'
37
+ )
38
+
39
+ expect(dropdownWrapper.classes('openDropdown')).toBe(false)
40
+
41
+ await trigger.trigger('hover')
42
+ expect(dropdownWrapper.classes('openDropdown')).toBe(false)
43
+
44
+ await trigger.trigger('click')
45
+ expect(dropdownWrapper.classes('openDropdown')).toBe(true)
46
+ })
47
+
48
+ it('does not apply the openDropdown class in hover mode', async () => {
49
+ const wrapper = mountDropdown({ openingMode: 'hover' })
50
+
51
+ const trigger = wrapper.find('[data-test-id="dropdown_trigger"]')
52
+ const dropdownWrapper = wrapper.find(
53
+ '[data-test-id="dropdown_dropdown_wrapper"]'
54
+ )
55
+
56
+ await trigger.trigger('click')
57
+
58
+ expect(dropdownWrapper.classes('openDropdown')).toBe(false)
59
+ })
60
+
61
+ it('toggles open and closed on repeated clicks', async () => {
62
+ const wrapper = mountDropdown({ openingMode: 'click' })
63
+
64
+ const trigger = wrapper.find('[data-test-id="dropdown_trigger"]')
65
+ const dropdownWrapper = wrapper.find(
66
+ '[data-test-id="dropdown_dropdown_wrapper"]'
67
+ )
68
+
69
+ await trigger.trigger('click')
70
+ expect(dropdownWrapper.classes('openDropdown')).toBe(true)
22
71
 
23
- const dropdownTrigger = wrapper.find('[data-test-id="dropdown_trigger"]')
72
+ await trigger.trigger('click')
73
+ expect(dropdownWrapper.classes('openDropdown')).toBe(false)
74
+ })
75
+
76
+ it('emits on-dropdown-toggle with the new open state', async () => {
77
+ const wrapper = mountDropdown({ openingMode: 'click' })
78
+
79
+ const trigger = wrapper.find('[data-test-id="dropdown_trigger"]')
24
80
 
25
- expect(dropdownTrigger.exists()).toBe(true)
26
- expect(dropdownTrigger.text()).toContain(defaultProps.trigger)
81
+ await trigger.trigger('click')
82
+ await trigger.trigger('click')
83
+
84
+ expect(wrapper.emitted('on-dropdown-toggle')).toHaveLength(2)
85
+ expect(wrapper.emitted('on-dropdown-toggle')[0]).toEqual([true])
86
+ expect(wrapper.emitted('on-dropdown-toggle')[1]).toEqual([false])
27
87
  })
28
88
 
29
- it('dropdown opens on click only', async () => {
89
+ it('closes when clicking outside in click mode', async () => {
90
+ // attachTo so the mounted click-outside listener sees a connected element
30
91
  const wrapper = mount(RCDropdown, {
31
92
  props: { ...defaultProps, openingMode: 'click' },
32
93
  slots: { ...defaultProps },
33
- global: {
34
- provide: {
35
- theme,
36
- },
37
- },
94
+ global: { provide: { theme } },
95
+ attachTo: document.body,
38
96
  })
39
97
 
40
- const dropdownTrigger = wrapper.find('[data-test-id="dropdown_trigger"]')
41
- const dropdownDropdownWrapper = wrapper.find(
98
+ const trigger = wrapper.find('[data-test-id="dropdown_trigger"]')
99
+ const dropdownWrapper = wrapper.find(
42
100
  '[data-test-id="dropdown_dropdown_wrapper"]'
43
101
  )
44
102
 
45
- expect(dropdownDropdownWrapper.classes('openDropdown')).toBe(false)
103
+ await trigger.trigger('click')
104
+ expect(dropdownWrapper.classes('openDropdown')).toBe(true)
46
105
 
47
- await dropdownTrigger.trigger('hover')
106
+ document.body.click()
107
+ await wrapper.vm.$nextTick()
48
108
 
49
- expect(dropdownDropdownWrapper.classes('openDropdown')).toBe(false)
109
+ expect(dropdownWrapper.classes('openDropdown')).toBe(false)
110
+ wrapper.unmount()
111
+ })
50
112
 
51
- await dropdownTrigger.trigger('click')
113
+ it('renders long unbroken content inside the dropdown window (EPDM-11708)', () => {
114
+ const longName =
115
+ 'ThisIsAnExtremelyLongVariantNameWithoutAnySpacesThatUsedToCauseHorizontalScroll'
116
+ const wrapper = mount(RCDropdown, {
117
+ props: { ...defaultProps },
118
+ slots: {
119
+ trigger: defaultProps.trigger,
120
+ dropdown: `<div>${longName}</div>`,
121
+ },
122
+ global: { provide: { theme } },
123
+ })
52
124
 
53
- expect(dropdownDropdownWrapper.classes('openDropdown')).toBe(true)
125
+ const content = wrapper.find('[data-test-id="dropdown_dropdown_content"]')
126
+ expect(content.text()).toContain(longName)
54
127
  })
55
128
  })
@@ -69,6 +69,8 @@
69
69
  min-width: 160px;
70
70
  box-shadow: -1px 0px 5px rgba(0, 0, 0, 0.302);
71
71
  z-index: 1;
72
+ overflow-wrap: break-word;
73
+ word-break: break-word;
72
74
  `
73
75
 
74
76
  const WrapperButton = styled('div', wrapperAttrs)`
@@ -1,150 +1,134 @@
1
1
  import InputNumber from './index.vue'
2
2
 
3
+ // import InputNumber from "@eturnity/eturnity_reusable_components/src/components/inputs/inputNumber"
4
+ // How to use:
5
+ // <input-number
6
+ // :value="inputValue" // required
7
+ // placeholder="Enter distance"
8
+ // unitName="pc"
9
+ // :numberPrecision="2"
10
+ // :minNumber="0"
11
+ // @input-change="onInputChange($event)"
12
+ // @on-enter-click="onInputSubmit()"
13
+ // />
14
+
3
15
  export default {
4
16
  title: 'Components/Inputs/InputNumber',
5
17
  component: InputNumber,
6
- // argTypes: {},
18
+ tags: ['autodocs'],
19
+ argTypes: {
20
+ value: { description: 'Current value (required). String or number.' },
21
+ placeholder: { description: 'Placeholder shown when empty' },
22
+ unitName: { description: 'Unit appended to the value, e.g. "pc"' },
23
+ showLinearUnitName: {
24
+ description: 'Render the unit next to the input instead of inside it',
25
+ control: 'boolean',
26
+ },
27
+ numberPrecision: {
28
+ description: 'Number of decimal places kept',
29
+ control: 'number',
30
+ },
31
+ minNumber: {
32
+ description: 'Values below this are clamped up to it on blur',
33
+ control: 'number',
34
+ },
35
+ showArrowControls: {
36
+ description: 'Show the up/down stepper buttons',
37
+ control: 'boolean',
38
+ },
39
+ isError: { description: 'Error state styling', control: 'boolean' },
40
+ errorMessage: { description: 'Message shown while isError is true' },
41
+ disabled: { description: 'Disable the input', control: 'boolean' },
42
+ labelText: { description: 'Text label above the input' },
43
+ labelInfoText: { description: 'Info tooltip shown next to the label' },
44
+ fontSize: { description: 'Font size of the input text' },
45
+ },
7
46
  }
8
47
 
9
- const Template = (args, { argTypes }) => ({
10
- // Components used in your story `template` are defined in the `components` object
48
+ const Template = (args) => ({
11
49
  components: { InputNumber },
12
- // The story's `args` need to be mapped into the template through the `setup()` method
13
- props: Object.keys(argTypes),
14
- template: '<input-number v-bind="$props" />',
15
-
16
- // import InputNumber from "@eturnity/eturnity_reusable_components/src/components/inputs/inputNumber"
17
- // How to use:
18
- // <input-number
19
- // placeholder="Enter distance"
20
- // :isError="false" //default is false
21
- // inputWidth="150px" //by default, this is 100%
22
- // :numberPrecision="3"
23
- // unitName="pc"
24
- // :value="inputValue" //required -- String
25
- // @input-change="onInputChange($event)" //required
26
- // @on-enter-click="onInputSubmit()"
27
- // :errorMessage="Enter a number between 1 and 10"
28
- // :disabled="false"
29
- // :noBorder="true"
30
- // textAlign="left" // "left, right, center"
31
- // :showLinearUnitName="true"
32
- // fontSize="13px"
33
- // labelText="Number of Modules"
34
- // labelInfoText="Here is some information for you..."
35
- // labelInfoAlign="left"
36
- // />
50
+ setup() {
51
+ return { args }
52
+ },
53
+ template: `
54
+ <div style="width: 260px; padding: 12px;">
55
+ <InputNumber v-bind="args" />
56
+ </div>
57
+ `,
37
58
  })
38
59
 
39
60
  export const Default = Template.bind({})
40
61
  Default.args = {
41
- placeholder: 'Enter Value',
42
- disabled: false,
43
62
  value: '',
63
+ placeholder: 'Enter value',
44
64
  inputWidth: '200px',
45
65
  minWidth: '150px',
46
66
  unitName: 'pc',
47
- isError: false,
48
67
  numberPrecision: 0,
49
- noBorder: false,
50
- textAlign: 'left',
51
- showLinearUnitName: false,
52
68
  }
53
69
 
54
- export const hasError = Template.bind({})
55
- hasError.args = {
56
- placeholder: 'Enter Value',
57
- errorMessage: 'This field is required',
58
- isError: true,
59
- disabled: false,
60
- inputWidth: '200px',
61
- }
62
-
63
- export const Disabled = Template.bind({})
64
- Disabled.args = {
65
- placeholder: 'Enter Value',
66
- disabled: true,
67
- value: '',
68
- inputWidth: '200px',
69
- isError: false,
70
- numberPrecision: 0,
71
- noBorder: false,
72
- textAlign: 'left',
73
- showLinearUnitName: false,
70
+ export const WithValueAndUnit = Template.bind({})
71
+ WithValueAndUnit.args = {
72
+ ...Default.args,
73
+ value: 10,
74
+ numberPrecision: 2,
74
75
  }
75
76
 
76
77
  export const LinearUnit = Template.bind({})
77
78
  LinearUnit.args = {
78
- placeholder: 'Enter Value',
79
- disabled: false,
79
+ ...Default.args,
80
80
  value: 10,
81
- inputWidth: '200px',
82
- unitName: 'pc',
83
- isError: false,
84
81
  numberPrecision: 2,
85
- noBorder: false,
86
- textAlign: 'left',
87
82
  showLinearUnitName: true,
88
83
  }
89
84
 
90
- export const NormalUnit = Template.bind({})
91
- NormalUnit.args = {
92
- placeholder: 'Enter Value',
93
- disabled: false,
85
+ export const WithArrowControls = Template.bind({})
86
+ WithArrowControls.args = {
87
+ ...Default.args,
88
+ value: 5,
89
+ showArrowControls: true,
90
+ }
91
+
92
+ export const WithMinNumber = Template.bind({})
93
+ WithMinNumber.args = {
94
+ ...Default.args,
95
+ value: 3,
96
+ minNumber: 5,
97
+ showArrowControls: true,
98
+ }
99
+
100
+ export const HasError = Template.bind({})
101
+ HasError.args = {
102
+ ...Default.args,
103
+ value: '',
104
+ isError: true,
105
+ errorMessage: 'This field is required',
106
+ }
107
+
108
+ export const Disabled = Template.bind({})
109
+ Disabled.args = {
110
+ ...Default.args,
94
111
  value: 10,
95
- inputWidth: '200px',
96
- unitName: 'pc',
97
- isError: false,
98
- numberPrecision: 2,
99
- noBorder: false,
100
- textAlign: 'left',
101
- showLinearUnitName: false,
112
+ disabled: true,
102
113
  }
103
114
 
104
115
  export const WithLabel = Template.bind({})
105
116
  WithLabel.args = {
106
- placeholder: 'Enter Value',
107
- disabled: false,
117
+ ...Default.args,
108
118
  value: 10,
109
- inputWidth: '200px',
110
- unitName: 'pc',
111
- isError: false,
112
119
  numberPrecision: 2,
113
- noBorder: false,
114
- textAlign: 'left',
115
- showLinearUnitName: false,
116
- labelText: 'Number Input',
120
+ labelText: 'Number of modules',
117
121
  }
118
122
 
119
123
  export const WithLabelInfo = Template.bind({})
120
124
  WithLabelInfo.args = {
121
- placeholder: 'Enter Value',
122
- disabled: false,
123
- value: 10,
124
- inputWidth: '200px',
125
- unitName: 'pc',
126
- isError: false,
127
- numberPrecision: 2,
128
- noBorder: false,
129
- textAlign: 'left',
130
- showLinearUnitName: false,
131
- labelText: 'Number Input',
125
+ ...WithLabel.args,
132
126
  labelInfoText: 'Here is some information for you...',
133
127
  labelInfoAlign: 'right',
134
128
  }
135
129
 
136
130
  export const LargerFont = Template.bind({})
137
131
  LargerFont.args = {
138
- placeholder: 'Enter Value',
139
- disabled: false,
140
- value: 10,
141
- inputWidth: '200px',
142
- unitName: 'pc',
143
- isError: false,
144
- numberPrecision: 2,
145
- noBorder: false,
146
- textAlign: 'left',
147
- showLinearUnitName: false,
148
- labelText: 'Number Input',
132
+ ...WithLabel.args,
149
133
  fontSize: '16px',
150
134
  }
@@ -0,0 +1,133 @@
1
+ import { mount } from '@vue/test-utils'
2
+
3
+ // error_icon.png / date-fns (via translateLang) can't be parsed by jest, so
4
+ // stub them out. iconCache uses import.meta and is mocked like other specs.
5
+ jest.mock('../../../assets/icons/error_icon.png', () => 'error_icon.png')
6
+ jest.mock('@/helpers/translateLang', () => ({
7
+ langForLocaleString: () => 'en-US',
8
+ translateLang: (lang) => lang,
9
+ }))
10
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
11
+ fetchIcon: jest.fn(() => Promise.resolve('mocked-icon-url.svg')),
12
+ }))
13
+
14
+ import InputNumber from '@/components/inputs/inputNumber'
15
+
16
+ const mountInput = (props = {}) =>
17
+ mount(InputNumber, { props: { value: null, numberPrecision: 0, ...props } })
18
+
19
+ describe('inputNumber/index.vue', () => {
20
+ describe('rendering', () => {
21
+ it('renders the value with its unit appended', () => {
22
+ const wrapper = mountInput({ value: 5, unitName: 'pc' })
23
+ expect(wrapper.find('input').element.value).toBe('5 pc')
24
+ })
25
+
26
+ it('keeps the unit out of the input when shown as a linear unit', () => {
27
+ const wrapper = mountInput({
28
+ value: 5,
29
+ unitName: 'pc',
30
+ showLinearUnitName: true,
31
+ })
32
+ expect(wrapper.find('input').element.value).toBe('5')
33
+ expect(wrapper.text()).toContain('pc')
34
+ })
35
+
36
+ it('renders the label text', () => {
37
+ const wrapper = mountInput({ value: 5, labelText: 'Modules' })
38
+ expect(wrapper.text()).toContain('Modules')
39
+ })
40
+
41
+ it('renders the error message when isError is set', () => {
42
+ const wrapper = mountInput({
43
+ value: 5,
44
+ isError: true,
45
+ errorMessage: 'Invalid number',
46
+ })
47
+ expect(wrapper.text()).toContain('Invalid number')
48
+ })
49
+
50
+ it('disables the input and hides the arrow controls when disabled', () => {
51
+ const wrapper = mountInput({
52
+ value: 5,
53
+ disabled: true,
54
+ showArrowControls: true,
55
+ })
56
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
57
+ expect(wrapper.findAll('button')).toHaveLength(0)
58
+ })
59
+ })
60
+
61
+ describe('emits', () => {
62
+ it('emits input-focus on focus', async () => {
63
+ const wrapper = mountInput({ value: 5 })
64
+ await wrapper.find('input').trigger('focus')
65
+ expect(wrapper.emitted('input-focus')).toHaveLength(1)
66
+ })
67
+
68
+ it('emits input-change with the parsed number while typing', async () => {
69
+ const wrapper = mountInput({ value: 5 })
70
+ await wrapper.find('input').trigger('focus')
71
+ await wrapper.find('input').setValue('7')
72
+ expect(wrapper.emitted('input-change')).toBeTruthy()
73
+ expect(wrapper.emitted('input-change').at(-1)).toEqual([7])
74
+ })
75
+
76
+ it('emits on-enter-click when Enter is pressed', async () => {
77
+ const wrapper = mountInput({ value: 5 })
78
+ await wrapper.find('input').trigger('keyup.enter')
79
+ expect(wrapper.emitted('on-enter-click')).toHaveLength(1)
80
+ })
81
+ })
82
+
83
+ // Boundary Value Analysis: onInputBlur clamps values below `minNumber`.
84
+ describe('boundary value analysis: minNumber clamping on blur', () => {
85
+ const blurWith = async (props) => {
86
+ const wrapper = mountInput(props)
87
+ await wrapper.find('input').trigger('blur')
88
+ return wrapper.emitted('input-blur')
89
+ }
90
+
91
+ // minNumber = 10 -> just-below / at / just-above the boundary
92
+ it.each([
93
+ ['below the minimum (min - 1)', 9, 10],
94
+ ['at the minimum', 10, 10],
95
+ ['above the minimum (min + 1)', 11, 11],
96
+ ])('clamps a value %s', async (_label, value, expected) => {
97
+ const emitted = await blurWith({ value, minNumber: 10 })
98
+ expect(emitted).toEqual([[expected]])
99
+ })
100
+
101
+ it('does not clamp when minNumber is 0 (0 is treated as no minimum)', async () => {
102
+ // `if (this.minNumber && ...)` makes 0 falsy, so a negative value passes
103
+ const emitted = await blurWith({ value: -5, minNumber: 0 })
104
+ expect(emitted).toEqual([[-5]])
105
+ })
106
+ })
107
+
108
+ // Boundary Value Analysis: decrement is guarded at the negative boundary.
109
+ describe('boundary value analysis: decrement negative guard', () => {
110
+ const decrement = async (props) => {
111
+ const wrapper = mountInput({ showArrowControls: true, ...props })
112
+ // buttons: [0] increment (up), [1] decrement (down)
113
+ await wrapper.findAll('button')[1].trigger('click')
114
+ return wrapper.emitted('input-change')
115
+ }
116
+
117
+ it('emits a negative value when allowNegative is true', async () => {
118
+ const emitted = await decrement({ value: 5, allowNegative: true })
119
+ expect(emitted).toEqual([[-1]])
120
+ })
121
+
122
+ it('blocks crossing below zero when allowNegative is false', async () => {
123
+ const emitted = await decrement({ value: 5, allowNegative: false })
124
+ expect(emitted).toBeUndefined()
125
+ })
126
+
127
+ it('increments from the zero baseline by the interaction step', async () => {
128
+ const wrapper = mountInput({ value: 5, showArrowControls: true })
129
+ await wrapper.findAll('button')[0].trigger('click')
130
+ expect(wrapper.emitted('input-change')).toEqual([[1]])
131
+ })
132
+ })
133
+ })
@@ -0,0 +1,103 @@
1
+ /* eslint-disable */
2
+ import { mount } from '@vue/test-utils'
3
+
4
+ // numberConverter pulls in translateLang (date-fns), which jest can't parse.
5
+ // Stub it so locale formatting is deterministic (en-US) under test.
6
+ jest.mock('@/helpers/translateLang', () => ({
7
+ langForLocaleString: () => 'en-US',
8
+ translateLang: (lang) => lang,
9
+ }))
10
+
11
+ import InputNumberQuestion from '@/components/inputs/inputNumberQuestion'
12
+
13
+ const questionWithUnit = {
14
+ number_format_precision: 2,
15
+ number_min_allowed: 0,
16
+ number_max_allowed: 500,
17
+ unit_short_name: 'kWh',
18
+ }
19
+
20
+ const questionPlain = {
21
+ number_format_precision: 0,
22
+ number_min_allowed: 1,
23
+ number_max_allowed: 99,
24
+ unit_short_name: '',
25
+ }
26
+
27
+ const mountInput = (props = {}) =>
28
+ mount(InputNumberQuestion, {
29
+ props: { question: questionPlain, value: null, ...props },
30
+ })
31
+
32
+ describe('inputNumberQuestion/index.vue', () => {
33
+ describe('rendering', () => {
34
+ it('formats the initial value through the question precision', () => {
35
+ const wrapper = mountInput({ question: questionWithUnit, value: '120' })
36
+ expect(wrapper.find('input').element.value).toBe('120')
37
+ })
38
+
39
+ it('renders the unit when the question carries one', () => {
40
+ const wrapper = mountInput({ question: questionWithUnit, value: '120' })
41
+ expect(wrapper.text()).toContain('kWh')
42
+ })
43
+
44
+ it('renders no unit container when the question has no unit', () => {
45
+ const wrapper = mountInput({ question: questionPlain, value: '5' })
46
+ expect(wrapper.find('span span').exists()).toBe(false)
47
+ })
48
+
49
+ it('renders the placeholder on the input', () => {
50
+ const wrapper = mountInput({ placeholder: 'Floor number' })
51
+ expect(wrapper.find('input').attributes('placeholder')).toBe(
52
+ 'Floor number'
53
+ )
54
+ })
55
+
56
+ it('renders the error message only when isError is set', () => {
57
+ const withError = mountInput({
58
+ isError: true,
59
+ errorMessage: 'Enter a number between 1 and 99',
60
+ })
61
+ expect(withError.text()).toContain('Enter a number between 1 and 99')
62
+
63
+ const withoutError = mountInput({
64
+ isError: false,
65
+ errorMessage: 'Enter a number between 1 and 99',
66
+ })
67
+ expect(withoutError.text()).not.toContain(
68
+ 'Enter a number between 1 and 99'
69
+ )
70
+ })
71
+ })
72
+
73
+ describe('emits', () => {
74
+ // Characterization test — documents current behaviour, do not "fix" here.
75
+ // The template binds `@input="onChangeHandler"`, so onChangeHandler receives
76
+ // the native InputEvent, but it treats its argument as the raw value string
77
+ // (no `$event.target.value`, unlike the sibling `inputNumber.onInput`).
78
+ // Parsing the event object yields NaN, so typing always emits '' rather than
79
+ // the parsed number. See EPDM-11715 notes.
80
+ it('emits input-change on typing (currently always empty, see note)', async () => {
81
+ const wrapper = mountInput({ question: questionPlain, value: null })
82
+ await wrapper.find('input').setValue('42')
83
+ expect(wrapper.emitted('input-change')).toBeTruthy()
84
+ expect(wrapper.emitted('input-change').at(-1)).toEqual([''])
85
+ })
86
+
87
+ it('emits on-enter-click when Enter is pressed', async () => {
88
+ const wrapper = mountInput({ value: '5' })
89
+ await wrapper.find('input').trigger('keyup.enter')
90
+ expect(wrapper.emitted('on-enter-click')).toHaveLength(1)
91
+ })
92
+ })
93
+
94
+ describe('clearInput watcher', () => {
95
+ it('clears the displayed text when clearInput flips to true', async () => {
96
+ const wrapper = mountInput({ question: questionWithUnit, value: '120' })
97
+ expect(wrapper.find('input').element.value).toBe('120')
98
+
99
+ await wrapper.setProps({ clearInput: true })
100
+ expect(wrapper.find('input').element.value).toBe('')
101
+ })
102
+ })
103
+ })
@@ -1,6 +1,18 @@
1
1
  import { ref } from 'vue'
2
2
  import InputNumberQuestion from './index.vue'
3
- import theme from '@/assets/theme'
3
+
4
+ // import InputNumberQuestion from "@eturnity/eturnity_reusable_components/src/components/inputs/inputNumberQuestion"
5
+ // How to use:
6
+ // <input-number-question
7
+ // :question="question" // required — { number_format_precision, number_min_allowed, number_max_allowed, unit_short_name }
8
+ // :value="inputValue" // required
9
+ // placeholder="Enter distance"
10
+ // inputWidth="150px" // default 100%
11
+ // :isError="false"
12
+ // errorMessage="Enter a number between 1 and 10"
13
+ // @input-change="onInputChange($event)"
14
+ // @on-enter-click="onInputSubmit()"
15
+ // />
4
16
 
5
17
  const questionWithUnit = {
6
18
  number_format_precision: 2,
@@ -23,6 +35,22 @@ export default {
23
35
  parameters: {
24
36
  layout: 'centered',
25
37
  },
38
+ argTypes: {
39
+ question: {
40
+ description:
41
+ 'Required. Drives formatting: number_format_precision, ' +
42
+ 'number_min_allowed, number_max_allowed, unit_short_name.',
43
+ },
44
+ value: { description: 'Current value (required). String or number.' },
45
+ placeholder: { description: 'Placeholder shown when empty' },
46
+ inputWidth: { description: 'Width of the input, defaults to 100%' },
47
+ isError: { description: 'Error state styling', control: 'boolean' },
48
+ errorMessage: { description: 'Message shown while isError is true' },
49
+ clearInput: {
50
+ description: 'When flipped to true, clears the displayed text',
51
+ control: 'boolean',
52
+ },
53
+ },
26
54
  }
27
55
 
28
56
  const Template = (args) => ({
@@ -32,21 +60,14 @@ const Template = (args) => ({
32
60
  return { args, val }
33
61
  },
34
62
  template: `
35
- <div data-test-id="input-number-question-story" style="min-width: 280px; padding: 16px;">
63
+ <div style="min-width: 280px; padding: 16px;">
36
64
  <InputNumberQuestion
37
- :question="args.question"
65
+ v-bind="args"
38
66
  :value="val"
39
- :placeholder="args.placeholder"
40
- :is-error="args.isError"
41
- :input-width="args.inputWidth"
42
- :error-message="args.errorMessage"
43
67
  @input-change="val = $event"
44
68
  />
45
69
  </div>
46
70
  `,
47
- provide: {
48
- theme,
49
- },
50
71
  })
51
72
 
52
73
  export const WithUnit = Template.bind({})
@@ -0,0 +1,109 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import SideMenu from '@/components/sideMenu'
3
+ import Spinner from '@/components/spinner'
4
+
5
+ jest.mock('@/components/icon/iconCache.mjs', () => ({
6
+ // need to mock this due to how jest handles import.meta
7
+ fetchIcon: jest.fn(() => Promise.resolve('mocked-icon-url.svg')),
8
+ }))
9
+
10
+ const tabsData = [
11
+ { key: 'dashboard', label: 'Dashboard', icon: 'house' },
12
+ { key: 'projects', label: 'Projects', icon: 'house' },
13
+ {
14
+ key: 'settings',
15
+ label: 'Settings',
16
+ icon: 'gear',
17
+ children: [
18
+ { key: 'general', label: 'General' },
19
+ { key: 'billing', label: 'Billing' },
20
+ ],
21
+ },
22
+ ]
23
+
24
+ const mountMenu = (props = {}) =>
25
+ mount(SideMenu, {
26
+ props: { tabsData, activeTab: 'dashboard', ...props },
27
+ })
28
+
29
+ const item = (wrapper, key) =>
30
+ wrapper.find(`[data-id="sub_menu_settings_${key}"]`)
31
+
32
+ describe('sideMenu/index.vue', () => {
33
+ it('shows a spinner and no menu items while tabsData is empty', () => {
34
+ const wrapper = mount(SideMenu, {
35
+ props: { tabsData: [], activeTab: 'dashboard' },
36
+ })
37
+
38
+ expect(wrapper.findComponent(Spinner).exists()).toBe(true)
39
+ expect(item(wrapper, 'dashboard').exists()).toBe(false)
40
+ })
41
+
42
+ it('renders a menu item with its label for each flat tab', () => {
43
+ const wrapper = mountMenu()
44
+
45
+ expect(item(wrapper, 'dashboard').exists()).toBe(true)
46
+ expect(item(wrapper, 'dashboard').text()).toContain('Dashboard')
47
+ expect(item(wrapper, 'projects').text()).toContain('Projects')
48
+ })
49
+
50
+ it('marks the active tab with data-active="true"', () => {
51
+ const wrapper = mountMenu({ activeTab: 'projects' })
52
+
53
+ expect(item(wrapper, 'projects').attributes('data-active')).toBe('true')
54
+ expect(item(wrapper, 'dashboard').attributes('data-active')).toBe('false')
55
+ })
56
+
57
+ it('emits tab-click with the activeKey when a flat item is clicked', async () => {
58
+ const wrapper = mountMenu()
59
+
60
+ await item(wrapper, 'projects').trigger('click')
61
+
62
+ expect(wrapper.emitted('tab-click')).toHaveLength(1)
63
+ expect(wrapper.emitted('tab-click')[0]).toEqual([{ activeKey: 'projects' }])
64
+ })
65
+
66
+ it('hides nested children until the parent is expanded', async () => {
67
+ const wrapper = mountMenu()
68
+
69
+ expect(item(wrapper, 'general').exists()).toBe(false)
70
+
71
+ await item(wrapper, 'settings').trigger('click')
72
+ expect(item(wrapper, 'general').exists()).toBe(true)
73
+ expect(item(wrapper, 'billing').exists()).toBe(true)
74
+
75
+ // clicking the parent again collapses it
76
+ await item(wrapper, 'settings').trigger('click')
77
+ expect(item(wrapper, 'general').exists()).toBe(false)
78
+ })
79
+
80
+ it('emits tab-click with activeKey and parentKey when a child is clicked', async () => {
81
+ const wrapper = mountMenu()
82
+
83
+ await item(wrapper, 'settings').trigger('click')
84
+ await item(wrapper, 'general').trigger('click')
85
+
86
+ expect(wrapper.emitted('tab-click')[0]).toEqual([
87
+ { activeKey: 'general', parentKey: 'settings' },
88
+ ])
89
+ })
90
+
91
+ it('renders the logout button and app version, and emits on-logout on click', async () => {
92
+ const wrapper = mountMenu({ hasLogout: true, appVersion: 'v1.2.3' })
93
+
94
+ const logout = wrapper.find('[data-id="button_settings_logout"]')
95
+ expect(logout.exists()).toBe(true)
96
+ expect(wrapper.text()).toContain('v1.2.3')
97
+
98
+ await logout.trigger('click')
99
+ expect(wrapper.emitted('on-logout')).toHaveLength(1)
100
+ })
101
+
102
+ it('hides the logout section when hasLogout is false', () => {
103
+ const wrapper = mountMenu({ hasLogout: false })
104
+
105
+ expect(wrapper.find('[data-id="button_settings_logout"]').exists()).toBe(
106
+ false
107
+ )
108
+ })
109
+ })
@@ -7,6 +7,19 @@ const flatMenu = [
7
7
  { key: 'settings', label: 'Settings', icon: 'House' },
8
8
  ]
9
9
 
10
+ const nestedMenu = [
11
+ { key: 'dashboard', label: 'Dashboard', icon: 'House' },
12
+ {
13
+ key: 'settings',
14
+ label: 'Settings',
15
+ icon: 'House',
16
+ children: [
17
+ { key: 'general', label: 'General' },
18
+ { key: 'billing', label: 'Billing' },
19
+ ],
20
+ },
21
+ ]
22
+
10
23
  export default {
11
24
  title: 'Components/Navigation/SideMenu',
12
25
  component: SideMenu,
@@ -51,3 +64,33 @@ SecondActive.args = {
51
64
  ...FlatItems.args,
52
65
  activeTab: 'projects',
53
66
  }
67
+
68
+ // A collapsible parent item — click "Settings" to expand its children.
69
+ export const NestedItems = Template.bind({})
70
+ NestedItems.args = {
71
+ tabsData: nestedMenu,
72
+ activeTab: 'general',
73
+ activeParentTab: 'settings',
74
+ hasLogout: false,
75
+ appVersion: 'Storybook',
76
+ }
77
+
78
+ // Bottom section with the logout button and the app version.
79
+ export const WithLogout = Template.bind({})
80
+ WithLogout.args = {
81
+ tabsData: flatMenu,
82
+ activeTab: 'dashboard',
83
+ activeParentTab: null,
84
+ hasLogout: true,
85
+ appVersion: 'v1.2.3',
86
+ }
87
+
88
+ // While tabsData is empty the menu shows a loading spinner.
89
+ export const Loading = Template.bind({})
90
+ Loading.args = {
91
+ tabsData: [],
92
+ activeTab: 'dashboard',
93
+ activeParentTab: null,
94
+ hasLogout: false,
95
+ appVersion: 'Storybook',
96
+ }