cozy-ui 146.2.1 → 146.3.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/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/react/DropdownText/index.jsx +3 -5
- package/react/MemberAutocomplete/MemberAutocomplete.spec.jsx +83 -0
- package/react/MemberAutocomplete/MemberAutocompleteActions.jsx +77 -0
- package/react/MemberAutocomplete/MemberChip.jsx +29 -0
- package/react/MemberAutocomplete/MemberSuggestion.jsx +32 -0
- package/react/MemberAutocomplete/Readme.md +65 -0
- package/react/MemberAutocomplete/helpers.js +70 -0
- package/react/MemberAutocomplete/helpers.spec.js +51 -0
- package/react/MemberAutocomplete/index.jsx +247 -0
- package/react/MemberAutocomplete/locales/de.json +5 -0
- package/react/MemberAutocomplete/locales/en.json +5 -0
- package/react/MemberAutocomplete/locales/es.json +5 -0
- package/react/MemberAutocomplete/locales/fr.json +5 -0
- package/react/MemberAutocomplete/locales/index.js +17 -0
- package/react/MemberAutocomplete/locales/it.json +5 -0
- package/react/MemberAutocomplete/locales/ru.json +5 -0
- package/react/MemberAutocomplete/locales/vi.json +5 -0
- package/react/MemberAutocomplete/useMemberInput.js +80 -0
- package/react/MemberAutocomplete/useMemberSuggestions.js +65 -0
- package/react/MemberAutocomplete/useMemberSuggestions.spec.js +97 -0
- package/react/MemberAutocomplete/useRoleMenuFocus.js +35 -0
- package/react/MemberRow/MemberOrGroupAvatar.jsx +21 -0
- package/react/MemberRow/MemberRoleMenu.jsx +29 -4
- package/react/MemberRow/MemberRoleMenuHeader.jsx +3 -8
- package/react/MemberRow/Readme.md +5 -4
- package/react/MemberRow/index.jsx +3 -19
- package/react/MemberRow/propTypes.js +1 -0
- package/react/MuiCozyTheme/overrides/makeDarkOverrides.js +5 -0
- package/transpiled/react/DropdownText/index.js +3 -3
- package/transpiled/react/MemberAutocomplete/MemberAutocomplete.spec.d.ts +1 -0
- package/transpiled/react/MemberAutocomplete/MemberAutocompleteActions.d.ts +25 -0
- package/transpiled/react/MemberAutocomplete/MemberAutocompleteActions.js +69 -0
- package/transpiled/react/MemberAutocomplete/MemberChip.d.ts +39 -0
- package/transpiled/react/MemberAutocomplete/MemberChip.js +33 -0
- package/transpiled/react/MemberAutocomplete/MemberSuggestion.d.ts +36 -0
- package/transpiled/react/MemberAutocomplete/MemberSuggestion.js +31 -0
- package/transpiled/react/MemberAutocomplete/helpers.d.ts +7 -0
- package/transpiled/react/MemberAutocomplete/helpers.js +73 -0
- package/transpiled/react/MemberAutocomplete/helpers.spec.d.ts +1 -0
- package/transpiled/react/MemberAutocomplete/index.d.ts +71 -0
- package/transpiled/react/MemberAutocomplete/index.js +295 -0
- package/transpiled/react/MemberAutocomplete/locales/index.d.ts +16 -0
- package/transpiled/react/MemberAutocomplete/locales/index.js +44 -0
- package/transpiled/react/MemberAutocomplete/useMemberInput.d.ts +14 -0
- package/transpiled/react/MemberAutocomplete/useMemberInput.js +76 -0
- package/transpiled/react/MemberAutocomplete/useMemberSuggestions.d.ts +11 -0
- package/transpiled/react/MemberAutocomplete/useMemberSuggestions.js +120 -0
- package/transpiled/react/MemberAutocomplete/useMemberSuggestions.spec.d.ts +1 -0
- package/transpiled/react/MemberAutocomplete/useRoleMenuFocus.d.ts +6 -0
- package/transpiled/react/MemberAutocomplete/useRoleMenuFocus.js +37 -0
- package/transpiled/react/MemberList/index.d.ts +1 -0
- package/transpiled/react/MemberRow/MemberOrGroupAvatar.d.ts +39 -0
- package/transpiled/react/MemberRow/MemberOrGroupAvatar.js +29 -0
- package/transpiled/react/MemberRow/MemberRoleMenu.d.ts +9 -1
- package/transpiled/react/MemberRow/MemberRoleMenu.js +26 -9
- package/transpiled/react/MemberRow/MemberRoleMenuHeader.d.ts +1 -0
- package/transpiled/react/MemberRow/MemberRoleMenuHeader.js +4 -13
- package/transpiled/react/MemberRow/MemberRowActions.d.ts +1 -0
- package/transpiled/react/MemberRow/index.d.ts +1 -10
- package/transpiled/react/MemberRow/index.js +4 -13
- package/transpiled/react/MemberRow/propTypes.d.ts +1 -0
- package/transpiled/react/MemberRow/propTypes.js +1 -0
- package/transpiled/react/MuiCozyTheme/overrides/makeDarkOverrides.d.ts +5 -0
- package/transpiled/react/MuiCozyTheme/overrides/makeDarkOverrides.js +5 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import cx from 'classnames'
|
|
2
|
+
import PropTypes from 'prop-types'
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
import { useI18n, useExtendI18n } from 'twake-i18n'
|
|
5
|
+
|
|
6
|
+
import MemberAutocompleteActions from './MemberAutocompleteActions'
|
|
7
|
+
import MemberChip from './MemberChip'
|
|
8
|
+
import MemberSuggestion from './MemberSuggestion'
|
|
9
|
+
import { getUnpickedMembers, isEmailLike } from './helpers'
|
|
10
|
+
import { locales } from './locales'
|
|
11
|
+
import { useMemberInput } from './useMemberInput'
|
|
12
|
+
import { useMemberSuggestions } from './useMemberSuggestions'
|
|
13
|
+
import { useRoleMenuFocus } from './useRoleMenuFocus'
|
|
14
|
+
import Autocomplete from '../Autocomplete'
|
|
15
|
+
import Buttons from '../Buttons'
|
|
16
|
+
import { memberPropType, rolesPropType } from '../MemberRow/propTypes'
|
|
17
|
+
import TextField from '../TextField'
|
|
18
|
+
import Typography from '../Typography'
|
|
19
|
+
import { getRandomUUID } from '../helpers/getRandomUUID'
|
|
20
|
+
import { makeStyles } from '../styles'
|
|
21
|
+
|
|
22
|
+
const LIMIT_TAGS = 3
|
|
23
|
+
|
|
24
|
+
const useStyles = makeStyles({
|
|
25
|
+
inputRoot: {
|
|
26
|
+
'&&': {
|
|
27
|
+
padding: '.375rem .5625rem',
|
|
28
|
+
paddingRight: ({ actionsWidth }) =>
|
|
29
|
+
actionsWidth > 0 ? `calc(${actionsWidth}px + 1.5rem)` : null
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
input: {
|
|
33
|
+
'&&': {
|
|
34
|
+
padding: '.59375rem .25rem'
|
|
35
|
+
},
|
|
36
|
+
'&&:first-child': {
|
|
37
|
+
paddingLeft: '.4375rem'
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
submit: {
|
|
41
|
+
marginTop: '.3125rem'
|
|
42
|
+
},
|
|
43
|
+
listbox: {
|
|
44
|
+
maxHeight: '9rem'
|
|
45
|
+
},
|
|
46
|
+
option: {
|
|
47
|
+
'&[aria-selected="true"]': {
|
|
48
|
+
backgroundColor: 'transparent'
|
|
49
|
+
},
|
|
50
|
+
'&[data-focus="true"], &:active': {
|
|
51
|
+
backgroundColor: 'var(--actionColorSelected)'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Field to pick the members to invite among suggestions, shown as chips
|
|
58
|
+
*/
|
|
59
|
+
const MemberAutocomplete = ({
|
|
60
|
+
value,
|
|
61
|
+
getSuggestions = () => [],
|
|
62
|
+
fetchSuggestions,
|
|
63
|
+
getMembersFromText = () => [],
|
|
64
|
+
minQueryLength = 3,
|
|
65
|
+
debounceMs = 300,
|
|
66
|
+
roles,
|
|
67
|
+
role,
|
|
68
|
+
title,
|
|
69
|
+
placeholder,
|
|
70
|
+
loading = false,
|
|
71
|
+
disabled = false,
|
|
72
|
+
autoFocus = false,
|
|
73
|
+
onChange,
|
|
74
|
+
onRoleChange,
|
|
75
|
+
onSubmit
|
|
76
|
+
}) => {
|
|
77
|
+
useExtendI18n(locales)
|
|
78
|
+
const { t } = useI18n()
|
|
79
|
+
const [inputId] = useState(getRandomUUID)
|
|
80
|
+
const [inputValue, setInputValue] = useState('')
|
|
81
|
+
const [actionsWidth, setActionsWidth] = useState(0)
|
|
82
|
+
const classes = useStyles({ actionsWidth })
|
|
83
|
+
const { suggestions, isFetching } = useMemberSuggestions({
|
|
84
|
+
inputValue,
|
|
85
|
+
getSuggestions,
|
|
86
|
+
fetchSuggestions,
|
|
87
|
+
getMembersFromText,
|
|
88
|
+
minQueryLength,
|
|
89
|
+
debounceMs
|
|
90
|
+
})
|
|
91
|
+
const {
|
|
92
|
+
handleChange,
|
|
93
|
+
handleInputChange,
|
|
94
|
+
handleKeyDown,
|
|
95
|
+
handlePaste,
|
|
96
|
+
handleBlur
|
|
97
|
+
} = useMemberInput({
|
|
98
|
+
inputValue,
|
|
99
|
+
value,
|
|
100
|
+
suggestions,
|
|
101
|
+
getMembersFromText,
|
|
102
|
+
onInputValueChange: setInputValue,
|
|
103
|
+
onChange
|
|
104
|
+
})
|
|
105
|
+
const {
|
|
106
|
+
isFocusKept,
|
|
107
|
+
handleRoleMenuOpen,
|
|
108
|
+
handleRoleMenuClose,
|
|
109
|
+
handleInputFocus
|
|
110
|
+
} = useRoleMenuFocus(inputId)
|
|
111
|
+
|
|
112
|
+
const titleId = `${inputId}-title`
|
|
113
|
+
const isTitleShown = title !== undefined
|
|
114
|
+
const isSubmitShown = onSubmit !== undefined
|
|
115
|
+
const hasMembersToSend = value.length > 0 || isEmailLike(inputValue)
|
|
116
|
+
const isLoading = loading || isFetching
|
|
117
|
+
const unpickedSuggestions = getUnpickedMembers(suggestions, value)
|
|
118
|
+
const fieldPlaceholder = value.length === 0 ? placeholder : undefined
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div>
|
|
122
|
+
{isTitleShown && (
|
|
123
|
+
<Typography id={titleId} variant="h5" className="u-mb-half">
|
|
124
|
+
{title}
|
|
125
|
+
</Typography>
|
|
126
|
+
)}
|
|
127
|
+
<div className="u-flex u-flex-items-start">
|
|
128
|
+
<Autocomplete
|
|
129
|
+
className="u-flex-grow-1"
|
|
130
|
+
classes={{
|
|
131
|
+
inputRoot: classes.inputRoot,
|
|
132
|
+
input: classes.input,
|
|
133
|
+
paper: 'u-mv-half u-bdrs-4',
|
|
134
|
+
listbox: cx('u-pv-half', classes.listbox),
|
|
135
|
+
option: cx('u-p-0 u-mih-auto', classes.option)
|
|
136
|
+
}}
|
|
137
|
+
id={inputId}
|
|
138
|
+
multiple
|
|
139
|
+
freeSolo
|
|
140
|
+
autoHighlight
|
|
141
|
+
disableClearable
|
|
142
|
+
limitTags={isFocusKept ? -1 : LIMIT_TAGS}
|
|
143
|
+
disabled={disabled}
|
|
144
|
+
options={unpickedSuggestions}
|
|
145
|
+
value={value}
|
|
146
|
+
inputValue={inputValue}
|
|
147
|
+
filterOptions={allOptions => allOptions}
|
|
148
|
+
getOptionLabel={option => option.name ?? option}
|
|
149
|
+
getOptionSelected={(option, picked) => option.id === picked.id}
|
|
150
|
+
renderOption={suggestion => (
|
|
151
|
+
<MemberSuggestion suggestion={suggestion} />
|
|
152
|
+
)}
|
|
153
|
+
renderTags={(members, getTagProps) =>
|
|
154
|
+
members.map((member, index) => (
|
|
155
|
+
<MemberChip
|
|
156
|
+
key={member.id}
|
|
157
|
+
member={member}
|
|
158
|
+
tagProps={getTagProps({ index })}
|
|
159
|
+
/>
|
|
160
|
+
))
|
|
161
|
+
}
|
|
162
|
+
renderInput={params => (
|
|
163
|
+
<TextField
|
|
164
|
+
{...params}
|
|
165
|
+
variant="outlined"
|
|
166
|
+
type="email"
|
|
167
|
+
placeholder={fieldPlaceholder}
|
|
168
|
+
autoFocus={autoFocus}
|
|
169
|
+
InputProps={{
|
|
170
|
+
...params.InputProps,
|
|
171
|
+
endAdornment: (
|
|
172
|
+
<MemberAutocompleteActions
|
|
173
|
+
roles={roles}
|
|
174
|
+
role={role}
|
|
175
|
+
isLoading={isLoading}
|
|
176
|
+
disabled={disabled}
|
|
177
|
+
onRoleChange={onRoleChange}
|
|
178
|
+
onRoleMenuOpen={handleRoleMenuOpen}
|
|
179
|
+
onRoleMenuClose={handleRoleMenuClose}
|
|
180
|
+
onWidthChange={setActionsWidth}
|
|
181
|
+
/>
|
|
182
|
+
)
|
|
183
|
+
}}
|
|
184
|
+
inputProps={{
|
|
185
|
+
...params.inputProps,
|
|
186
|
+
'aria-labelledby': isTitleShown ? titleId : undefined,
|
|
187
|
+
onKeyDown: handleKeyDown,
|
|
188
|
+
onPaste: handlePaste
|
|
189
|
+
}}
|
|
190
|
+
focused={isFocusKept || undefined}
|
|
191
|
+
onFocus={handleInputFocus}
|
|
192
|
+
onBlur={handleBlur}
|
|
193
|
+
/>
|
|
194
|
+
)}
|
|
195
|
+
onChange={handleChange}
|
|
196
|
+
onInputChange={handleInputChange}
|
|
197
|
+
/>
|
|
198
|
+
{isSubmitShown && (
|
|
199
|
+
<Buttons
|
|
200
|
+
className={cx('u-ml-1', classes.submit)}
|
|
201
|
+
label={t('MemberAutocomplete.send')}
|
|
202
|
+
disabled={disabled || !hasMembersToSend}
|
|
203
|
+
onClick={onSubmit}
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
MemberAutocomplete.propTypes = {
|
|
212
|
+
/** Picked members, see `MemberRow` */
|
|
213
|
+
value: PropTypes.arrayOf(memberPropType).isRequired,
|
|
214
|
+
/** Returns the local members matching a text */
|
|
215
|
+
getSuggestions: PropTypes.func,
|
|
216
|
+
/** Resolves with the remote members matching a text, shown before the local ones */
|
|
217
|
+
fetchSuggestions: PropTypes.func,
|
|
218
|
+
/** Turns a typed or pasted text (e.g. emails) into members, suggested when nothing matches.
|
|
219
|
+
* Give them the `email` of the person, so that a suggested or picked one replaces them */
|
|
220
|
+
getMembersFromText: PropTypes.func,
|
|
221
|
+
/** Text length from which `fetchSuggestions` is called */
|
|
222
|
+
minQueryLength: PropTypes.number,
|
|
223
|
+
/** Delay before calling `fetchSuggestions` */
|
|
224
|
+
debounceMs: PropTypes.number,
|
|
225
|
+
/** Roles the members can get, as `{ value, label }` */
|
|
226
|
+
roles: rolesPropType,
|
|
227
|
+
/** Role given to the picked members */
|
|
228
|
+
role: PropTypes.string,
|
|
229
|
+
/** Title shown above the field */
|
|
230
|
+
title: PropTypes.string,
|
|
231
|
+
/** Placeholder of the field, shown while no member is picked */
|
|
232
|
+
placeholder: PropTypes.string,
|
|
233
|
+
/** Whether the suggestions are loading */
|
|
234
|
+
loading: PropTypes.bool,
|
|
235
|
+
/** Whether the field is disabled */
|
|
236
|
+
disabled: PropTypes.bool,
|
|
237
|
+
/** Whether the field takes the focus */
|
|
238
|
+
autoFocus: PropTypes.bool,
|
|
239
|
+
/** Called with the picked members */
|
|
240
|
+
onChange: PropTypes.func.isRequired,
|
|
241
|
+
/** Called with the new role */
|
|
242
|
+
onRoleChange: PropTypes.func,
|
|
243
|
+
/** Shows a send button calling it */
|
|
244
|
+
onSubmit: PropTypes.func
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export default MemberAutocomplete
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import de from './de.json'
|
|
2
|
+
import en from './en.json'
|
|
3
|
+
import es from './es.json'
|
|
4
|
+
import fr from './fr.json'
|
|
5
|
+
import it from './it.json'
|
|
6
|
+
import ru from './ru.json'
|
|
7
|
+
import vi from './vi.json'
|
|
8
|
+
|
|
9
|
+
export const locales = {
|
|
10
|
+
de,
|
|
11
|
+
en,
|
|
12
|
+
es,
|
|
13
|
+
fr,
|
|
14
|
+
it,
|
|
15
|
+
ru,
|
|
16
|
+
vi
|
|
17
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getUnpickedMembers,
|
|
3
|
+
isEmailLike,
|
|
4
|
+
makeMembersFromValue,
|
|
5
|
+
resolveKnownMembers
|
|
6
|
+
} from './helpers'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Turns the typed text into members on Enter, Space, paste and blur. A text giving no member
|
|
10
|
+
* stays typed, and a member for a suggested or picked person is replaced by it.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} params
|
|
13
|
+
* @param {string} params.inputValue - Typed text
|
|
14
|
+
* @param {object[]} params.value - Picked members
|
|
15
|
+
* @param {object[]} params.suggestions - Suggested members
|
|
16
|
+
* @param {Function} params.getMembersFromText - Turns a text into members
|
|
17
|
+
* @param {Function} params.onInputValueChange - Called with the typed text
|
|
18
|
+
* @param {Function} params.onChange - Called with the picked members
|
|
19
|
+
*/
|
|
20
|
+
export const useMemberInput = ({
|
|
21
|
+
inputValue,
|
|
22
|
+
value,
|
|
23
|
+
suggestions,
|
|
24
|
+
getMembersFromText,
|
|
25
|
+
onInputValueChange,
|
|
26
|
+
onChange
|
|
27
|
+
}) => {
|
|
28
|
+
const getKnownMembersFromText = text =>
|
|
29
|
+
resolveKnownMembers(getMembersFromText(text), [...value, ...suggestions])
|
|
30
|
+
|
|
31
|
+
const addMembersFromText = text => {
|
|
32
|
+
const members = getKnownMembersFromText(text)
|
|
33
|
+
if (members.length === 0) return false
|
|
34
|
+
|
|
35
|
+
onChange([...value, ...getUnpickedMembers(members, value)])
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const handleChange = (event, newValue, reason) => {
|
|
40
|
+
const typedText = newValue.find(item => typeof item === 'string')
|
|
41
|
+
const isUnknownText =
|
|
42
|
+
typedText !== undefined && getKnownMembersFromText(typedText).length === 0
|
|
43
|
+
if (isUnknownText) return
|
|
44
|
+
|
|
45
|
+
onChange(makeMembersFromValue(newValue, getKnownMembersFromText))
|
|
46
|
+
if (reason !== 'remove-option') onInputValueChange('')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The Autocomplete resets the text whenever `value` is a new array, even with the same members
|
|
50
|
+
const handleInputChange = (event, newInputValue, reason) => {
|
|
51
|
+
if (reason !== 'reset') onInputValueChange(newInputValue)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleKeyDown = event => {
|
|
55
|
+
if (event.key === ' ' && addMembersFromText(inputValue)) {
|
|
56
|
+
event.preventDefault()
|
|
57
|
+
onInputValueChange('')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handlePaste = event => {
|
|
62
|
+
if (addMembersFromText(event.clipboardData.getData('text'))) {
|
|
63
|
+
event.preventDefault()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handleBlur = () => {
|
|
68
|
+
if (isEmailLike(inputValue) && addMembersFromText(inputValue)) {
|
|
69
|
+
onInputValueChange('')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
handleChange,
|
|
75
|
+
handleInputChange,
|
|
76
|
+
handleKeyDown,
|
|
77
|
+
handlePaste,
|
|
78
|
+
handleBlur
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { makeSuggestions } from './helpers'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Computes the members to suggest for the typed text. The local ones and the ones made
|
|
7
|
+
* from the text are always up to date. The fetched ones come first once `fetchSuggestions`
|
|
8
|
+
* answers for the current text.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} params
|
|
11
|
+
* @param {string} params.inputValue - Typed text
|
|
12
|
+
* @param {Function} params.getSuggestions - Returns the local members matching a text
|
|
13
|
+
* @param {Function} [params.fetchSuggestions] - Resolves with the remote members matching a text
|
|
14
|
+
* @param {Function} params.getMembersFromText - Turns a text into members, suggested when nothing matches
|
|
15
|
+
* @param {number} params.minQueryLength - Text length from which `fetchSuggestions` is called
|
|
16
|
+
* @param {number} params.debounceMs - Delay before calling `fetchSuggestions`
|
|
17
|
+
* @returns {{ suggestions: object[], isFetching: boolean }}
|
|
18
|
+
*/
|
|
19
|
+
export const useMemberSuggestions = ({
|
|
20
|
+
inputValue,
|
|
21
|
+
getSuggestions,
|
|
22
|
+
fetchSuggestions,
|
|
23
|
+
getMembersFromText,
|
|
24
|
+
minQueryLength,
|
|
25
|
+
debounceMs
|
|
26
|
+
}) => {
|
|
27
|
+
const [fetched, setFetched] = useState({ query: '', members: [] })
|
|
28
|
+
const fetchSuggestionsRef = useRef()
|
|
29
|
+
fetchSuggestionsRef.current = fetchSuggestions
|
|
30
|
+
|
|
31
|
+
const query = inputValue.trim()
|
|
32
|
+
const isRemoteQuery =
|
|
33
|
+
fetchSuggestions !== undefined && query.length >= minQueryLength
|
|
34
|
+
const isFetching = isRemoteQuery && fetched.query !== query
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!isRemoteQuery) return
|
|
38
|
+
|
|
39
|
+
let isOutdated = false
|
|
40
|
+
const timeout = setTimeout(async () => {
|
|
41
|
+
let members = []
|
|
42
|
+
try {
|
|
43
|
+
members = (await fetchSuggestionsRef.current(query)) ?? []
|
|
44
|
+
} catch {
|
|
45
|
+
members = []
|
|
46
|
+
}
|
|
47
|
+
if (!isOutdated) setFetched({ query, members })
|
|
48
|
+
}, debounceMs)
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
isOutdated = true
|
|
52
|
+
clearTimeout(timeout)
|
|
53
|
+
}
|
|
54
|
+
}, [query, isRemoteQuery, debounceMs])
|
|
55
|
+
|
|
56
|
+
if (query.length === 0) return { suggestions: [], isFetching: false }
|
|
57
|
+
|
|
58
|
+
const fetchedSuggestions = fetched.query === query ? fetched.members : []
|
|
59
|
+
const suggestions = makeSuggestions(
|
|
60
|
+
[...fetchedSuggestions, ...getSuggestions(query)],
|
|
61
|
+
getMembersFromText(query)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return { suggestions, isFetching }
|
|
65
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react-hooks'
|
|
2
|
+
|
|
3
|
+
import { useMemberSuggestions } from './useMemberSuggestions'
|
|
4
|
+
|
|
5
|
+
const alice = { id: 'alice', name: 'Alice' }
|
|
6
|
+
const bob = { id: 'bob', name: 'Bob' }
|
|
7
|
+
const email = { id: 'carol@cozy.cc', name: 'carol@cozy.cc' }
|
|
8
|
+
|
|
9
|
+
describe('useMemberSuggestions', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.useFakeTimers()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
jest.useRealTimers()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const setup = props =>
|
|
19
|
+
renderHook(
|
|
20
|
+
inputValue =>
|
|
21
|
+
useMemberSuggestions({
|
|
22
|
+
inputValue,
|
|
23
|
+
getSuggestions: () => [],
|
|
24
|
+
getMembersFromText: () => [],
|
|
25
|
+
minQueryLength: 3,
|
|
26
|
+
debounceMs: 300,
|
|
27
|
+
...props
|
|
28
|
+
}),
|
|
29
|
+
{ initialProps: 'al' }
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
it('shows the local suggestions at once below the minimum length', () => {
|
|
33
|
+
const fetchSuggestions = jest.fn()
|
|
34
|
+
const { result } = setup({
|
|
35
|
+
getSuggestions: () => [alice],
|
|
36
|
+
fetchSuggestions
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(result.current.suggestions).toEqual([alice])
|
|
40
|
+
expect(fetchSuggestions).not.toHaveBeenCalled()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('shows the local suggestions at once, then the fetched ones first', async () => {
|
|
44
|
+
const { result, rerender } = setup({
|
|
45
|
+
getSuggestions: () => [alice],
|
|
46
|
+
fetchSuggestions: async () => [bob, alice]
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
rerender('ali')
|
|
50
|
+
expect(result.current.suggestions).toEqual([alice])
|
|
51
|
+
expect(result.current.isFetching).toBe(true)
|
|
52
|
+
await act(async () => {
|
|
53
|
+
jest.advanceTimersByTime(300)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(result.current.suggestions).toEqual([bob, alice])
|
|
57
|
+
expect(result.current.isFetching).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('stops suggesting the fetched members once the text changes', async () => {
|
|
61
|
+
const { result, rerender } = setup({
|
|
62
|
+
fetchSuggestions: async () => [bob],
|
|
63
|
+
getMembersFromText: () => [email]
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
rerender('bob')
|
|
67
|
+
await act(async () => {
|
|
68
|
+
jest.advanceTimersByTime(300)
|
|
69
|
+
})
|
|
70
|
+
rerender('carol@cozy.cc')
|
|
71
|
+
|
|
72
|
+
expect(result.current.suggestions).toEqual([email])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('ignores the answer for an outdated text', async () => {
|
|
76
|
+
const fetchSuggestions = jest.fn(async query =>
|
|
77
|
+
query === 'ali' ? [bob] : []
|
|
78
|
+
)
|
|
79
|
+
const { result, rerender } = setup({ fetchSuggestions })
|
|
80
|
+
|
|
81
|
+
rerender('ali')
|
|
82
|
+
rerender('alic')
|
|
83
|
+
await act(async () => {
|
|
84
|
+
jest.advanceTimersByTime(300)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
expect(fetchSuggestions).toHaveBeenCalledTimes(1)
|
|
88
|
+
expect(fetchSuggestions).toHaveBeenCalledWith('alic')
|
|
89
|
+
expect(result.current.suggestions).toEqual([])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('suggests the members made from the text when nothing matches', () => {
|
|
93
|
+
const { result } = setup({ getMembersFromText: () => [email] })
|
|
94
|
+
|
|
95
|
+
expect(result.current.suggestions).toEqual([email])
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keeps the field shown as focused while the role menu, opened from the focused input,
|
|
5
|
+
* is open. On close, the input gets the focus back if the menu took it.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} inputId - Id of the input
|
|
8
|
+
*/
|
|
9
|
+
export const useRoleMenuFocus = inputId => {
|
|
10
|
+
const [isFocusKept, setFocusKept] = useState(false)
|
|
11
|
+
|
|
12
|
+
const handleRoleMenuOpen = () => {
|
|
13
|
+
setFocusKept(document.activeElement.id === inputId)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A menu shown as a bottom sheet on mobile leaves the focus on the input
|
|
17
|
+
const handleRoleMenuClose = () => {
|
|
18
|
+
if (!isFocusKept) return
|
|
19
|
+
|
|
20
|
+
const input = document.getElementById(inputId)
|
|
21
|
+
if (document.activeElement === input) setFocusKept(false)
|
|
22
|
+
else input.focus()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const handleInputFocus = () => {
|
|
26
|
+
setFocusKept(false)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
isFocusKept,
|
|
31
|
+
handleRoleMenuOpen,
|
|
32
|
+
handleRoleMenuClose,
|
|
33
|
+
handleInputFocus
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import PropTypes from 'prop-types'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import MemberAvatar from './MemberAvatar'
|
|
5
|
+
import MemberGroupAvatar from './MemberGroupAvatar'
|
|
6
|
+
import { memberPropType } from './propTypes'
|
|
7
|
+
|
|
8
|
+
const MemberOrGroupAvatar = ({ member, size }) => {
|
|
9
|
+
const { avatarUrl, initials, color, isGroup } = member
|
|
10
|
+
|
|
11
|
+
if (isGroup) return <MemberGroupAvatar size={size} color={color} />
|
|
12
|
+
|
|
13
|
+
return <MemberAvatar size={size} initials={initials} avatarUrl={avatarUrl} />
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
MemberOrGroupAvatar.propTypes = {
|
|
17
|
+
member: memberPropType.isRequired,
|
|
18
|
+
size: PropTypes.string.isRequired
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default MemberOrGroupAvatar
|
|
@@ -11,7 +11,16 @@ import ListItemText from '../ListItemText'
|
|
|
11
11
|
import Radios from '../Radios'
|
|
12
12
|
import useBreakpoints from '../providers/Breakpoints'
|
|
13
13
|
|
|
14
|
-
const MemberRoleMenu = ({
|
|
14
|
+
const MemberRoleMenu = ({
|
|
15
|
+
member,
|
|
16
|
+
value,
|
|
17
|
+
roles,
|
|
18
|
+
size,
|
|
19
|
+
disabled = false,
|
|
20
|
+
onChange,
|
|
21
|
+
onOpen,
|
|
22
|
+
onClose
|
|
23
|
+
}) => {
|
|
15
24
|
const { isMobile } = useBreakpoints()
|
|
16
25
|
const buttonRef = useRef()
|
|
17
26
|
const [isMenuDisplayed, setMenuDisplayed] = useState(false)
|
|
@@ -19,6 +28,16 @@ const MemberRoleMenu = ({ member, value, roles, onChange }) => {
|
|
|
19
28
|
const label = roles.find(role => role.value === value)?.label
|
|
20
29
|
const isHeaderShown = isMobile && member !== undefined
|
|
21
30
|
|
|
31
|
+
const openMenu = () => {
|
|
32
|
+
setMenuDisplayed(true)
|
|
33
|
+
onOpen?.()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const closeMenu = () => {
|
|
37
|
+
setMenuDisplayed(false)
|
|
38
|
+
onClose?.()
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
const handleRoleClick = role => {
|
|
23
42
|
if (role.value !== value) onChange(role.value)
|
|
24
43
|
}
|
|
@@ -29,8 +48,10 @@ const MemberRoleMenu = ({ member, value, roles, onChange }) => {
|
|
|
29
48
|
ref={buttonRef}
|
|
30
49
|
aria-controls="simple-menu"
|
|
31
50
|
aria-haspopup="true"
|
|
32
|
-
onClick={
|
|
51
|
+
onClick={openMenu}
|
|
33
52
|
textVariant="body2"
|
|
53
|
+
size={size}
|
|
54
|
+
disabled={disabled}
|
|
34
55
|
>
|
|
35
56
|
{label}
|
|
36
57
|
</DropdownButton>
|
|
@@ -42,7 +63,7 @@ const MemberRoleMenu = ({ member, value, roles, onChange }) => {
|
|
|
42
63
|
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
|
43
64
|
keepMounted
|
|
44
65
|
autoClose
|
|
45
|
-
onClose={
|
|
66
|
+
onClose={closeMenu}
|
|
46
67
|
>
|
|
47
68
|
{/* The header renders nothing on desktop but would still take the initial focus of the menu */}
|
|
48
69
|
{isHeaderShown && <MemberRoleMenuHeader member={member} />}
|
|
@@ -67,7 +88,11 @@ MemberRoleMenu.propTypes = {
|
|
|
67
88
|
member: memberPropType,
|
|
68
89
|
value: PropTypes.string.isRequired,
|
|
69
90
|
roles: rolesPropType.isRequired,
|
|
70
|
-
|
|
91
|
+
size: PropTypes.oneOf(['small', 'medium', 'large']),
|
|
92
|
+
disabled: PropTypes.bool,
|
|
93
|
+
onChange: PropTypes.func.isRequired,
|
|
94
|
+
onOpen: PropTypes.func,
|
|
95
|
+
onClose: PropTypes.func
|
|
71
96
|
}
|
|
72
97
|
|
|
73
98
|
export default MemberRoleMenu
|