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.
Files changed (66) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +1 -1
  3. package/react/DropdownText/index.jsx +3 -5
  4. package/react/MemberAutocomplete/MemberAutocomplete.spec.jsx +83 -0
  5. package/react/MemberAutocomplete/MemberAutocompleteActions.jsx +77 -0
  6. package/react/MemberAutocomplete/MemberChip.jsx +29 -0
  7. package/react/MemberAutocomplete/MemberSuggestion.jsx +32 -0
  8. package/react/MemberAutocomplete/Readme.md +65 -0
  9. package/react/MemberAutocomplete/helpers.js +70 -0
  10. package/react/MemberAutocomplete/helpers.spec.js +51 -0
  11. package/react/MemberAutocomplete/index.jsx +247 -0
  12. package/react/MemberAutocomplete/locales/de.json +5 -0
  13. package/react/MemberAutocomplete/locales/en.json +5 -0
  14. package/react/MemberAutocomplete/locales/es.json +5 -0
  15. package/react/MemberAutocomplete/locales/fr.json +5 -0
  16. package/react/MemberAutocomplete/locales/index.js +17 -0
  17. package/react/MemberAutocomplete/locales/it.json +5 -0
  18. package/react/MemberAutocomplete/locales/ru.json +5 -0
  19. package/react/MemberAutocomplete/locales/vi.json +5 -0
  20. package/react/MemberAutocomplete/useMemberInput.js +80 -0
  21. package/react/MemberAutocomplete/useMemberSuggestions.js +65 -0
  22. package/react/MemberAutocomplete/useMemberSuggestions.spec.js +97 -0
  23. package/react/MemberAutocomplete/useRoleMenuFocus.js +35 -0
  24. package/react/MemberRow/MemberOrGroupAvatar.jsx +21 -0
  25. package/react/MemberRow/MemberRoleMenu.jsx +29 -4
  26. package/react/MemberRow/MemberRoleMenuHeader.jsx +3 -8
  27. package/react/MemberRow/Readme.md +5 -4
  28. package/react/MemberRow/index.jsx +3 -19
  29. package/react/MemberRow/propTypes.js +1 -0
  30. package/react/MuiCozyTheme/overrides/makeDarkOverrides.js +5 -0
  31. package/transpiled/react/DropdownText/index.js +3 -3
  32. package/transpiled/react/MemberAutocomplete/MemberAutocomplete.spec.d.ts +1 -0
  33. package/transpiled/react/MemberAutocomplete/MemberAutocompleteActions.d.ts +25 -0
  34. package/transpiled/react/MemberAutocomplete/MemberAutocompleteActions.js +69 -0
  35. package/transpiled/react/MemberAutocomplete/MemberChip.d.ts +39 -0
  36. package/transpiled/react/MemberAutocomplete/MemberChip.js +33 -0
  37. package/transpiled/react/MemberAutocomplete/MemberSuggestion.d.ts +36 -0
  38. package/transpiled/react/MemberAutocomplete/MemberSuggestion.js +31 -0
  39. package/transpiled/react/MemberAutocomplete/helpers.d.ts +7 -0
  40. package/transpiled/react/MemberAutocomplete/helpers.js +73 -0
  41. package/transpiled/react/MemberAutocomplete/helpers.spec.d.ts +1 -0
  42. package/transpiled/react/MemberAutocomplete/index.d.ts +71 -0
  43. package/transpiled/react/MemberAutocomplete/index.js +295 -0
  44. package/transpiled/react/MemberAutocomplete/locales/index.d.ts +16 -0
  45. package/transpiled/react/MemberAutocomplete/locales/index.js +44 -0
  46. package/transpiled/react/MemberAutocomplete/useMemberInput.d.ts +14 -0
  47. package/transpiled/react/MemberAutocomplete/useMemberInput.js +76 -0
  48. package/transpiled/react/MemberAutocomplete/useMemberSuggestions.d.ts +11 -0
  49. package/transpiled/react/MemberAutocomplete/useMemberSuggestions.js +120 -0
  50. package/transpiled/react/MemberAutocomplete/useMemberSuggestions.spec.d.ts +1 -0
  51. package/transpiled/react/MemberAutocomplete/useRoleMenuFocus.d.ts +6 -0
  52. package/transpiled/react/MemberAutocomplete/useRoleMenuFocus.js +37 -0
  53. package/transpiled/react/MemberList/index.d.ts +1 -0
  54. package/transpiled/react/MemberRow/MemberOrGroupAvatar.d.ts +39 -0
  55. package/transpiled/react/MemberRow/MemberOrGroupAvatar.js +29 -0
  56. package/transpiled/react/MemberRow/MemberRoleMenu.d.ts +9 -1
  57. package/transpiled/react/MemberRow/MemberRoleMenu.js +26 -9
  58. package/transpiled/react/MemberRow/MemberRoleMenuHeader.d.ts +1 -0
  59. package/transpiled/react/MemberRow/MemberRoleMenuHeader.js +4 -13
  60. package/transpiled/react/MemberRow/MemberRowActions.d.ts +1 -0
  61. package/transpiled/react/MemberRow/index.d.ts +1 -10
  62. package/transpiled/react/MemberRow/index.js +4 -13
  63. package/transpiled/react/MemberRow/propTypes.d.ts +1 -0
  64. package/transpiled/react/MemberRow/propTypes.js +1 -0
  65. package/transpiled/react/MuiCozyTheme/overrides/makeDarkOverrides.d.ts +5 -0
  66. package/transpiled/react/MuiCozyTheme/overrides/makeDarkOverrides.js +5 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ # [146.3.0](https://github.com/cozy/cozy-ui/compare/v146.2.2...v146.3.0) (2026-11-03)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **DropdownText:** Restore the colors once enabled again ([8d28332](https://github.com/cozy/cozy-ui/commit/8d28332))
7
+
8
+
9
+ ### Features
10
+
11
+ * **MemberAutocomplete:** Add the field to pick the members to invite ([ee0250b](https://github.com/cozy/cozy-ui/commit/ee0250b))
12
+ * **MemberRoleMenu:** Add the size, disabled, onOpen and onClose props ([5306f72](https://github.com/cozy/cozy-ui/commit/5306f72))
13
+
14
+ ## [146.2.2](https://github.com/cozy/cozy-ui/compare/v146.2.1...v146.2.2) (2026-11-02)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **MuiCozyTheme:** Readable label on a primary Fab in dark mode ([804e3d6](https://github.com/cozy/cozy-ui/commit/804e3d6))
20
+
1
21
  ## [146.2.1](https://github.com/cozy/cozy-ui/compare/v146.2.0...v146.2.1) (2026-11-02)
2
22
 
3
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-ui",
3
- "version": "146.2.1",
3
+ "version": "146.3.0",
4
4
  "description": "Cozy apps UI SDK",
5
5
  "engines": {
6
6
  "node": "24.x"
@@ -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' : undefined),
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,83 @@
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
+ })
@@ -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
+ })