From 5fd447c32a034fe626326c1942524367d90d1bb1 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Thu, 7 May 2020 12:44:10 +0200 Subject: [PATCH] feat: search as you type for user/groups WIP --- .../widgets/src/SharingDialog/AccessSelect.js | 8 +- .../Autocomplete/Autocomplete.js | 110 +++++++++++++++++ .../Autocomplete/InputWrapper.js | 114 ++++++++++++++++++ .../SharingDialog/Autocomplete/MenuWrapper.js | 57 +++++++++ .../widgets/src/SharingDialog/ShareBlock.js | 97 +++++++++++++-- .../src/SharingDialog/SharingDialog.js | 10 +- .../SharingDialog/SharingDialog.stories.js | 52 +++++++- .../widgets/src/SharingDialog/SharingList.js | 47 +++++--- .../src/SharingDialog/SharingListItem.js | 2 +- packages/widgets/src/SharingDialog/helpers.js | 15 +++ .../src/SharingDialog/sharingConstants.js | 63 +++++----- 11 files changed, 511 insertions(+), 64 deletions(-) create mode 100644 packages/widgets/src/SharingDialog/Autocomplete/Autocomplete.js create mode 100644 packages/widgets/src/SharingDialog/Autocomplete/InputWrapper.js create mode 100644 packages/widgets/src/SharingDialog/Autocomplete/MenuWrapper.js create mode 100644 packages/widgets/src/SharingDialog/helpers.js diff --git a/packages/widgets/src/SharingDialog/AccessSelect.js b/packages/widgets/src/SharingDialog/AccessSelect.js index 4affadf073..7ff595eebd 100644 --- a/packages/widgets/src/SharingDialog/AccessSelect.js +++ b/packages/widgets/src/SharingDialog/AccessSelect.js @@ -19,14 +19,18 @@ export const AccessSelect = ({ access, onChange, showNoAccessOption }) => ( {Object.entries(accessStrings).map( ([value, strings]) => (value !== ACCESS_NONE || showNoAccessOption) && ( - + ) )} ) AccessSelect.propTypes = { - access: PropTypes.object, + access: PropTypes.string, showNoAccessOption: PropTypes.bool, onChange: PropTypes.func, } diff --git a/packages/widgets/src/SharingDialog/Autocomplete/Autocomplete.js b/packages/widgets/src/SharingDialog/Autocomplete/Autocomplete.js new file mode 100644 index 0000000000..f7e614468a --- /dev/null +++ b/packages/widgets/src/SharingDialog/Autocomplete/Autocomplete.js @@ -0,0 +1,110 @@ +import React, { createRef, useState, useEffect } from 'react' +import propTypes from '@dhis2/prop-types' + +import { Menu, MenuItem } from '@dhis2/ui-core' +import { InputField } from '../../' +import { MenuWrapper } from './MenuWrapper' + +// Keycodes for the keypress event handlers +// XXX implement keyboard navigation in the Menu ?! +/*const ESCAPE_KEY = 27 +const SPACE_KEY = 32 +const UP_KEY = 38 +const DOWN_KEY = 40 +*/ + +// XXX pass this whole component or the one that renders the MenuItem +// from the app/parent to make it as flexible as possible +const SearchResults = ({ searchResults, onClick }) => ( + + {searchResults.map(searchResult => ( + + ))} + +) + +SearchResults.propTypes = { + searchResults: propTypes.array, + onClick: propTypes.func, +} + +export const Autocomplete = ({ + placeholder, + onChange, + onClose, + onSearch, + dataTest, + maxHeight, + inputWidth, + value, + searchResults, +}) => { + const inputRef = createRef() + const menuRef = createRef() + + const [menuWidth, setMenuWidth] = useState('auto') + + useEffect(() => { + if (inputRef.current) { + console.log('set menu width', inputRef.current.offsetWidth) + setMenuWidth(`${inputRef.current.offsetWidth}px`) + } + }, []) + + // TODO debounce + const onInputChange = ({ value }) => { + onSearch(value) + } + + const onSelect = ({ value }) => { + onChange(value) + } + + return ( +
+
+ +
+ {Boolean(searchResults.length) && ( + + + + )} +
+ ) +} + +Autocomplete.defaultProps = { + dataTest: 'dhis2-uicore-select', +} + +Autocomplete.propTypes = { + dataTest: propTypes.string, + inputWidth: propTypes.number, + maxHeight: propTypes.string, + placeholder: propTypes.string, + searchResults: propTypes.array, + value: propTypes.string, + onChange: propTypes.func, + onClose: propTypes.func, + onSearch: propTypes.func, +} diff --git a/packages/widgets/src/SharingDialog/Autocomplete/InputWrapper.js b/packages/widgets/src/SharingDialog/Autocomplete/InputWrapper.js new file mode 100644 index 0000000000..2cfaad96dd --- /dev/null +++ b/packages/widgets/src/SharingDialog/Autocomplete/InputWrapper.js @@ -0,0 +1,114 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' +import { ArrowDown } from './ArrowDown.js' +import { colors, theme, sharedPropTypes } from '@dhis2/ui-constants' + +const InputWrapper = ({ + dataTest, + onToggle, + children, + tabIndex, + error, + warning, + valid, + disabled, + dense, + className, + inputRef, +}) => { + const classNames = cx(className, 'root', { + error, + warning, + valid, + disabled, + dense, + }) + + return ( +
+
{children}
+
+ +
+ + +
+ ) +} + +InputWrapper.defaultProps = { + tabIndex: '0', +} + +InputWrapper.propTypes = { + dataTest: propTypes.string.isRequired, + inputRef: propTypes.object.isRequired, + tabIndex: propTypes.string.isRequired, + onToggle: propTypes.func.isRequired, + children: propTypes.element, + className: propTypes.string, + dense: propTypes.bool, + disabled: propTypes.bool, + error: sharedPropTypes.statusPropType, + valid: sharedPropTypes.statusPropType, + warning: sharedPropTypes.statusPropType, +} + +export { InputWrapper } diff --git a/packages/widgets/src/SharingDialog/Autocomplete/MenuWrapper.js b/packages/widgets/src/SharingDialog/Autocomplete/MenuWrapper.js new file mode 100644 index 0000000000..279a68bf6c --- /dev/null +++ b/packages/widgets/src/SharingDialog/Autocomplete/MenuWrapper.js @@ -0,0 +1,57 @@ +import React from 'react' +import { resolve } from 'styled-jsx/css' + +import propTypes from '@dhis2/prop-types' + +import { Card, Layer, Popper } from '@dhis2/ui-core' + +const MenuWrapper = ({ + children, + dataTest, + maxHeight, + menuWidth, + onClick, + menuRef, +}) => { + const { styles, className: cardClassName } = resolve` + height: auto; + max-height: ${maxHeight}; + overflow: auto; + ` + return ( + + +
+ {children} + + {styles} + + +
+
+
+ ) +} + +MenuWrapper.defaultProps = { + maxHeight: '280px', +} + +MenuWrapper.propTypes = { + dataTest: propTypes.string.isRequired, + menuRef: propTypes.object.isRequired, + menuWidth: propTypes.string.isRequired, + children: propTypes.node, + maxHeight: propTypes.string, + onClick: propTypes.func, +} + +export { MenuWrapper } diff --git a/packages/widgets/src/SharingDialog/ShareBlock.js b/packages/widgets/src/SharingDialog/ShareBlock.js index 1edabaffb0..47aac0bf6b 100644 --- a/packages/widgets/src/SharingDialog/ShareBlock.js +++ b/packages/widgets/src/SharingDialog/ShareBlock.js @@ -1,21 +1,97 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import PropTypes from '@dhis2/prop-types' import i18n from '@dhis2/d2-i18n' +import { useDataQuery } from '@dhis2/app-runtime' import { Button } from '@dhis2/ui-core' -import { InputField } from '../' +import { Autocomplete } from './Autocomplete/Autocomplete' import { AccessSelect } from './AccessSelect' import { shareBlockStyles } from './SharingDialog.styles' +import { debounce } from './helpers' + +const query = { + usersAndGroups: { + resource: 'sharing/search', + params: ({ search }) => ({ + key: search, + }), + }, +} + export const ShareBlock = ({ onAdd }) => { const [userOrGroup, setUserOrGroup] = useState(undefined) const [access, setAccess] = useState(undefined) + const [usersAndGroups, setUsersAndGroups] = useState({}) + const [searchResults, setSearchResults] = useState([]) + + const { data, error, refetch } = useDataQuery(query, { + lazy: true, + }) + + const addType = type => result => ({ ...result, type }) + + useEffect(() => { + if (data) { + setSearchResults( + data.usersAndGroups.users.concat(data.usersAndGroups.userGroups) + ) + + setUsersAndGroups( + data.usersAndGroups.users + .map(addType('user')) + .concat( + data.usersAndGroups.userGroups.map(addType('group')) + ) + .reduce((result, obj) => { + result[obj.id] = obj + + return result + }, {}) + ) + } + }, [data]) + + const fetchData = debounce(text => { + console.log('text', text) + refetch({ search: text }) + }, 500) + + const onSearch = text => { + setUserOrGroup({ name: text }) + + if (text.length) { + fetchData(text) + } else { + setSearchResults([]) + } + + if (error) { + console.log('onSearch error', error) + } + } + + const onClose = () => { + setSearchResults([]) + setUsersAndGroups({}) + } + + const onChange = id => { + setUserOrGroup(usersAndGroups[id]) + + onClose() + } const onSubmit = e => { e.preventDefault() - onAdd({ type: 'user', id: userOrGroup, access }) + onAdd({ + type: userOrGroup.type, + id: userOrGroup.id, + name: userOrGroup.displayName || userOrGroup.name, + access, + }) setUserOrGroup(undefined) setAccess(undefined) } @@ -25,16 +101,23 @@ export const ShareBlock = ({ onAdd }) => {

{i18n.t('Share with users and groups')}

- setUserOrGroup(value)} + value={userOrGroup?.name} + searchResults={searchResults} + onClose={onClose} + onChange={onChange} + onSearch={onSearch} />
- diff --git a/packages/widgets/src/SharingDialog/SharingDialog.js b/packages/widgets/src/SharingDialog/SharingDialog.js index dcf00cf18a..feef420ddf 100644 --- a/packages/widgets/src/SharingDialog/SharingDialog.js +++ b/packages/widgets/src/SharingDialog/SharingDialog.js @@ -26,13 +26,17 @@ export const SharingDialog = ({ initialSharingSettings ) - const addUserOrGroupAccess = ({ type, id, access }) => { - console.log(type, id, access) + const addUserOrGroupAccess = ({ type, id, name, access }) => { + console.log(type, id, name, access) updateSharingSettings({ ...sharingSettings, [`${type}s`]: { ...sharingSettings[`${type}s`], - [id]: access, + [id]: { + id, + name, + access, + }, }, }) } diff --git a/packages/widgets/src/SharingDialog/SharingDialog.stories.js b/packages/widgets/src/SharingDialog/SharingDialog.stories.js index bf0c79d2f3..8fdf7198c0 100644 --- a/packages/widgets/src/SharingDialog/SharingDialog.stories.js +++ b/packages/widgets/src/SharingDialog/SharingDialog.stories.js @@ -1,5 +1,6 @@ import React from 'react' import { storiesOf } from '@storybook/react' +import { CustomDataProvider } from '@dhis2/app-runtime' import { SharingDialog } from '../index.js' @@ -7,10 +8,53 @@ window.onChange = window.Cypress && window.Cypress.cy.stub() window.onFocus = window.Cypress && window.Cypress.cy.stub() window.onBlur = window.Cypress && window.Cypress.cy.stub() -storiesOf('Component/Widget/SharingDialog', module) +const customData = { + 'sharing/search': { + userGroups: [ + { + id: 'wl5cDMuUhmF', + name: 'Administrators', + displayName: 'Administrators', + }, + { + id: 'lFHP5lLkzVr', + name: 'System administrators', + displayName: 'System administrators', + }, + { + id: 'zz6XckBrLlj', + name: '_DATASET_System administrator (ALL)', + displayName: '_DATASET_System administrator (ALL)', + }, + { + id: 'vRoAruMnNpB', + name: '_PROGRAM_MNCH / PNC (Adult Woman) program', + displayName: '_PROGRAM_MNCH / PNC (Adult Woman) program', + }, + { + id: 'pBnkuih0c1K', + name: '_PROGRAM_System administrator (ALL)', + displayName: '_PROGRAM_System administrator (ALL)', + }, + ], + users: [ + { + id: 'xE7jOejl9FI', + name: 'John Traore', + displayName: 'John Traore', + }, + ], + }, +} + +storiesOf('Component/Connected/SharingDialog', module) .add('Simple', () => ( - + + + )) .add('With name', () => ( - - )) \ No newline at end of file + + + + )) diff --git a/packages/widgets/src/SharingDialog/SharingList.js b/packages/widgets/src/SharingDialog/SharingList.js index 9735eff4d4..81595027fe 100644 --- a/packages/widgets/src/SharingDialog/SharingList.js +++ b/packages/widgets/src/SharingDialog/SharingList.js @@ -34,11 +34,11 @@ export const SharingList = ({ sharingSettings, onChange }) => ( onChange({ ...sharingSettings, public: access }) } /> - {Object.entries(sharingSettings.groups).map( - ([group, access]) => ( + {Object.values(sharingSettings.groups).map( + ({ id, name, access }) => ( @@ -46,18 +46,29 @@ export const SharingList = ({ sharingSettings, onChange }) => ( ...sharingSettings, groups: { ...sharingSettings.groups, - [group]: newAccess, + [id]: { + ...sharingSettings.groups[id], + access: newAccess, + }, }, }) } + onRemove={() => { + const updatedSharingSettings = { + ...sharingSettings, + } + delete updatedSharingSettings.groups[id] + onChange(updatedSharingSettings) + }} /> ) )} - {Object.entries(sharingSettings.users).map( - ([user, access]) => + {Object.values(sharingSettings.users).map( + ({ id, name, access }) => access && ( @@ -65,19 +76,21 @@ export const SharingList = ({ sharingSettings, onChange }) => ( ...sharingSettings, users: { ...sharingSettings.users, - [user]: newAccess, + [id]: { + ...sharingSettings.users[id], + access: newAccess, + }, }, }) } - onRemove={() => - onChange({ + onRemove={() => { + const updatedSharingSettings = { ...sharingSettings, - users: { - ...sharingSettings.users, - [user]: undefined, - }, - }) - } + } + delete updatedSharingSettings.users[id] + + onChange(updatedSharingSettings) + }} /> ) )} diff --git a/packages/widgets/src/SharingDialog/SharingListItem.js b/packages/widgets/src/SharingDialog/SharingListItem.js index 34cb8d55eb..49d0c86bfd 100644 --- a/packages/widgets/src/SharingDialog/SharingListItem.js +++ b/packages/widgets/src/SharingDialog/SharingListItem.js @@ -49,7 +49,7 @@ export const SharingListItem = ({ ) SharingListItem.propTypes = { - access: PropTypes.object, + access: PropTypes.string, name: PropTypes.string, target: PropTypes.string, onChange: PropTypes.func, diff --git a/packages/widgets/src/SharingDialog/helpers.js b/packages/widgets/src/SharingDialog/helpers.js new file mode 100644 index 0000000000..f949a162e3 --- /dev/null +++ b/packages/widgets/src/SharingDialog/helpers.js @@ -0,0 +1,15 @@ +export const debounce = function(f, ms) { + let timeout + + return function(...args) { + console.log('args', args, timeout) + if (timeout) { + clearTimeout(timeout) + } + timeout = setTimeout(function() { + console.log('call and reset') + timeout = undefined + f(...args) + }, ms) + } +} diff --git a/packages/widgets/src/SharingDialog/sharingConstants.js b/packages/widgets/src/SharingDialog/sharingConstants.js index 7781a02724..c11bf54bf7 100644 --- a/packages/widgets/src/SharingDialog/sharingConstants.js +++ b/packages/widgets/src/SharingDialog/sharingConstants.js @@ -1,26 +1,26 @@ -import i18n from "@dhis2/d2-i18n"; +import i18n from '@dhis2/d2-i18n' -export const ACCESS_NONE = "ACCESS_NONE"; -export const ACCESS_VIEW_ONLY = "ACCESS_VIEW_ONLY"; -export const ACCESS_VIEW_AND_EDIT = "ACCESS_VIEW_AND_EDIT"; +export const ACCESS_NONE = 'ACCESS_NONE' +export const ACCESS_VIEW_ONLY = 'ACCESS_VIEW_ONLY' +export const ACCESS_VIEW_AND_EDIT = 'ACCESS_VIEW_AND_EDIT' const noAccess = i18n.t('No access') export const accessStrings = { - [ACCESS_NONE]: { - publicDescription: noAccess, - description: noAccess, - option: noAccess - }, - [ACCESS_VIEW_ONLY]: { - publicDescription: i18n.t('Anyone logged in can find and view'), - description: i18n.t('Can find and view'), - option: i18n.t('View only') - }, - [ACCESS_VIEW_AND_EDIT]: { - publicDescription: i18n.t('Anyone logged in can find, edit, and view'), - description: i18n.t('Can find, edit, and view'), - option: i18n.t('Edit and view') - } + [ACCESS_NONE]: { + publicDescription: noAccess, + description: noAccess, + option: noAccess, + }, + [ACCESS_VIEW_ONLY]: { + publicDescription: i18n.t('Anyone logged in can find and view'), + description: i18n.t('Can find and view'), + option: i18n.t('View only'), + }, + [ACCESS_VIEW_AND_EDIT]: { + publicDescription: i18n.t('Anyone logged in can find, edit, and view'), + description: i18n.t('Can find, edit, and view'), + option: i18n.t('Edit and view'), + }, } export const SHARE_TARGET_EXTERNAL = 'SHARE_TARGET_EXTERNAL' @@ -28,25 +28,28 @@ export const SHARE_TARGET_PUBLIC = 'SHARE_TARGET_PUBLIC' export const SHARE_TARGET_USER = 'SHARE_TARGET_USER' export const SHARE_TARGET_GROUP = 'SHARE_TARGET_GROUP' -export const isPermanentTarget = target => [SHARE_TARGET_EXTERNAL, SHARE_TARGET_PUBLIC].includes(target) +export const isPermanentTarget = target => + [SHARE_TARGET_EXTERNAL, SHARE_TARGET_PUBLIC].includes(target) export const defaultSharingSettings = { external: ACCESS_NONE, public: ACCESS_VIEW_ONLY, - groups: [], - users: [] + groups: {}, + users: {}, } export const sharingSettingsAreEqual = (a, b) => { - const aGroups = Object.entries(a.groups).filter(([_, access]) => !!access) - const aUsers = Object.entries(a.users).filter(([_, access]) => !!access) - const bGroups = Object.entries(b.groups).filter(([_, access]) => !!access) - const bUsers = Object.entries(b.users).filter(([_, access]) => !!access) - - return a.external === b.external && + const aGroups = Object.values(a.groups).filter(({ access }) => !!access) + const aUsers = Object.values(a.users).filter(({ access }) => !!access) + const bGroups = Object.values(b.groups).filter(({ access }) => !!access) + const bUsers = Object.values(b.users).filter(({ access }) => !!access) + console.log('a U', aUsers, 'b U', bUsers, 'a G', aGroups, 'b G', bGroups) + return ( + a.external === b.external && a.public === b.public && aGroups.length === bGroups.length && - aGroups.every(([id, access]) => b.groups[id] === access) && + aGroups.every(({ id, access }) => b.groups[id] === access) && aUsers.length === bUsers.length && - aUsers.every(([id, access]) => b.users[id] === access) + aUsers.every(({ id, access }) => b.users[id] === access) + ) }