Skip to content

Commit 8cf959e

Browse files
committed
feat: Open NestedSelect to a specific element
The NestedSelect can now open at the specific child level. To do this, all options must have the `id` attribute (at least the element sought), and specify the value of this `id` in the `focusedId` attribute at the root of the options. See the documentation for the example.
1 parent d74b509 commit 8cf959e

File tree

6 files changed

+219
-90
lines changed

6 files changed

+219
-90
lines changed

react/NestedSelect/NestedSelect.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import List from '../List'
66
import Input from '../Input'
77
import Typography from '../Typography'
88
import ItemRow from './ItemRow'
9+
import { makeHistory } from './helpers'
910

1011
export { ItemRow }
1112

@@ -31,7 +32,7 @@ const NestedSelect = ({
3132
}) => {
3233
const innerRef = useRef()
3334
const [state, setState] = useState({
34-
history: [options],
35+
history: makeHistory(options, canSelectParent),
3536
searchValue: '',
3637
searchResult: []
3738
})
@@ -189,6 +190,8 @@ NestedSelect.defaultProps = {
189190
}
190191

191192
const ItemPropType = PropTypes.shape({
193+
/** Used to open NestedSelect on the element with this "id" value */
194+
focusedId: PropTypes.string,
192195
/** Header shown above options list */
193196
header: PropTypes.node,
194197
icon: PropTypes.element,

react/NestedSelect/NestedSelect.md

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
You can use `react/NestedSelect/NestedSelectResponsive` wich provides automaticaly a modal on desktop and bottomsheet on mobile, or directly `react/NestedSelect/Modal` and `react/NestedSelect/BottomSheet`.
22

3+
You can open the NestedSelect where a specific item is located, for this your items must have a ***id*** attribute and add the ***focusedId*** attribute to the root of the options.
4+
See below for example
5+
36
```jsx
47
import DemoProvider from 'cozy-ui/docs/components/DemoProvider'
58
import Variants from 'cozy-ui/docs/components/Variants'
@@ -26,7 +29,7 @@ const Image = ({ letter }) => (
2629
)
2730

2831
const letterOption = (letter, description, key) => ({
29-
id: letter,
32+
id: `${letter}${key ? `_${key}` : ''}`,
3033
title: letter,
3134
description,
3235
key,
@@ -43,7 +46,8 @@ const SettingAction = ({ item, onClick }) => {
4346
)
4447
}
4548

46-
const makeOptions = withHeaders => ({
49+
const makeOptions = ({ withHeaders, focusedId } = {}) => ({
50+
focusedId,
4751
header: withHeaders ?
4852
<Alert className="u-mt-1 u-mh-1" icon={false}>This is a header for options</Alert>
4953
: undefined,
@@ -131,34 +135,25 @@ const StaticExample = () => {
131135

132136
const RADIO_BUTTON_ANIM_DURATION = 500
133137

134-
// Crude parent-children relationship
135-
const isParent = (item, childItem) => {
136-
return childItem.title.includes(item.title)
137-
}
138-
139138
const InteractiveExample = () => {
140139
const [showingModal, setShowingModal] = useState(false)
141-
const [selectedItem, setSelected] = useState({ title: 'A' })
140+
const [selectedItem, setSelected] = useState(letterOption('A'))
142141

143142
const showModal = () => setShowingModal(true)
144143
const hideModal = () => setShowingModal(false)
145144

146-
const isSelected = (item, level) => {
145+
const isSelected = item => {
147146
if (!selectedItem) {
148147
return false
149-
} else if (level === 0 && isParent(item, selectedItem)) {
150-
return true
151-
} else if (item.title === selectedItem.title) {
152-
return true
153148
}
154-
return false
149+
return item.id === selectedItem.id
155150
}
156151

157152
const searchOptions = withHeaders => ({
158153
placeholderSearch: 'Placeholder Search',
159154
noDataLabel: 'No Data Found',
160155
onSearch: (value) => {
161-
const options = makeOptions(withHeaders)
156+
const options = makeOptions({ withHeaders })
162157
return options.children.filter(o => o.description && o.description.toLowerCase().includes(value.toLowerCase()))
163158
},
164159
displaySearchResultItem: item =>
@@ -200,7 +195,7 @@ const InteractiveExample = () => {
200195
onSelect={handleSelect}
201196
onClose={hideModal}
202197
isSelected={isSelected}
203-
options={makeOptions(variant.withHeaders)}
198+
options={makeOptions({withHeaders: variant.withHeaders, focusedId: selectedItem.id})}
204199
radioPosition={variant.leftRadio ? 'left' : 'right'}
205200
title={variant.noTitle ? undefined : "Please select letter"}
206201
transformParentItem={transformParentItem}
Lines changed: 107 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,130 @@
11
import React from 'react'
2+
import '@testing-library/jest-dom'
23
import { render, fireEvent } from '@testing-library/react'
34

4-
import NestedSelect, { ItemRow } from './NestedSelect'
5-
import ListItem from '../ListItem'
5+
import NestedSelect from './NestedSelect'
66
import { BreakpointsProvider } from '../providers/Breakpoints'
77

88
describe('NestedSelect', () => {
9-
const options = {
9+
const makeOption = text => ({
10+
id: text,
11+
key: text,
12+
title: text
13+
})
14+
const makeOptions = itemSelected => ({
15+
focusedId: itemSelected?.title,
1016
children: [
11-
{ title: 'A' },
12-
{ title: 'B', children: [{ title: 'B1' }, { title: 'B2' }] },
13-
{ title: 'C' }
17+
makeOption('A'),
18+
{
19+
...makeOption('B'),
20+
children: [makeOption('B1'), makeOption('B2')]
21+
},
22+
makeOption('C')
1423
]
15-
}
24+
})
1625

17-
const findSelectedRow = root => {
18-
return root.findWhere(n => {
19-
if (n.type() != ItemRow) {
26+
const setup = ({
27+
canSelectParent,
28+
itemSelected,
29+
searchOptions,
30+
onSelect = jest.fn(),
31+
onCancel = jest.fn()
32+
} = {}) => {
33+
const isSelected = item => {
34+
if (!itemSelected) {
2035
return false
2136
}
22-
return n.props().isSelected
23-
})
24-
}
25-
26-
const simulateClick = row =>
27-
row
28-
.find(ListItem)
29-
.props()
30-
.onClick()
31-
32-
const setup = ({ canSelectParent, itemSelected, searchOptions }) => {
33-
// Very crude notion of parenting
34-
const isParent = (item, childItem) => {
35-
return childItem && childItem.title.includes(item.title)
37+
return item.title === itemSelected.title
3638
}
3739

38-
const isSelected = (item, level) => {
39-
if (level === 0 && isParent(item, itemSelected)) {
40-
return true
41-
} else if (itemSelected && itemSelected.title === item.title) {
42-
return true
43-
} else {
44-
return false
45-
}
46-
}
40+
const options = makeOptions(itemSelected)
4741

48-
const use = searchOptions ? render : mount
49-
const root = use(
42+
return render(
5043
<BreakpointsProvider>
5144
<NestedSelect
5245
canSelectParent={canSelectParent}
5346
options={options}
5447
isSelected={isSelected}
55-
onSelect={jest.fn()}
56-
onCancel={jest.fn()}
48+
onSelect={onSelect}
49+
onCancel={onCancel}
5750
searchOptions={searchOptions}
5851
/>
5952
</BreakpointsProvider>
6053
)
61-
return { root }
6254
}
6355

64-
describe('when selecting a normal category', () => {
65-
it('should show only one selected item', () => {
66-
const { root } = setup({
67-
itemSelected: { title: 'B1' }
68-
})
69-
const selectedRow = findSelectedRow(root)
70-
expect(selectedRow.length).toBe(1)
71-
expect(selectedRow.text()).toBe('B')
72-
simulateClick(selectedRow)
73-
root.update()
74-
75-
const selectedRow2 = findSelectedRow(root)
76-
expect(selectedRow2.length).toBe(1)
77-
expect(selectedRow2.text()).toBe('B1')
56+
describe('when no item is already selected', () => {
57+
it('should show only children items after click on parent', () => {
58+
const { queryByText } = setup()
59+
const selectedRow = queryByText('B')
60+
expect(selectedRow).toBeInTheDocument()
61+
fireEvent.click(selectedRow)
62+
63+
const selectedRowB = queryByText('B')
64+
const selectedRowB1 = queryByText('B1')
65+
const selectedRowB2 = queryByText('B2')
66+
67+
expect(selectedRowB).toBeNull()
68+
expect(selectedRowB1).toBeInTheDocument()
69+
expect(selectedRowB2).toBeInTheDocument()
70+
})
71+
72+
it('should show parent item & children items after click on parent', () => {
73+
const { queryByText } = setup({ canSelectParent: true })
74+
const selectedRow = queryByText('B')
75+
expect(selectedRow).toBeInTheDocument()
76+
fireEvent.click(selectedRow)
77+
78+
const selectedRowB = queryByText('B')
79+
const selectedRowB1 = queryByText('B1')
80+
const selectedRowB2 = queryByText('B2')
81+
expect(selectedRowB).toBeInTheDocument()
82+
expect(selectedRowB1).toBeInTheDocument()
83+
expect(selectedRowB2).toBeInTheDocument()
7884
})
7985
})
8086

81-
describe('when allowing parent to be selected', () => {
82-
it('should show a line for the parent inside the category', () => {
83-
const { root } = setup({
84-
itemSelected: { title: 'B1' },
87+
describe('when item is already selected', () => {
88+
it('should show the selected item', () => {
89+
const { queryByText } = setup({ itemSelected: { title: 'B1' } })
90+
91+
const selectedRowB = queryByText('B')
92+
const selectedRowB1 = queryByText('B1')
93+
const selectedRowB2 = queryByText('B2')
94+
95+
expect(selectedRowB).toBeNull()
96+
expect(selectedRowB1).toBeInTheDocument()
97+
expect(selectedRowB2).toBeInTheDocument()
98+
})
99+
it('should show the selected item (with parent)', () => {
100+
const { queryByText } = setup({
101+
itemSelected: { title: 'B1', id: 'B1' },
85102
canSelectParent: true
86103
})
87-
const selectedRow = findSelectedRow(root)
88-
expect(selectedRow.length).toBe(1)
89-
simulateClick(selectedRow)
90-
root.update()
91104

92-
expect(root.find(ItemRow).length).toBe(3)
105+
const selectedRowB = queryByText('B')
106+
const selectedRowB1 = queryByText('B1')
107+
const selectedRowB2 = queryByText('B2')
108+
109+
expect(selectedRowB).toBeInTheDocument()
110+
expect(selectedRowB1).toBeInTheDocument()
111+
expect(selectedRowB2).toBeInTheDocument()
112+
})
113+
})
114+
115+
describe('when clicking on an item', () => {
116+
it("should call onSelect with the selected item (who doesn't have children)", () => {
117+
const onSelect = jest.fn()
118+
const { queryByText } = setup({ onSelect })
119+
const selectedRowB = queryByText('B')
120+
fireEvent.click(selectedRowB)
121+
122+
expect(onSelect).not.toHaveBeenCalled()
123+
124+
const selectedRowB1 = queryByText('B1')
125+
fireEvent.click(selectedRowB1)
126+
127+
expect(onSelect).toHaveBeenCalledWith(makeOption('B1'))
93128
})
94129
})
95130

@@ -102,12 +137,12 @@ describe('NestedSelect', () => {
102137
return []
103138
}
104139
}
105-
const { root } = setup({
140+
const { getByText, queryByPlaceholderText } = setup({
106141
searchOptions
107142
})
108143

109-
fireEvent.click(root.getByText('B'))
110-
expect(root.queryByPlaceholderText('Placeholder Search')).toBeFalsy()
144+
fireEvent.click(getByText('B'))
145+
expect(queryByPlaceholderText('Placeholder Search')).toBeFalsy()
111146
})
112147

113148
it('should return no data (onSearch return [])', () => {
@@ -118,14 +153,14 @@ describe('NestedSelect', () => {
118153
return []
119154
}
120155
}
121-
const { root } = setup({
156+
const { getByPlaceholderText, getByText } = setup({
122157
searchOptions
123158
})
124-
const searchInput = root.getByPlaceholderText('Placeholder Search')
159+
const searchInput = getByPlaceholderText('Placeholder Search')
125160
expect(searchInput).toBeTruthy()
126161

127162
fireEvent.change(searchInput, { target: { value: 'cozy' } })
128-
const noData = root.getByText('No Data Found')
163+
const noData = getByText('No Data Found')
129164
expect(noData).toBeTruthy()
130165
})
131166

@@ -143,20 +178,19 @@ describe('NestedSelect', () => {
143178
return data.filter(d => d.title.startsWith(value))
144179
}
145180
}
146-
const { root } = setup({
147-
itemSelected: { title: 'B1' },
181+
const { getByPlaceholderText, queryByText } = setup({
148182
canSelectParent: true,
149183
searchOptions
150184
})
151185

152-
const searchInput = root.getByPlaceholderText('Placeholder Search')
186+
const searchInput = getByPlaceholderText('Placeholder Search')
153187
expect(searchInput).toBeTruthy()
154188

155189
fireEvent.change(searchInput, { target: { value: 'cozy' } })
156190

157-
expect(root.queryByText('cozy 1')).toBeTruthy()
158-
expect(root.queryByText('cozy 1')).toBeTruthy()
159-
expect(root.queryByText('anything')).toBeFalsy()
191+
expect(queryByText('cozy 1')).toBeTruthy()
192+
expect(queryByText('cozy 1')).toBeTruthy()
193+
expect(queryByText('anything')).toBeFalsy()
160194
})
161195
})
162196
})

react/NestedSelect/helpers.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @param {import('../types').NestedSelectOption} options
3+
* @param {boolean} canSelectParent
4+
* @returns {import('../types').NestedSelectOption[]}
5+
*/
6+
export const makeHistory = (options, canSelectParent) => {
7+
if (!options) return []
8+
9+
const focusedId = options.focusedId ?? null
10+
if (!focusedId) return [options]
11+
12+
const result = []
13+
const findElement = opts => {
14+
const itemFound = opts.children?.some(
15+
child => child.id === focusedId || findElement(child)
16+
)
17+
if (itemFound) {
18+
if (canSelectParent) {
19+
const item = opts.children.find(child => child.id === focusedId)
20+
if (item?.children) result.push(item)
21+
}
22+
result.push(opts)
23+
return true
24+
}
25+
return false
26+
}
27+
findElement(options)
28+
29+
if (result.length === 0) {
30+
console.log('No item found with id', focusedId)
31+
result.push(options)
32+
}
33+
34+
return result
35+
}

0 commit comments

Comments
 (0)