cozy-ui 146.2.2 → 146.3.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/CHANGELOG.md +21 -0
- package/package.json +1 -1
- package/react/DropdownText/index.jsx +3 -5
- package/react/MemberAutocomplete/MemberAutocomplete.spec.jsx +107 -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 +248 -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 +84 -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/makeTheme.jsx +12 -1
- 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 +296 -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 +15 -0
- package/transpiled/react/MemberAutocomplete/useMemberInput.js +79 -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/makeTheme.js +15 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## [146.3.1](https://github.com/cozy/cozy-ui/compare/v146.3.0...v146.3.1) (2026-11-03)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **MemberAutocomplete:** Enable Send only for a text giving members ([6ffaeb0](https://github.com/cozy/cozy-ui/commit/6ffaeb0))
|
|
7
|
+
* **MuiCozyTheme:** Stop the CircularProgress animation in screenshots ([d5025ad](https://github.com/cozy/cozy-ui/commit/d5025ad))
|
|
8
|
+
|
|
9
|
+
# [146.3.0](https://github.com/cozy/cozy-ui/compare/v146.2.2...v146.3.0) (2026-11-03)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* **DropdownText:** Restore the colors once enabled again ([8d28332](https://github.com/cozy/cozy-ui/commit/8d28332))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **MemberAutocomplete:** Add the field to pick the members to invite ([ee0250b](https://github.com/cozy/cozy-ui/commit/ee0250b))
|
|
20
|
+
* **MemberRoleMenu:** Add the size, disabled, onOpen and onClose props ([5306f72](https://github.com/cozy/cozy-ui/commit/5306f72))
|
|
21
|
+
|
|
1
22
|
## [146.2.2](https://github.com/cozy/cozy-ui/compare/v146.2.1...v146.2.2) (2026-11-02)
|
|
2
23
|
|
|
3
24
|
|
package/package.json
CHANGED
|
@@ -14,15 +14,13 @@ const useStyles = makeStyles(theme => ({
|
|
|
14
14
|
spaceBetween ? 'space-between' : 'left'
|
|
15
15
|
},
|
|
16
16
|
typography: {
|
|
17
|
-
color: ({ disabled }) =>
|
|
18
|
-
disabled ? theme.palette.text.disabled : undefined
|
|
17
|
+
color: ({ disabled }) => (disabled ? theme.palette.text.disabled : null)
|
|
19
18
|
},
|
|
20
19
|
endIcon: {
|
|
21
20
|
display: 'flex',
|
|
22
21
|
marginLeft: '5px',
|
|
23
|
-
marginTop: ({ variant }) => (variant === 'body1' ? '3px' :
|
|
24
|
-
color: ({ disabled }) =>
|
|
25
|
-
disabled ? theme.palette.text.disabled : undefined
|
|
22
|
+
marginTop: ({ variant }) => (variant === 'body1' ? '3px' : null),
|
|
23
|
+
color: ({ disabled }) => (disabled ? theme.palette.text.disabled : null)
|
|
26
24
|
}
|
|
27
25
|
}))
|
|
28
26
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import MemberAutocomplete from '.'
|
|
5
|
+
import DemoProvider from '../providers/DemoProvider'
|
|
6
|
+
|
|
7
|
+
const getMembersFromText = text =>
|
|
8
|
+
(text.match(/[^\s,;]+@[^\s,;]+/g) || []).map(email => ({
|
|
9
|
+
id: email,
|
|
10
|
+
name: email
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
describe('MemberAutocomplete', () => {
|
|
14
|
+
const setup = () => {
|
|
15
|
+
const onChange = jest.fn()
|
|
16
|
+
render(
|
|
17
|
+
<DemoProvider>
|
|
18
|
+
<MemberAutocomplete
|
|
19
|
+
value={[]}
|
|
20
|
+
getMembersFromText={getMembersFromText}
|
|
21
|
+
placeholder="Add people"
|
|
22
|
+
onChange={onChange}
|
|
23
|
+
/>
|
|
24
|
+
</DemoProvider>
|
|
25
|
+
)
|
|
26
|
+
return { onChange, input: screen.getByPlaceholderText('Add people') }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
it('adds a member per pasted email', () => {
|
|
30
|
+
const { onChange, input } = setup()
|
|
31
|
+
|
|
32
|
+
fireEvent.paste(input, {
|
|
33
|
+
clipboardData: { getData: () => 'alice@cozy.cc, bob@cozy.cc' }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
37
|
+
{ id: 'alice@cozy.cc', name: 'alice@cozy.cc' },
|
|
38
|
+
{ id: 'bob@cozy.cc', name: 'bob@cozy.cc' }
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('adds the typed email on Space', () => {
|
|
43
|
+
const { onChange, input } = setup()
|
|
44
|
+
|
|
45
|
+
fireEvent.change(input, { target: { value: 'alice@cozy.cc' } })
|
|
46
|
+
fireEvent.keyDown(input, { key: ' ' })
|
|
47
|
+
|
|
48
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
49
|
+
{ id: 'alice@cozy.cc', name: 'alice@cozy.cc' }
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('adds the typed email when leaving the field', () => {
|
|
54
|
+
const { onChange, input } = setup()
|
|
55
|
+
|
|
56
|
+
fireEvent.change(input, { target: { value: 'alice@cozy.cc' } })
|
|
57
|
+
fireEvent.blur(input)
|
|
58
|
+
|
|
59
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
60
|
+
{ id: 'alice@cozy.cc', name: 'alice@cozy.cc' }
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('keeps the typed text when the picked members come in a new array', () => {
|
|
65
|
+
const alice = { id: 'alice', name: 'Alice' }
|
|
66
|
+
const renderField = value => (
|
|
67
|
+
<DemoProvider>
|
|
68
|
+
<MemberAutocomplete
|
|
69
|
+
value={value}
|
|
70
|
+
placeholder="Add people"
|
|
71
|
+
onChange={() => {}}
|
|
72
|
+
/>
|
|
73
|
+
</DemoProvider>
|
|
74
|
+
)
|
|
75
|
+
const { rerender } = render(renderField([alice]))
|
|
76
|
+
const input = screen.getByRole('textbox')
|
|
77
|
+
|
|
78
|
+
fireEvent.change(input, { target: { value: 'bo' } })
|
|
79
|
+
rerender(renderField([alice]))
|
|
80
|
+
|
|
81
|
+
expect(input.value).toBe('bo')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('enables Send only for a typed text that gives members', () => {
|
|
85
|
+
const getMembersWithDomain = text =>
|
|
86
|
+
(text.match(/\S+@\S+\.\S+/g) || []).map(email => ({ id: email }))
|
|
87
|
+
render(
|
|
88
|
+
<DemoProvider>
|
|
89
|
+
<MemberAutocomplete
|
|
90
|
+
value={[]}
|
|
91
|
+
getMembersFromText={getMembersWithDomain}
|
|
92
|
+
placeholder="Add people"
|
|
93
|
+
onChange={() => {}}
|
|
94
|
+
onSubmit={() => {}}
|
|
95
|
+
/>
|
|
96
|
+
</DemoProvider>
|
|
97
|
+
)
|
|
98
|
+
const input = screen.getByPlaceholderText('Add people')
|
|
99
|
+
const send = screen.getByRole('button', { name: 'Send' })
|
|
100
|
+
|
|
101
|
+
fireEvent.change(input, { target: { value: 'bob@example' } })
|
|
102
|
+
expect(send).toBeDisabled()
|
|
103
|
+
|
|
104
|
+
fireEvent.change(input, { target: { value: 'bob@example.com' } })
|
|
105
|
+
expect(send).toBeEnabled()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import cx from 'classnames'
|
|
2
|
+
import PropTypes from 'prop-types'
|
|
3
|
+
import React, { useLayoutEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
import CircularProgress from '../CircularProgress'
|
|
6
|
+
import MemberRoleMenu from '../MemberRow/MemberRoleMenu'
|
|
7
|
+
import { rolesPropType } from '../MemberRow/propTypes'
|
|
8
|
+
import { makeStyles } from '../styles'
|
|
9
|
+
|
|
10
|
+
const useStyles = makeStyles({
|
|
11
|
+
root: {
|
|
12
|
+
top: '.375rem',
|
|
13
|
+
height: '2.375rem'
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const MemberAutocompleteActions = ({
|
|
18
|
+
roles,
|
|
19
|
+
role,
|
|
20
|
+
isLoading,
|
|
21
|
+
disabled,
|
|
22
|
+
onRoleChange,
|
|
23
|
+
onRoleMenuOpen,
|
|
24
|
+
onRoleMenuClose,
|
|
25
|
+
onWidthChange
|
|
26
|
+
}) => {
|
|
27
|
+
const classes = useStyles()
|
|
28
|
+
const rootRef = useRef()
|
|
29
|
+
const isRoleMenuShown = roles !== undefined && role !== undefined
|
|
30
|
+
const hasActions = isLoading || isRoleMenuShown
|
|
31
|
+
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
onWidthChange(rootRef.current ? rootRef.current.offsetWidth : 0)
|
|
34
|
+
}, [roles, role, isLoading, onWidthChange])
|
|
35
|
+
|
|
36
|
+
if (!hasActions) return null
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
// Clicks and keys in the role menu, portaled or not, bubble to the Autocomplete root,
|
|
40
|
+
// which would focus the input, remove the last member or pick a suggestion
|
|
41
|
+
<div
|
|
42
|
+
ref={rootRef}
|
|
43
|
+
className={cx(
|
|
44
|
+
'u-pos-absolute u-right-m u-flex u-flex-items-center u-g-half',
|
|
45
|
+
classes.root
|
|
46
|
+
)}
|
|
47
|
+
onClick={event => event.stopPropagation()}
|
|
48
|
+
onKeyDown={event => event.stopPropagation()}
|
|
49
|
+
>
|
|
50
|
+
{isLoading && <CircularProgress size={16} />}
|
|
51
|
+
{isRoleMenuShown && (
|
|
52
|
+
<MemberRoleMenu
|
|
53
|
+
value={role}
|
|
54
|
+
roles={roles}
|
|
55
|
+
size="small"
|
|
56
|
+
disabled={disabled}
|
|
57
|
+
onChange={onRoleChange}
|
|
58
|
+
onOpen={onRoleMenuOpen}
|
|
59
|
+
onClose={onRoleMenuClose}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
MemberAutocompleteActions.propTypes = {
|
|
67
|
+
roles: rolesPropType,
|
|
68
|
+
role: PropTypes.string,
|
|
69
|
+
isLoading: PropTypes.bool.isRequired,
|
|
70
|
+
disabled: PropTypes.bool.isRequired,
|
|
71
|
+
onRoleChange: PropTypes.func,
|
|
72
|
+
onRoleMenuOpen: PropTypes.func.isRequired,
|
|
73
|
+
onRoleMenuClose: PropTypes.func.isRequired,
|
|
74
|
+
onWidthChange: PropTypes.func.isRequired
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default MemberAutocompleteActions
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import PropTypes from 'prop-types'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { useI18n, useExtendI18n } from 'twake-i18n'
|
|
4
|
+
|
|
5
|
+
import Chip from '../Chips'
|
|
6
|
+
import MemberOrGroupAvatar from '../MemberRow/MemberOrGroupAvatar'
|
|
7
|
+
import { locales } from '../MemberRow/locales'
|
|
8
|
+
import { memberPropType } from '../MemberRow/propTypes'
|
|
9
|
+
|
|
10
|
+
const MemberChip = ({ member, tagProps }) => {
|
|
11
|
+
useExtendI18n(locales)
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Chip
|
|
16
|
+
{...tagProps}
|
|
17
|
+
label={member.name || t('MemberRow.defaultName')}
|
|
18
|
+
avatar={<MemberOrGroupAvatar member={member} size="xs" />}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
MemberChip.propTypes = {
|
|
24
|
+
member: memberPropType.isRequired,
|
|
25
|
+
/** Props given by the Autocomplete for each tag */
|
|
26
|
+
tagProps: PropTypes.object.isRequired
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default MemberChip
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useI18n, useExtendI18n } from 'twake-i18n'
|
|
3
|
+
|
|
4
|
+
import ListItem from '../ListItem'
|
|
5
|
+
import ListItemIcon from '../ListItemIcon'
|
|
6
|
+
import ListItemText from '../ListItemText'
|
|
7
|
+
import MemberOrGroupAvatar from '../MemberRow/MemberOrGroupAvatar'
|
|
8
|
+
import { locales } from '../MemberRow/locales'
|
|
9
|
+
import { memberPropType } from '../MemberRow/propTypes'
|
|
10
|
+
|
|
11
|
+
const MemberSuggestion = ({ suggestion }) => {
|
|
12
|
+
useExtendI18n(locales)
|
|
13
|
+
const { t } = useI18n()
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<ListItem component="div">
|
|
17
|
+
<ListItemIcon>
|
|
18
|
+
<MemberOrGroupAvatar member={suggestion} size="m" />
|
|
19
|
+
</ListItemIcon>
|
|
20
|
+
<ListItemText
|
|
21
|
+
primary={suggestion.name || t('MemberRow.defaultName')}
|
|
22
|
+
secondary={suggestion.subtitle}
|
|
23
|
+
/>
|
|
24
|
+
</ListItem>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
MemberSuggestion.propTypes = {
|
|
29
|
+
suggestion: memberPropType.isRequired
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default MemberSuggestion
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Field to pick the members to invite: suggestions while typing, picked members shown as chips, and optionally the role to give them and a send button.
|
|
2
|
+
|
|
3
|
+
The field only gets display data, as `MemberRow`: the app turns its own contacts, groups or users into members (`id`, `name`, `subtitle`, `email`, `avatarUrl`, `initials`, `color`, `isGroup`, `data`).
|
|
4
|
+
|
|
5
|
+
* Local suggestions from `getSuggestions` show at once. Remote ones from `fetchSuggestions` come first, once `minQueryLength` characters are typed and `debounceMs` has passed.
|
|
6
|
+
* `getMembersFromText` turns the text into members on Enter, Space, paste, and on blur for an email-like text. Its members are also suggested when nothing else matches. A text giving no member stays typed.
|
|
7
|
+
* Two members with the same `id` or the same `email`, whatever its case, are the same person: a typed email gives the suggested or picked contact with this email, and is never added twice.
|
|
8
|
+
* The role menu shows in the field when both `roles` and `role` are given.
|
|
9
|
+
* Out of focus, the field collapses to its first three members and a `+N` counter.
|
|
10
|
+
* The label of the send button can be replaced in the app's locales with the `MemberAutocomplete.send` key.
|
|
11
|
+
|
|
12
|
+
### Demo
|
|
13
|
+
|
|
14
|
+
```jsx
|
|
15
|
+
import DemoProvider from 'cozy-ui/docs/components/DemoProvider'
|
|
16
|
+
import Variants from 'cozy-ui/docs/components/Variants'
|
|
17
|
+
import MemberAutocomplete from 'cozy-ui/transpiled/react/MemberAutocomplete'
|
|
18
|
+
|
|
19
|
+
const contacts = [
|
|
20
|
+
{ id: 'alice', name: 'Alice Wonderland', subtitle: 'alice@cozy.cc', email: 'alice@cozy.cc', initials: 'AW' },
|
|
21
|
+
{ id: 'bob', name: 'Bob Sponge', subtitle: 'bob@cozy.cc', email: 'bob@cozy.cc', initials: 'BS' },
|
|
22
|
+
{ id: 'carol', name: 'Carol Singer', subtitle: 'carol@cozy.cc', email: 'carol@cozy.cc', initials: 'CS' },
|
|
23
|
+
{ id: 'family', name: 'Family', subtitle: '3 members', isGroup: true, color: '#FF7F1B' }
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const roles = [
|
|
27
|
+
{ value: 'readWrite', label: 'Can edit' },
|
|
28
|
+
{ value: 'readOnly', label: 'Can view' }
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const getSuggestions = text =>
|
|
32
|
+
contacts.filter(contact =>
|
|
33
|
+
[contact.name, contact.email || ""].some(field => field.toLowerCase().includes(text.toLowerCase()))
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const getMembersFromText = text =>
|
|
37
|
+
(text.match(/[^\s,;]+@[^\s,;]+\.[^\s,;]+/g) || []).map(email => ({ id: email, name: email, subtitle: '-', email, initials: email[0].toUpperCase() }))
|
|
38
|
+
|
|
39
|
+
const initialVariants = [{ title: true, roles: true, submit: true, loading: false, disabled: false }]
|
|
40
|
+
|
|
41
|
+
initialState = { members: [], role: 'readWrite' }
|
|
42
|
+
|
|
43
|
+
;
|
|
44
|
+
|
|
45
|
+
<DemoProvider>
|
|
46
|
+
<Variants initialVariants={initialVariants} screenshotAllVariants>
|
|
47
|
+
{variant => (
|
|
48
|
+
<MemberAutocomplete
|
|
49
|
+
value={state.members}
|
|
50
|
+
getSuggestions={getSuggestions}
|
|
51
|
+
getMembersFromText={getMembersFromText}
|
|
52
|
+
roles={variant.roles ? roles : undefined}
|
|
53
|
+
role={state.role}
|
|
54
|
+
title={variant.title ? 'Add people' : undefined}
|
|
55
|
+
placeholder="Type a name or email address"
|
|
56
|
+
loading={variant.loading}
|
|
57
|
+
disabled={variant.disabled}
|
|
58
|
+
onChange={members => setState({ members })}
|
|
59
|
+
onRoleChange={role => setState({ role })}
|
|
60
|
+
onSubmit={variant.submit ? () => setState({ members: [] }) : undefined}
|
|
61
|
+
/>
|
|
62
|
+
)}
|
|
63
|
+
</Variants>
|
|
64
|
+
</DemoProvider>
|
|
65
|
+
```
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const EMAIL_LIKE = /\S+@\S+/
|
|
2
|
+
|
|
3
|
+
const MAX_SUGGESTIONS = 20
|
|
4
|
+
|
|
5
|
+
export const isEmailLike = text => EMAIL_LIKE.test(text)
|
|
6
|
+
|
|
7
|
+
const normalizeEmail = email => email.trim().toLowerCase()
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether two members are the same person: same id, or same email whatever its case
|
|
11
|
+
*/
|
|
12
|
+
export const isSameMember = (member, other) =>
|
|
13
|
+
member.id === other.id ||
|
|
14
|
+
(Boolean(member.email) &&
|
|
15
|
+
Boolean(other.email) &&
|
|
16
|
+
normalizeEmail(member.email) === normalizeEmail(other.email))
|
|
17
|
+
|
|
18
|
+
export const removeDuplicateMembers = members =>
|
|
19
|
+
members.filter(
|
|
20
|
+
(member, index) =>
|
|
21
|
+
members.findIndex(other => isSameMember(member, other)) === index
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export const getUnpickedMembers = (members, pickedMembers) =>
|
|
25
|
+
members.filter(
|
|
26
|
+
member => !pickedMembers.some(picked => isSameMember(member, picked))
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Replaces each member by the known one for the same person, so that a typed email
|
|
31
|
+
* gives the suggested contact rather than a second member
|
|
32
|
+
*
|
|
33
|
+
* @param {object[]} members - Members made from a text
|
|
34
|
+
* @param {object[]} knownMembers - Picked and suggested members
|
|
35
|
+
* @returns {object[]}
|
|
36
|
+
*/
|
|
37
|
+
export const resolveKnownMembers = (members, knownMembers) =>
|
|
38
|
+
removeDuplicateMembers(
|
|
39
|
+
members.map(
|
|
40
|
+
member =>
|
|
41
|
+
knownMembers.find(known => isSameMember(member, known)) ?? member
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Turns the value of the Autocomplete, where typed texts stay strings, into members without duplicates
|
|
47
|
+
*
|
|
48
|
+
* @param {Array<object|string>} value - Picked members and typed texts
|
|
49
|
+
* @param {Function} getMembersFromText - Turns a text into members
|
|
50
|
+
* @returns {object[]}
|
|
51
|
+
*/
|
|
52
|
+
export const makeMembersFromValue = (value, getMembersFromText) =>
|
|
53
|
+
removeDuplicateMembers(
|
|
54
|
+
value.flatMap(item =>
|
|
55
|
+
typeof item === 'string' ? getMembersFromText(item) : [item]
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Members to suggest: the matching ones without duplicates, else the ones made from the text
|
|
61
|
+
*
|
|
62
|
+
* @param {object[]} matchingSuggestions - Fetched then local members matching the text
|
|
63
|
+
* @param {object[]} textSuggestions - Members made from the text
|
|
64
|
+
* @returns {object[]}
|
|
65
|
+
*/
|
|
66
|
+
export const makeSuggestions = (matchingSuggestions, textSuggestions) =>
|
|
67
|
+
(matchingSuggestions.length > 0
|
|
68
|
+
? removeDuplicateMembers(matchingSuggestions)
|
|
69
|
+
: textSuggestions
|
|
70
|
+
).slice(0, MAX_SUGGESTIONS)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isSameMember,
|
|
3
|
+
makeMembersFromValue,
|
|
4
|
+
resolveKnownMembers
|
|
5
|
+
} from './helpers'
|
|
6
|
+
|
|
7
|
+
describe('isSameMember', () => {
|
|
8
|
+
it('matches the same id or the same email whatever its case', () => {
|
|
9
|
+
const alice = { id: 'alice', email: 'Alice@cozy.cc' }
|
|
10
|
+
|
|
11
|
+
expect(isSameMember(alice, { id: 'alice' })).toBe(true)
|
|
12
|
+
expect(isSameMember(alice, { id: 'other', email: 'alice@cozy.cc ' })).toBe(
|
|
13
|
+
true
|
|
14
|
+
)
|
|
15
|
+
expect(isSameMember(alice, { id: 'bob', email: 'bob@cozy.cc' })).toBe(false)
|
|
16
|
+
expect(isSameMember({ id: 'alice' }, { id: 'bob' })).toBe(false)
|
|
17
|
+
expect(
|
|
18
|
+
isSameMember({ id: 'alice', email: '' }, { id: 'bob', email: '' })
|
|
19
|
+
).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('resolveKnownMembers', () => {
|
|
24
|
+
it('replaces the members of known people and removes the duplicates', () => {
|
|
25
|
+
const alice = { id: 'alice', email: 'alice@cozy.cc' }
|
|
26
|
+
const typed = [
|
|
27
|
+
{ id: 'alice@cozy.cc', email: 'alice@cozy.cc' },
|
|
28
|
+
{ id: 'bob@cozy.cc', email: 'bob@cozy.cc' },
|
|
29
|
+
{ id: 'ALICE@cozy.cc', email: 'ALICE@cozy.cc' }
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
expect(resolveKnownMembers(typed, [alice])).toEqual([
|
|
33
|
+
alice,
|
|
34
|
+
{ id: 'bob@cozy.cc', email: 'bob@cozy.cc' }
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('makeMembersFromValue', () => {
|
|
40
|
+
const getMembersFromText = text =>
|
|
41
|
+
text.split(',').map(email => ({ id: email.trim() }))
|
|
42
|
+
|
|
43
|
+
it('turns the typed texts into members and removes the duplicates', () => {
|
|
44
|
+
const value = [{ id: 'alice@cozy.cc' }, 'bob@cozy.cc, alice@cozy.cc']
|
|
45
|
+
|
|
46
|
+
expect(makeMembersFromValue(value, getMembersFromText)).toEqual([
|
|
47
|
+
{ id: 'alice@cozy.cc' },
|
|
48
|
+
{ id: 'bob@cozy.cc' }
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
})
|