@eturnity/eturnity_reusable_components 9.25.17 → 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 +1 -1
- package/src/components/collapsableInfoText/collapsableInfoText.spec.js +57 -0
- package/src/components/collapsableInfoText/collapsableInfoText.stories.js +8 -0
- package/src/components/dropdown/Dropdown.stories.js +9 -0
- package/src/components/dropdown/dropdown.spec.js +99 -26
- package/src/components/dropdown/index.vue +2 -0
- package/src/components/filter/Filter.stories.js +177 -0
- package/src/components/filter/filter.spec.js +183 -0
- package/src/components/inputs/inputNumber/InputNumber.stories.js +87 -103
- package/src/components/inputs/inputNumber/inputNumber.spec.js +133 -0
- package/src/components/inputs/inputNumberQuestion/inputNumberQuestion.spec.js +103 -0
- package/src/components/inputs/inputNumberQuestion/inputNumberQuestion.stories.js +31 -10
- package/src/components/inputs/select/select.spec.js +154 -0
- package/src/components/inputs/select/select.stories.js +230 -56
- 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/src/components/sideMenu/sideMenu.spec.js +109 -0
- package/src/components/sideMenu/sideMenu.stories.js +43 -0
|
@@ -1,127 +1,131 @@
|
|
|
1
|
+
import { ref, watch } from 'vue'
|
|
1
2
|
import TextAreaInput from './index.vue'
|
|
2
3
|
|
|
4
|
+
// To use:
|
|
5
|
+
// import TextAreaInput from '@eturnity/eturnity_reusable_components/src/components/inputs/textAreaInput'
|
|
6
|
+
// <text-area-input
|
|
7
|
+
// placeholder="Comments"
|
|
8
|
+
// :value="form.note"
|
|
9
|
+
// label="Question 5"
|
|
10
|
+
// row-height="4"
|
|
11
|
+
// :is-error="false"
|
|
12
|
+
// error-text="This field is required"
|
|
13
|
+
// @input-change="onInputChange($event)"
|
|
14
|
+
// />
|
|
15
|
+
|
|
3
16
|
export default {
|
|
4
17
|
title: 'Components/Inputs/TextAreaInput',
|
|
5
18
|
component: TextAreaInput,
|
|
6
|
-
|
|
19
|
+
tags: ['autodocs'],
|
|
20
|
+
argTypes: {
|
|
21
|
+
value: { control: 'text' },
|
|
22
|
+
placeholder: { control: 'text' },
|
|
23
|
+
label: { control: 'text' },
|
|
24
|
+
rowHeight: { control: 'text' },
|
|
25
|
+
fontSize: { control: 'text' },
|
|
26
|
+
inputWidth: { control: 'text' },
|
|
27
|
+
resize: {
|
|
28
|
+
control: 'inline-radio',
|
|
29
|
+
options: ['none', 'both', 'horizontal', 'vertical'],
|
|
30
|
+
},
|
|
31
|
+
alignItems: {
|
|
32
|
+
control: 'inline-radio',
|
|
33
|
+
options: ['horizontal', 'vertical'],
|
|
34
|
+
},
|
|
35
|
+
appTheme: { control: 'inline-radio', options: ['light', 'dark'] },
|
|
36
|
+
isDisabled: { control: 'boolean' },
|
|
37
|
+
isError: { control: 'boolean' },
|
|
38
|
+
errorText: { control: 'text' },
|
|
39
|
+
infoTextMessage: { control: 'text' },
|
|
40
|
+
isRequiredLabel: { control: 'boolean' },
|
|
41
|
+
},
|
|
7
42
|
}
|
|
8
43
|
|
|
9
|
-
const Template = (args
|
|
10
|
-
// Components used in your story `template` are defined in the `components` object
|
|
44
|
+
const Template = (args) => ({
|
|
11
45
|
components: { TextAreaInput },
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// alignItems="horizontal" // horizontal, vertical
|
|
28
|
-
// :isDisabled="true"
|
|
29
|
-
// />
|
|
46
|
+
setup() {
|
|
47
|
+
const model = ref(args.value)
|
|
48
|
+
watch(
|
|
49
|
+
() => args.value,
|
|
50
|
+
(v) => {
|
|
51
|
+
model.value = v
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
return { args, model }
|
|
55
|
+
},
|
|
56
|
+
template: `
|
|
57
|
+
<div style="padding: 16px;">
|
|
58
|
+
<TextAreaInput v-bind="args" :value="model" @input-change="model = $event" />
|
|
59
|
+
</div>
|
|
60
|
+
`,
|
|
30
61
|
})
|
|
31
62
|
|
|
32
|
-
|
|
33
|
-
Default.args = {
|
|
63
|
+
const baseArgs = {
|
|
34
64
|
placeholder: 'Enter a comment',
|
|
35
|
-
isDisabled: false,
|
|
36
65
|
rowHeight: '2',
|
|
37
|
-
isError: false,
|
|
38
|
-
errorText: 'This field is required',
|
|
39
|
-
infoTextMessage: '',
|
|
40
|
-
label: '',
|
|
41
66
|
value: '',
|
|
42
67
|
alignItems: 'vertical',
|
|
43
68
|
inputWidth: '350px',
|
|
44
69
|
}
|
|
45
70
|
|
|
71
|
+
export const Default = Template.bind({})
|
|
72
|
+
Default.args = {
|
|
73
|
+
...baseArgs,
|
|
74
|
+
}
|
|
75
|
+
|
|
46
76
|
export const Disabled = Template.bind({})
|
|
47
77
|
Disabled.args = {
|
|
48
|
-
|
|
78
|
+
...baseArgs,
|
|
49
79
|
isDisabled: true,
|
|
50
|
-
|
|
51
|
-
isError: false,
|
|
52
|
-
errorText: 'This field is required',
|
|
53
|
-
infoTextMessage: '',
|
|
54
|
-
label: '',
|
|
55
|
-
value: '',
|
|
56
|
-
alignItems: 'vertical',
|
|
57
|
-
inputWidth: '350px',
|
|
80
|
+
value: 'Cannot edit this',
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
export const Error = Template.bind({})
|
|
61
84
|
Error.args = {
|
|
62
|
-
|
|
63
|
-
isDisabled: false,
|
|
64
|
-
rowHeight: '2',
|
|
85
|
+
...baseArgs,
|
|
65
86
|
isError: true,
|
|
66
87
|
errorText: 'This field is required',
|
|
67
|
-
infoTextMessage: '',
|
|
68
|
-
label: '',
|
|
69
|
-
value: '',
|
|
70
|
-
alignItems: 'vertical',
|
|
71
|
-
inputWidth: '350px',
|
|
72
88
|
}
|
|
73
89
|
|
|
74
90
|
export const WithLabel = Template.bind({})
|
|
75
91
|
WithLabel.args = {
|
|
76
|
-
|
|
77
|
-
isDisabled: false,
|
|
78
|
-
rowHeight: '2',
|
|
79
|
-
isError: false,
|
|
80
|
-
errorText: 'This field is required',
|
|
81
|
-
infoTextMessage: 'Here is some information',
|
|
92
|
+
...baseArgs,
|
|
82
93
|
label: 'Description',
|
|
94
|
+
infoTextMessage: 'Here is some information',
|
|
83
95
|
value: 'Here is my description!',
|
|
84
|
-
alignItems: 'vertical',
|
|
85
|
-
inputWidth: '350px',
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
export const HorizontalLabel = Template.bind({})
|
|
89
99
|
HorizontalLabel.args = {
|
|
90
|
-
|
|
91
|
-
isDisabled: false,
|
|
92
|
-
rowHeight: '2',
|
|
93
|
-
isError: false,
|
|
94
|
-
errorText: 'This field is required',
|
|
95
|
-
infoTextMessage: 'Here is some information',
|
|
100
|
+
...baseArgs,
|
|
96
101
|
label: 'Description',
|
|
102
|
+
infoTextMessage: 'Here is some information',
|
|
97
103
|
value: 'Here is my description!',
|
|
98
104
|
alignItems: 'horizontal',
|
|
99
|
-
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const RequiredLabel = Template.bind({})
|
|
108
|
+
RequiredLabel.args = {
|
|
109
|
+
...baseArgs,
|
|
110
|
+
label: 'Description',
|
|
111
|
+
isRequiredLabel: true,
|
|
100
112
|
}
|
|
101
113
|
|
|
102
114
|
export const LargerTextArea = Template.bind({})
|
|
103
115
|
LargerTextArea.args = {
|
|
104
|
-
|
|
105
|
-
isDisabled: false,
|
|
106
|
-
rowHeight: '5',
|
|
107
|
-
isError: false,
|
|
108
|
-
errorText: 'This field is required',
|
|
109
|
-
infoTextMessage: 'Here is some information',
|
|
116
|
+
...baseArgs,
|
|
110
117
|
label: 'Description',
|
|
118
|
+
rowHeight: '5',
|
|
111
119
|
value: 'Here is my description!',
|
|
112
|
-
|
|
120
|
+
inputWidth: null,
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
export const LargerFontSize = Template.bind({})
|
|
116
124
|
LargerFontSize.args = {
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
...baseArgs,
|
|
126
|
+
label: 'Description',
|
|
119
127
|
rowHeight: '5',
|
|
120
128
|
fontSize: '24px',
|
|
121
|
-
isError: false,
|
|
122
|
-
errorText: 'This field is required',
|
|
123
|
-
infoTextMessage: 'Here is some information',
|
|
124
|
-
label: 'Description',
|
|
125
129
|
value: 'Here is my description!',
|
|
126
|
-
|
|
130
|
+
inputWidth: null,
|
|
127
131
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import TextAreaInput from '@/components/inputs/textAreaInput'
|
|
4
|
+
import InfoText from '@/components/infoText'
|
|
5
|
+
|
|
6
|
+
// InfoText renders an icon; the real iconCache.mjs uses import.meta.
|
|
7
|
+
jest.mock('@/components/icon/iconCache.mjs', () => ({
|
|
8
|
+
fetchIcon: jest.fn(() => Promise.resolve('mocked-icon-url.svg')),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
describe('TextAreaInput', () => {
|
|
12
|
+
const mountTextArea = (props = {}) =>
|
|
13
|
+
mount(TextAreaInput, { props: { value: '', ...props } })
|
|
14
|
+
|
|
15
|
+
it('renders a textarea with the value, placeholder and row height', () => {
|
|
16
|
+
const textarea = mountTextArea({
|
|
17
|
+
value: 'Hello',
|
|
18
|
+
placeholder: 'Enter a comment',
|
|
19
|
+
rowHeight: '5',
|
|
20
|
+
}).find('textarea')
|
|
21
|
+
|
|
22
|
+
expect(textarea.element.value).toBe('Hello')
|
|
23
|
+
expect(textarea.attributes('placeholder')).toBe('Enter a comment')
|
|
24
|
+
expect(textarea.attributes('rows')).toBe('5')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('emits input-change with the new value when typing', async () => {
|
|
28
|
+
const wrapper = mountTextArea()
|
|
29
|
+
|
|
30
|
+
await wrapper.find('textarea').setValue('new text')
|
|
31
|
+
|
|
32
|
+
expect(wrapper.emitted('input-change')).toHaveLength(1)
|
|
33
|
+
expect(wrapper.emitted('input-change')[0]).toEqual(['new text'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not emit input-change when disabled', async () => {
|
|
37
|
+
const wrapper = mountTextArea({ isDisabled: true })
|
|
38
|
+
const textarea = wrapper.find('textarea')
|
|
39
|
+
|
|
40
|
+
expect(textarea.attributes('disabled')).toBeDefined()
|
|
41
|
+
await textarea.setValue('blocked')
|
|
42
|
+
|
|
43
|
+
expect(wrapper.emitted('input-change')).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('emits input-blur and input-focus with the current value', async () => {
|
|
47
|
+
const wrapper = mountTextArea({ value: 'abc' })
|
|
48
|
+
const textarea = wrapper.find('textarea')
|
|
49
|
+
|
|
50
|
+
await textarea.trigger('focus')
|
|
51
|
+
await textarea.trigger('blur')
|
|
52
|
+
|
|
53
|
+
expect(wrapper.emitted('input-focus')[0]).toEqual(['abc'])
|
|
54
|
+
expect(wrapper.emitted('input-blur')[0]).toEqual(['abc'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders the label with the required star', () => {
|
|
58
|
+
const wrapper = mountTextArea({ label: 'Description', isRequiredLabel: true })
|
|
59
|
+
expect(wrapper.text()).toContain('Description')
|
|
60
|
+
expect(wrapper.text()).toContain('*')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('renders the info text only when a message is provided', () => {
|
|
64
|
+
expect(
|
|
65
|
+
mountTextArea({ label: 'Description' }).findComponent(InfoText).exists()
|
|
66
|
+
).toBe(false)
|
|
67
|
+
expect(
|
|
68
|
+
mountTextArea({ label: 'Description', infoTextMessage: 'Some hint' })
|
|
69
|
+
.findComponent(InfoText)
|
|
70
|
+
.exists()
|
|
71
|
+
).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('shows the error text only when isError is set', () => {
|
|
75
|
+
expect(mountTextArea({ errorText: 'Required' }).text()).not.toContain(
|
|
76
|
+
'Required'
|
|
77
|
+
)
|
|
78
|
+
expect(
|
|
79
|
+
mountTextArea({ isError: true, errorText: 'Required' }).text()
|
|
80
|
+
).toContain('Required')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('prefers inputDataId over dataId for the textarea data-id', () => {
|
|
84
|
+
const textarea = mountTextArea({
|
|
85
|
+
inputDataId: 'note_input',
|
|
86
|
+
dataId: 'fallback',
|
|
87
|
+
}).find('textarea')
|
|
88
|
+
expect(textarea.attributes('data-id')).toBe('note_input')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -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
|
+
}
|