diff --git a/.gitignore b/.gitignore index 3f78ed6b9..679f0afa4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.iml .idea +.vscode node_modules*/ .sass-cache diff --git a/package.json b/package.json index 77cb9409e..3e2f41a79 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "react-stub-context": "^0.7.0", "react-tap-event-plugin": "^2.0.1", "react-test-renderer": "^15.6.0", + "recompose": "^0.26.0", "rxjs": "^5.4.3", "sass-loader": "^6.0.2", "style-loader": "^0.18.2", diff --git a/src/sharing/Access.component.js b/src/sharing/Access.component.js new file mode 100644 index 000000000..f74c68cf8 --- /dev/null +++ b/src/sharing/Access.component.js @@ -0,0 +1,170 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { compose, mapProps, getContext, withProps } from 'recompose'; +import FontIcon from 'material-ui/FontIcon'; +import { config } from 'd2/lib/d2'; +import IconButton from 'material-ui/IconButton'; + +import PermissionPicker from './PermissionPicker.component'; + +import { + accessStringToObject, + accessObjectToString, +} from './utils'; + +config.i18n.strings.add('public_access'); +config.i18n.strings.add('external_access'); +config.i18n.strings.add('anyone_can_view_without_a_login'); +config.i18n.strings.add('anyone_can_find_view_and_edit'); +config.i18n.strings.add('anyone_can_find_and_view'); +config.i18n.strings.add('no_access'); + +const styles = { + accessView: { + fontWeight: '400', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: '4px 8px', + }, + accessDescription: { + display: 'flex', + flexDirection: 'column', + flex: 1, + paddingLeft: 16, + }, +}; + +const d2Context = { + d2: PropTypes.object.isRequired, +}; + +const getAccessIcon = (userType) => { + switch (userType) { + case 'user': return 'person'; + case 'userGroup': return 'group'; + case 'external': return 'public'; + case 'public': return 'business'; + default: return 'person'; + } +}; + +const useAccessObjectFormat = props => ({ + ...props, + access: accessStringToObject(props.access), + onChange: (newAccess) => { + props.onChange(accessObjectToString(newAccess)); + }, +}); + +export const Access = ({ + access, + accessType, + accessOptions, + primaryText, + secondaryText, + onChange, + onRemove, + disabled, +}) => ( +
+ + {getAccessIcon(accessType)} + +
+
{primaryText}
+
{secondaryText || ' '}
+
+ + + + {})} + >clear +
+); + +Access.contextTypes = d2Context; + +Access.propTypes = { + access: PropTypes.object.isRequired, + accessType: PropTypes.string.isRequired, + accessOptions: PropTypes.object.isRequired, + primaryText: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool, + secondaryText: PropTypes.string, + onRemove: PropTypes.func, +}; + +Access.defaultProps = { + secondaryText: undefined, + onRemove: undefined, + disabled: false, +}; + +export const GroupAccess = compose( + mapProps(useAccessObjectFormat), + withProps(props => ({ + accessType: props.groupType, + primaryText: props.groupName, + accessOptions: { + meta: { canView: true, canEdit: true, noAccess: false }, + data: props.dataShareable && { canView: true, canEdit: true, noAccess: true }, + }, + })), +)(Access); + +export const ExternalAccess = compose( + getContext(d2Context), + withProps(props => ({ + accessType: 'external', + primaryText: props.d2.i18n.getTranslation('external_access'), + secondaryText: props.access + ? props.d2.i18n.getTranslation('anyone_can_view_without_a_login') + : props.d2.i18n.getTranslation('no_access'), + access: { + meta: { canEdit: false, canView: props.access }, + data: { canEdit: false, canView: false }, + }, + onChange: (newAccess) => { + props.onChange(newAccess.meta.canView); + }, + accessOptions: { + meta: { canView: true, canEdit: false, noAccess: true }, + }, + })), +)(Access); + +const constructSecondaryText = ({ canView, canEdit }) => { + if (canEdit) { + return 'anyone_can_find_view_and_edit'; + } + + return canView + ? 'anyone_can_find_and_view' + : 'no_access'; +}; + +export const PublicAccess = compose( + mapProps(useAccessObjectFormat), + getContext(d2Context), + withProps(props => ({ + accessType: 'public', + primaryText: props.d2.i18n.getTranslation('public_access'), + secondaryText: props.d2.i18n.getTranslation(constructSecondaryText(props.access.meta)), + accessOptions: { + meta: { canView: true, canEdit: true, noAccess: true }, + data: props.dataShareable && { canView: true, canEdit: true, noAccess: true }, + }, + })), +)(Access); diff --git a/src/sharing/CreatedBy.component.js b/src/sharing/CreatedBy.component.js index fa1ca1d7c..f8ddcae5e 100644 --- a/src/sharing/CreatedBy.component.js +++ b/src/sharing/CreatedBy.component.js @@ -5,18 +5,16 @@ import { config } from 'd2/lib/d2'; config.i18n.strings.add('created_by'); config.i18n.strings.add('no_author'); -const CreatedBy = ({ user }, context) => { - const createdByText = user && user.name ? - `${context.d2.i18n.getTranslation('created_by')}: ${user.name}` : +const CreatedBy = ({ author }, context) => { + const createdByText = author ? + `${context.d2.i18n.getTranslation('created_by')}: ${author.name}` : context.d2.i18n.getTranslation('no_author'); return
{createdByText}
; }; CreatedBy.propTypes = { - user: PropTypes.shape({ - name: PropTypes.string, - }).isRequired, + author: PropTypes.object.isRequired, }; CreatedBy.contextTypes = { diff --git a/src/sharing/ExternalAccess.component.js b/src/sharing/ExternalAccess.component.js deleted file mode 100644 index d0dc3ea7a..000000000 --- a/src/sharing/ExternalAccess.component.js +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint react/jsx-no-bind: 0 */ - -import PropTypes from 'prop-types'; -import React from 'react'; -import { config } from 'd2/lib/d2'; - -import Rule from './Rule.component'; - -config.i18n.strings.add('external_access'); -config.i18n.strings.add('anyone_can_view_without_a_login'); -config.i18n.strings.add('no_access'); - -const ExternalAccess = ({ canView, disabled, onChange }, context) => ( - -); - -ExternalAccess.propTypes = { - canView: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - disabled: PropTypes.bool, -}; - -ExternalAccess.contextTypes = { - d2: PropTypes.object.isRequired, -}; - -export default ExternalAccess; diff --git a/src/sharing/PermissionOption.component.js b/src/sharing/PermissionOption.component.js new file mode 100644 index 000000000..8f49a75a4 --- /dev/null +++ b/src/sharing/PermissionOption.component.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FontIcon from 'material-ui/FontIcon'; +import MenuItem from 'material-ui/MenuItem'; + +class PermissionOption extends Component { + ref = null; + + render = () => { + if (this.props.disabled) { + return null; + } + + return ( + + {this.props.isSelected ? 'done' : ''} + + } + primaryText={this.props.primaryText} + value={this.props.value} + disabled={this.props.disabled} + onClick={this.props.onClick} + focusState={this.props.focusState} + /> + ); + } +} + +PermissionOption.propTypes = { + disabled: PropTypes.bool.isRequired, + isSelected: PropTypes.bool.isRequired, + primaryText: PropTypes.string.isRequired, + value: PropTypes.object.isRequired, + onClick: PropTypes.func, + focusState: PropTypes.string, +}; + +PermissionOption.defaultProps = { + onClick: undefined, + focusState: 'none', +}; + +PermissionOption.muiName = 'MenuItem'; +export default PermissionOption; diff --git a/src/sharing/PermissionPicker.component.js b/src/sharing/PermissionPicker.component.js index a88199a83..9867e2f79 100644 --- a/src/sharing/PermissionPicker.component.js +++ b/src/sharing/PermissionPicker.component.js @@ -1,77 +1,165 @@ /* eslint react/jsx-no-bind: 0 */ import PropTypes from 'prop-types'; -import React from 'react'; -import FontIcon from 'material-ui/FontIcon'; -import MenuItem from 'material-ui/MenuItem'; -import IconMenu from 'material-ui/IconMenu'; +import React, { Component } from 'react'; import IconButton from 'material-ui/IconButton'; +import Divider from 'material-ui/Divider'; +import Popover from 'material-ui/Popover'; +import Menu from 'material-ui/Menu'; import { config } from 'd2/lib/d2'; +import PermissionOption from './PermissionOption.component'; + config.i18n.strings.add('can_edit_and_view'); +config.i18n.strings.add('can_capture_data'); +config.i18n.strings.add('can_view_data'); config.i18n.strings.add('can_view_only'); config.i18n.strings.add('no_access'); -function getAccessIcon(accessOptions) { - if (accessOptions.canView) { - return accessOptions.canEdit ? 'create' : 'remove_red_eye'; +const styles = { + optionHeader: { + paddingLeft: 16, + paddingTop: 16, + fontWeight: '400', + color: 'gray', + }, +}; + +const getAccessIcon = (metaAccess) => { + if (metaAccess.canEdit) { + return 'create'; } - return 'not_interested'; -} + return metaAccess.canView + ? 'remove_red_eye' + : 'not_interested'; +}; -function createMenuItem(text, canView, canEdit, isSelected, disabled) { - return !disabled && ( - - {isSelected ? 'done' : ''} - - } - /> - ); -} +class PermissionPicker extends Component { + state = { + open: false, + }; -const PermissionPicker = ({ accessOptions, onChange, disabled, disableWritePermission, disableNoAccess }, context) => ( - onChange(value)} - iconButtonElement={ - - {getAccessIcon(accessOptions)} - - } - > - { createMenuItem(context.d2.i18n.getTranslation('can_edit_and_view'), - true, true, accessOptions.canEdit, disableWritePermission) } - { createMenuItem(context.d2.i18n.getTranslation('can_view_only'), - true, false, accessOptions.canView && !accessOptions.canEdit) } - { createMenuItem(context.d2.i18n.getTranslation('no_access'), - false, false, !accessOptions.canView && !accessOptions.canEdit, disableNoAccess) } - -); + onOptionClick = (event, menuItem) => { + const newAccess = { + ...this.props.access, + ...menuItem.props.value, + }; + + this.props.onChange(newAccess); + } + + openMenu = (event) => { + event.preventDefault(); + this.setState({ + open: true, + anchor: event.currentTarget, + }); + } + + closeMenu = () => { + this.setState({ + open: false, + }); + } + + translate = s => this.context.d2.i18n.getTranslation(s); + + render = () => { + const { data, meta } = this.props.access; + const { data: dataOptions, meta: metaOptions } = this.props.accessOptions; + + return ( +
+ + {getAccessIcon(meta)} + + + + + + + + + + + { dataOptions && +
+ + + + + + +
+ } +
+
+ ); + } +} PermissionPicker.propTypes = { - accessOptions: PropTypes.shape({ - canView: PropTypes.bool.isRequired, - canEdit: PropTypes.bool, - }).isRequired, + access: PropTypes.object.isRequired, + accessOptions: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, disabled: PropTypes.bool, - disableWritePermission: PropTypes.bool, - disableNoAccess: PropTypes.bool, +}; + +PermissionPicker.defaultProps = { + disabled: false, }; PermissionPicker.contextTypes = { d2: PropTypes.object.isRequired, }; +const OptionHeader = ({ text }) => ( +
+ {text.toUpperCase()} +
+); + +OptionHeader.propTypes = { + text: PropTypes.string.isRequired, +}; + export default PermissionPicker; diff --git a/src/sharing/PublicAccess.component.js b/src/sharing/PublicAccess.component.js deleted file mode 100644 index 736a673fb..000000000 --- a/src/sharing/PublicAccess.component.js +++ /dev/null @@ -1,44 +0,0 @@ -/* eslint react/jsx-no-bind: 0 */ - -import PropTypes from 'prop-types'; -import React from 'react'; -import { config } from 'd2/lib/d2'; -import Rule from './Rule.component'; - -config.i18n.strings.add('public_access'); -config.i18n.strings.add('anyone_can_find_and_view'); -config.i18n.strings.add('anyone_can_find_view_and_edit'); - -function constructSecondaryText(canView, canEdit, context) { - if (canView) { - return canEdit - ? context.d2.i18n.getTranslation('anyone_can_find_view_and_edit') - : context.d2.i18n.getTranslation('anyone_can_find_and_view'); - } - - return 'No access'; -} - -const PublicAccess = ({ canView, canEdit, disabled, onChange }, context) => ( - -); - -PublicAccess.propTypes = { - canView: PropTypes.bool.isRequired, - canEdit: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - disabled: PropTypes.bool, -}; - -PublicAccess.contextTypes = { - d2: PropTypes.object.isRequired, -}; - -export default PublicAccess; diff --git a/src/sharing/Rule.component.js b/src/sharing/Rule.component.js deleted file mode 100644 index 7d476a3d6..000000000 --- a/src/sharing/Rule.component.js +++ /dev/null @@ -1,77 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import FontIcon from 'material-ui/FontIcon'; -import IconButton from 'material-ui/IconButton'; - -import PermissionPicker from './PermissionPicker.component'; - -const styles = { - ruleView: { - fontWeight: '400', - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: '4px 8px', - }, - ruleDescription: { - display: 'flex', - flexDirection: 'column', - flex: 1, - paddingLeft: 16, - }, -}; - -function getAccessIcon(userType) { - switch (userType) { - case 'user': return 'person'; - case 'userGroup': return 'group'; - case 'external': return 'public'; - case 'public': return 'business'; - default: return 'person'; - } -} - -const Rule = ({ - accessType, primaryText, secondaryText, accessOptions, onChange, onRemove, - disabled, disableWritePermission, disableNoAccess, -}) => ( -
- - {getAccessIcon(accessType)} - -
-
{primaryText}
-
{secondaryText || ' '}
-
- - - - {})} - >clear -
-); - -Rule.propTypes = { - accessType: PropTypes.oneOf(['user', 'userGroup', 'external', 'public']).isRequired, - primaryText: PropTypes.string.isRequired, - accessOptions: PropTypes.object.isRequired, - secondaryText: PropTypes.string, - onChange: PropTypes.func, - onRemove: PropTypes.func, - disabled: PropTypes.bool, - disableWritePermission: PropTypes.bool, - disableNoAccess: PropTypes.bool, -}; - -export default Rule; diff --git a/src/sharing/Sharing.component.js b/src/sharing/Sharing.component.js index 17ee7ebd9..2f3b02bc1 100644 --- a/src/sharing/Sharing.component.js +++ b/src/sharing/Sharing.component.js @@ -7,9 +7,11 @@ import Subheader from 'material-ui/Subheader'; import Heading from '../headings/Heading.component'; import UserSearch from './UserSearch.component'; import CreatedBy from './CreatedBy.component'; -import PublicAccess from './PublicAccess.component'; -import ExternalAccess from './ExternalAccess.component'; -import UserGroupAccess from './UserGroupAccess.component'; +import { + PublicAccess, + ExternalAccess, + GroupAccess, +} from './Access.component'; config.i18n.strings.add('who_has_access'); @@ -18,7 +20,7 @@ const styles = { color: '#818181', }, titleBodySpace: { - paddingTop: 50, + paddingTop: 30, }, rules: { height: '240px', @@ -31,98 +33,135 @@ const styles = { * preferences. */ class Sharing extends React.Component { - constructor(props) { - super(props); - this.accessList = null; + onAccessRuleChange = id => (accessRule) => { + const changeWithId = rule => (rule.id === id ? { ...rule, access: accessRule } : rule); + const userAccesses = (this.props.sharedObject.object.userAccesses || []).map(changeWithId); + const userGroupAccesses = (this.props.sharedObject.object.userGroupAccesses || []).map(changeWithId); + + this.props.onChange({ + userAccesses, + userGroupAccesses, + }); } - setAccessListRef = (ref) => { - this.accessList = ref; - } + onAccessRemove = accessOwnerId => () => { + const withoutId = accessOwner => accessOwner.id !== accessOwnerId; + const userAccesses = (this.props.sharedObject.object.userAccesses || []).filter(withoutId); + const userGroupAccesses = (this.props.sharedObject.object.userGroupAccesses || []).filter(withoutId); - publicAccessChanged = ({ canView, canEdit }) => { - this.props.onSharingChanged({ - publicCanView: canView, - publicCanEdit: canEdit, + this.props.onChange({ + userAccesses, + userGroupAccesses, }); } - externalAccessChanged = ({ canView }) => { - this.props.onSharingChanged({ isSharedExternally: canView }); + onPublicAccessChange = (publicAccess) => { + this.props.onChange({ + publicAccess, + }); } - accessRulesChanged = (id, canView, canEdit) => { - const accesses = [...this.props.accesses].map(accessRule => ( - accessRule.id === id ? { ...accessRule, canView, canEdit } : accessRule - )); + onExternalAccessChange = (externalAccess) => { + this.props.onChange({ + externalAccess, + }); + } - this.props.onSharingChanged({ accesses }); + setAccessListRef = (ref) => { + this.accessListRef = ref; } - addUserGroupAccess = (userGroup) => { - const accesses = [...this.props.accesses, userGroup]; - this.props.onSharingChanged({ accesses }, () => { - this.scrollAccessListToBottom(); - }); + accessListRef = null; + + addUserAccess = (userAccess) => { + const currentAccesses = this.props.sharedObject.object.userAccesses || []; + this.props.onChange({ + userAccesses: [...currentAccesses, userAccess], + }, this.scrollAccessListToBottom()); } - removeUserGroupAccess = (userGroupId) => { - const accesses = [...this.props.accesses].filter(userGroup => userGroup.id !== userGroupId); - this.props.onSharingChanged({ accesses }); + addUserGroupAccess = (userGroupAccess) => { + const currentAccesses = this.props.sharedObject.object.userGroupAccesses || []; + this.props.onChange({ + userGroupAccesses: [...currentAccesses, userGroupAccess], + }, this.scrollAccessListToBottom()); } scrollAccessListToBottom = () => { - this.accessList.scrollTop = this.accessList.scrollHeight; + this.accessListRef.scrollTop = this.accessListRef.scrollHeight; } render() { + const { + user, + displayName, + userAccesses, + userGroupAccesses, + publicAccess, + externalAccess, + } = this.props.sharedObject.object; + const { + allowPublicAccess, + allowExternalAccess, + } = this.props.sharedObject.meta; + + const accessIds = (userAccesses || []).map(access => access.id) + .concat((userGroupAccesses || []).map(access => access.id)); + return (
- - + +
{this.context.d2.i18n.getTranslation('who_has_access')}
- { this.props.accesses.map((accessRules, index) => - (
- { this.removeUserGroupAccess(accessRules.id); }} - - // eslint-disable-next-line - onChange={(newAccessRules) => { - this.accessRulesChanged(accessRules.id, newAccessRules.canView, - newAccessRules.canEdit); - }} + { userAccesses && userAccesses.map(access => ( +
+ + +
+ ))} + { userGroupAccesses && userGroupAccesses.map(access => ( +
+ -
), - )} +
+ ))}
-
); @@ -130,67 +169,21 @@ class Sharing extends React.Component { } Sharing.propTypes = { - - /** - * Author of the shared object. - */ - authorOfSharableItem: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - }).isRequired, - /** - * Display name of the shared object. + * The object to share */ - nameOfSharableItem: PropTypes.string.isRequired, + sharedObject: PropTypes.object.isRequired, - /** - * Is *true* if the public access options (publicCanView/publicCanEdit) - * can be changed - */ - canSetPublicAccess: PropTypes.bool.isRequired, - - /** - * Is *true* if the external access options (isSharedExternally) can be - * changed - */ - canSetExternalAccess: PropTypes.bool.isRequired, - - /** - * If *true*, the object can currently be found and viewed by all users of - * the DHIS instance. - */ - publicCanView: PropTypes.bool.isRequired, - - /** - * If *true*, the object can currently be found, viewed and changed by all - * users of the DHIS instance. - */ - publicCanEdit: PropTypes.bool.isRequired, - - /** - * If *true*, the object is shared outside of DHIS. - */ - isSharedExternally: PropTypes.bool.isRequired, - - /** - * A list of the access preferences of the sharable object. Each entry in - * the list consists of a type (user or userGroup), an id, a name and - * whether the user or group can view and/or edit the object. + /* + * If true, the object's data should have their own settings. */ - accesses: PropTypes.arrayOf(PropTypes.shape({ - type: PropTypes.oneOf(['user', 'userGroup']).isRequired, - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - canView: PropTypes.bool.isRequired, - canEdit: PropTypes.bool.isRequired, - })).isRequired, + dataShareable: PropTypes.bool.isRequired, /** * Function that takes an object containing updated sharing preferences and * an optional callback fired when the change was successfully posted. */ - onSharingChanged: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, /** * Takes a string and a callback, and returns matching users and userGroups. diff --git a/src/sharing/SharingDialog.component.js b/src/sharing/SharingDialog.component.js index 03ed9a37b..194ef8861 100644 --- a/src/sharing/SharingDialog.component.js +++ b/src/sharing/SharingDialog.component.js @@ -1,5 +1,3 @@ -/* eslint no-console: 0 */ - import { config, getInstance } from 'd2/lib/d2'; import Dialog from 'material-ui/Dialog/Dialog'; import FlatButton from 'material-ui/FlatButton/FlatButton'; @@ -13,64 +11,26 @@ config.i18n.strings.add('share'); config.i18n.strings.add('close'); config.i18n.strings.add('no_manage_access'); -function cachedAccessTypeToString(canView, canEdit) { - if (canView) { - return canEdit - ? 'rw------' - : 'r-------'; - } - - return '--------'; -} - -function transformAccessObject(access, type) { - return { - id: access.id, - name: access.name, - displayName: access.displayName, - type, - canView: access.access && access.access.includes('r'), - canEdit: access.access && access.access.includes('rw'), - }; -} - -function transformObjectStructure(apiMeta, apiObject) { - const userGroupAccesses = !apiObject.userGroupAccesses ? [] : apiObject.userGroupAccesses.map( - access => transformAccessObject(access, 'userGroup')); - - const userAccesses = !apiObject.userAccesses ? [] : apiObject.userAccesses.map( - access => transformAccessObject(access, 'user')); - - const combinedAccesses = userGroupAccesses.concat(userAccesses); - const authorOfSharableItem = apiObject.user && { - id: apiObject.user.id, - name: apiObject.user.name, - }; +const styles = { + loadingMask: { + position: 'relative', + }, +}; - return { - authorOfSharableItem, - nameOfSharableItem: apiObject.name, - canSetPublicAccess: apiMeta.allowPublicAccess, - canSetExternalAccess: apiMeta.allowExternalAccess, - publicCanView: apiObject.publicAccess.includes('r'), - publicCanEdit: apiObject.publicAccess.includes('rw'), - isSharedExternally: apiObject.externalAccess, - accesses: combinedAccesses, - }; -} +const defaultState = { + dataShareableTypes: [], + sharedObject: null, + errorMessage: '', +}; /** * A pop-up dialog for changing sharing preferences for a sharable object. */ class SharingDialog extends React.Component { - constructor(props) { - super(props); - this.state = { - accessForbidden: false, - apiObject: null, - objectToShare: null, - }; + state = defaultState; + componentDidMount() { + this.loadDataSharingSettings(); if (this.props.open) { this.loadObjectFromApi(); } @@ -78,11 +38,7 @@ class SharingDialog extends React.Component { componentWillReceiveProps(nextProps) { if (nextProps.id !== this.props.id) { - this.setState({ - accessForbidden: false, - objectToShare: null, - }); - + this.resetState(); if (nextProps.open) this.loadObjectFromApi(); } @@ -91,109 +47,91 @@ class SharingDialog extends React.Component { } } - onSearchRequest = (searchText) => { - const apiInstance = this.state.api; - - return apiInstance.get('sharing/search', { key: searchText }) - .then((searchResult) => { - const transformedResult = searchResult.users.map( - user => transformAccessObject(user, 'user')); - - return transformedResult.concat( - searchResult.userGroups.map( - userGroup => transformAccessObject(userGroup, 'userGroup'))); - }); - } + onSearchRequest = key => + this.state.api.get('sharing/search', { key }) + .then(searchResult => searchResult); onSharingChanged = (updatedAttributes, onSuccess) => { - const objectToShare = { - ...this.state.objectToShare, - ...updatedAttributes, + const updatedObject = { + meta: this.state.sharedObject.meta, + object: { + ...this.state.sharedObject.object, + ...updatedAttributes, + }, }; - const apiObject = this.restoreObjectStructure(objectToShare); + this.postChanges(updatedObject, onSuccess); + } - return this.state.api.post(`sharing?type=${this.props.type}&id=${this.props.id}`, apiObject) + postChanges = (updatedObject, onSuccess) => { + const url = `sharing?type=${this.props.type}&id=${this.props.id}`; + return this.state.api.post(url, updatedObject) .then(({ httpStatus, message }) => { if (httpStatus === 'OK') { this.setState({ - objectToShare, - apiObject, + sharedObject: updatedObject, }, () => { if (onSuccess) onSuccess(); }); - } else { - console.warn('Failed to post changes.'); - console.warn('SERVER SAID:', message); } return message; - }) - .catch(({ message }) => { - console.warn('Failed to post changes.'); - console.warn('SERVER SAID:', message); + }).catch(({ message }) => { + this.setState({ + errorMessage: message, + }); }); } - loadObjectFromApi() { + resetState = () => { + this.setState(defaultState); + } + + loadDataSharingSettings = () => { getInstance().then((d2) => { - const apiInstance = d2.Api.getApi(); - apiInstance.get('sharing', { type: this.props.type, id: this.props.id }) - .then((apiObject) => { - this.setState({ - api: apiInstance, - apiObject, - objectToShare: transformObjectStructure(apiObject.meta, apiObject.object), - }); - }) - .catch(() => { + const api = d2.Api.getApi(); + + api.get('schemas', { fields: ['name', 'dataShareable'] }) + .then((schemas) => { + const dataShareableTypes = schemas.schemas + .filter(item => item.dataShareable) + .map(item => item.name); + this.setState({ - accessForbidden: true, + dataShareableTypes, }); }); }); } - restoreObjectStructure(transformedObject) { - const userAccesses = []; - const userGroupAccesses = []; - - transformedObject.accesses.forEach((access) => { - const apiAccess = { - id: access.id, - name: access.name, - displayName: access.name, - access: cachedAccessTypeToString(access.canView, access.canEdit), - }; + loadObjectFromApi = () => { + getInstance().then((d2) => { + const api = d2.Api.getApi(); + const { type, id } = this.props; - if (access.type === 'user') { - userAccesses.push(apiAccess); - } else { - userGroupAccesses.push(apiAccess); - } + api.get('sharing', { type, id }) + .then((sharedObject) => { + this.setState({ + api, + sharedObject, + }); + }) + .catch(() => { + this.setState({ + errorMessage: this.context.d2.i18n.getTranslation('no_manage_access'), + }); + }); }); - - return { - meta: this.state.apiObject.meta, - object: { - ...this.state.apiObject.object, - userAccesses, - userGroupAccesses, - - publicAccess: cachedAccessTypeToString( - transformedObject.publicCanView, - transformedObject.publicCanEdit, - ), - externalAccess: transformedObject.isSharedExternally, - }, - }; } closeSharingDialog = () => { - this.props.onRequestClose(this.state.apiObject.object); + this.props.onRequestClose(this.state.sharedObject.object); } render() { + const dataShareable = this.state.dataShareableTypes.indexOf(this.props.type) !== -1; + const errorOccurred = this.state.errorMessage !== ''; + const isLoading = !this.state.sharedObject && this.props.open && !errorOccurred; const sharingDialogActions = [ , ]; - const loadingMaskStyle = { - position: 'relative', - }; - - if (!this.state.objectToShare) { - if (this.state.accessForbidden) { - return ( - - ); - } - - if (this.props.open) { - return (); - } - - return null; - } - return ( - - + - + { isLoading && } + { this.state.sharedObject && + + + + } +
); } } diff --git a/src/sharing/UserGroupAccess.component.js b/src/sharing/UserGroupAccess.component.js deleted file mode 100644 index d1d97bc7f..000000000 --- a/src/sharing/UserGroupAccess.component.js +++ /dev/null @@ -1,28 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Rule from './Rule.component'; - -const UserGroupAccess = ({ nameOfGroup, groupType, canView, canEdit, onChange, onRemove }) => ( - -); - -UserGroupAccess.propTypes = { - nameOfGroup: PropTypes.string.isRequired, - groupType: PropTypes.oneOf(['user', 'userGroup']), - canView: PropTypes.bool.isRequired, - canEdit: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onRemove: PropTypes.func.isRequired, -}; - -export default UserGroupAccess; diff --git a/src/sharing/UserSearch.component.js b/src/sharing/UserSearch.component.js index 9a8d3dd56..5d7b047f5 100644 --- a/src/sharing/UserSearch.component.js +++ b/src/sharing/UserSearch.component.js @@ -1,7 +1,10 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { config } from 'd2/lib/d2'; +import { Subject, Observable } from 'rxjs'; import AutoComplete from 'material-ui/AutoComplete'; + +import { accessObjectToString } from './utils'; import PermissionPicker from './PermissionPicker.component'; config.i18n.strings.add('add_users_and_user_groups'); @@ -10,7 +13,6 @@ config.i18n.strings.add('enter_names'); const styles = { container: { fontWeight: '400', - marginTop: 16, padding: 16, backgroundColor: '#F5F5F5', display: 'flex', @@ -37,72 +39,73 @@ const styles = { }, }; -function debounce(inner, ms = 0) { - let timer = null; - let resolves = []; - - return (...args) => { - clearTimeout(timer); - timer = setTimeout(() => { - const result = inner(...args); - resolves.forEach(r => r(result)); - resolves = []; - }, ms); +const searchDelay = 300; - return new Promise(r => resolves.push(r)); +class UserSearch extends Component { + state = { + defaultAccess: { + meta: { canView: true, canEdit: true }, + data: { canView: false, canEdit: false }, + }, + searchResult: [], }; -} -class UserSearch extends Component { - constructor(props) { - super(props); - this.state = { - initialViewAccess: true, - initialEditAccess: true, - searchText: '', - searchResult: [], - }; - - this.debouncedSearch = debounce(this.fetchSearchResult.bind(this), 300); + componentWillMount() { + this.inputStream + .debounce(() => Observable.timer(searchDelay)) + .subscribe((searchText) => { + this.fetchSearchResult(searchText); + }); } - accessOptionsChanged = ({ canView, canEdit }) => { - this.setState({ - initialViewAccess: canView, - initialEditAccess: canEdit, - }); + onResultClick = (_, index) => { + const selection = this.state.searchResult[index]; + const type = selection.type; + delete selection.type; + + if (type === 'userAccess') { + this.props.addUserAccess({ ...selection, access: accessObjectToString(this.state.defaultAccess) }); + } else this.props.addUserGroupAccess({ ...selection, access: accessObjectToString(this.state.defaultAccess) }); } - fetchSearchResult(searchText) { + inputStream = new Subject(); + + hasNoCurrentAccess = userOrGroup => this.props.currentAccessIds.indexOf(userOrGroup.id) === -1; + + fetchSearchResult = (searchText) => { if (searchText === '') { - this.setState({ searchResult: [] }); + this.handleSearchResult([]); } else { - this.props.onSearch(searchText) - .then((searchResult) => { - const noDuplicates = searchResult.filter( - result => !this.props.currentAccesses.some(access => access.id === result.id)); - this.setState({ searchResult: noDuplicates }); - }); + this.props.onSearch(searchText).then(({ users, userGroups }) => { + const addType = type => result => ({ ...result, type }); + const searchResult = users + .map(addType('userAccess')) + .filter(this.hasNoCurrentAccess) + .concat(userGroups + .map(addType('userGroupAccess')) + .filter(this.hasNoCurrentAccess), + ); + + this.handleSearchResult(searchResult); + }); } } - groupWasSelected = (chosenRequest, index) => { - if (index === -1) return; - this.setState({ searchText: '' }); - const selectedGroup = this.state.searchResult[index]; - this.props.addUserGroupAccess({ - ...selectedGroup, - canView: this.state.initialViewAccess, - canEdit: this.state.initialEditAccess, - }); + handleSearchResult = (searchResult) => { + this.setState({ searchResult }); } handleUpdateInput = (searchText) => { - this.setState({ searchText }); - this.debouncedSearch(searchText); + this.inputStream.next(searchText); } - generousFilter = () => true; + accessOptionsChanged = (accessOptions) => { + this.setState({ + defaultAccess: accessOptions, + }); + } + + noFilter = () => true; render() { return ( @@ -112,25 +115,24 @@ class UserSearch extends Component {
@@ -140,8 +142,10 @@ class UserSearch extends Component { UserSearch.propTypes = { onSearch: PropTypes.func.isRequired, + addUserAccess: PropTypes.func.isRequired, + dataShareable: PropTypes.bool.isRequired, addUserGroupAccess: PropTypes.func.isRequired, - currentAccesses: PropTypes.array.isRequired, + currentAccessIds: PropTypes.array.isRequired, }; UserSearch.contextTypes = { diff --git a/src/sharing/__tests__/Access.component.spec.js b/src/sharing/__tests__/Access.component.spec.js new file mode 100644 index 000000000..d86808fd1 --- /dev/null +++ b/src/sharing/__tests__/Access.component.spec.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Access } from '../Access.component'; +import { getStubContext } from '../../../config/inject-theme'; + +describe('Sharing: Access component', () => { + let accessComponent; + const renderComponent = (props = {}) => { + accessComponent = shallow(, { + context: getStubContext(), + }); + }; + + const accessProps = { + access: { + data: { canView: true, canEdit: true }, + meta: { canView: false, canEdit: false }, + }, + accessOptions: { + data: { canView: true, canEdit: true, noAccess: true }, + meta: { canView: true, canEdit: true, noAccess: true }, + }, + accessType: 'user', + primaryText: 'Tom Wakiki', + secondaryText: 'Some description', + onChange: () => {}, + onRemove: () => {}, + disabled: false, + }; + + renderComponent(accessProps); + + it('should render subcomponents', () => { + expect(accessComponent.find('FontIcon')).toHaveLength(1); + expect(accessComponent.find('PermissionPicker')).toHaveLength(1); + expect(accessComponent.find('IconButton')).toHaveLength(1); + }); + + it('should show the primary and secondary text', () => { + expect(accessComponent.text()).toContain(accessProps.primaryText); + expect(accessComponent.text()).toContain(accessProps.secondaryText); + }); +}); diff --git a/src/sharing/__tests__/CreatedBy.component.spec.js b/src/sharing/__tests__/CreatedBy.component.spec.js index a2dbbd5af..b9909daa9 100644 --- a/src/sharing/__tests__/CreatedBy.component.spec.js +++ b/src/sharing/__tests__/CreatedBy.component.spec.js @@ -12,7 +12,7 @@ describe('Sharing: CreatedBy component', () => { }; it('should render a div showing the author\'s name', () => { - const userObject = { + const authorObject = { id: 'GOLswS44mh8', name: 'Tom Wakiki', created: '2012-11-21T11:02:04.303+0000', @@ -20,7 +20,7 @@ describe('Sharing: CreatedBy component', () => { href: 'http://localhost:8080/dhis/api/users/GOLswS44mh8', }; - renderComponent({ user: userObject }); + renderComponent({ author: authorObject }); expect(createdByComponent.text()).toBe('created_by_translated: Tom Wakiki'); }); }); diff --git a/src/sharing/__tests__/ExternalAccess.component.spec.js b/src/sharing/__tests__/ExternalAccess.component.spec.js deleted file mode 100644 index e1c97b56e..000000000 --- a/src/sharing/__tests__/ExternalAccess.component.spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import Rule from '../Rule.component'; -import ExternalAccess from '../ExternalAccess.component'; -import { getStubContext } from '../../../config/inject-theme'; - -describe('Sharing: ExternalAccess component', () => { - let externalAccessComponent; - - const renderComponent = (props = {}) => { - externalAccessComponent = shallow(, { - context: getStubContext(), - }); - - return externalAccessComponent; - }; - - it('should render a Rule component', () => { - renderComponent({ canView: true, disabled: false, onChange: () => {} }); - expect(externalAccessComponent.find(Rule)).toHaveLength(1); - }); - - describe('Rule', () => { - let ruleComponent; - let onChange; - - beforeEach(() => { - onChange = jest.fn(); - ruleComponent = externalAccessComponent.find(Rule); - renderComponent({ canView: true, disabled: false, onChange }); - }); - - it('should have a suitable title', () => { - expect(ruleComponent.props().primaryText).toBe('external_access_translated'); - }); - - it('should describe the access as viewable if external access is enabled', () => { - expect(ruleComponent.props().secondaryText.toLowerCase()) - .toBe('anyone_can_view_without_a_login_translated'); - }); - - it('should have no access if external access is disabled', () => { - renderComponent({ canView: false, disabled: false, onChange }); - ruleComponent = externalAccessComponent.find(Rule); - expect(ruleComponent.props().secondaryText.toLowerCase()).toBe('no_access_translated'); - }); - }); -}); diff --git a/src/sharing/__tests__/PermissionPicker.component.spec.js b/src/sharing/__tests__/PermissionPicker.component.spec.js index 84464e3c9..8f6ab2f92 100644 --- a/src/sharing/__tests__/PermissionPicker.component.spec.js +++ b/src/sharing/__tests__/PermissionPicker.component.spec.js @@ -1,16 +1,21 @@ import React from 'react'; import { shallow } from 'enzyme'; -import IconMenu from 'material-ui/IconMenu'; -import MenuItem from 'material-ui/MenuItem'; +import IconButton from 'material-ui/IconButton'; +import PermissionOption from '../PermissionOption.component'; import PermissionPicker from '../PermissionPicker.component'; import { getStubContext } from '../../../config/inject-theme'; const permissionPickerProps = { - accessOptions: { canView: true, canEdit: true }, + access: { + meta: { canView: true, canEdit: true }, + data: { canView: true, canEdit: true }, + }, + accessOptions: { + meta: { canView: true, canEdit: true, noAccess: true }, + data: { canView: true, canEdit: true, noAccess: true }, + }, onChange: () => {}, disabled: false, - disableWritePermission: false, - disableNoAccess: false, }; describe('Sharing: PermissionPicker component', () => { @@ -26,61 +31,45 @@ describe('Sharing: PermissionPicker component', () => { }); it('should render an IconButton', () => { - expect(permissionPickerComponent.find(IconMenu)).toHaveLength(1); + expect(permissionPickerComponent.find(IconButton)).toHaveLength(1); }); - it('should render three MenuItems if all access options are available', () => { - expect(permissionPickerComponent.find(MenuItem)).toHaveLength(3); - }); - - it('should render two MenuItems if disableWritePermission is false', () => { - permissionPickerComponent = renderComponent({ - ...permissionPickerProps, - disableWritePermission: true, - }); - - expect(permissionPickerComponent.find(MenuItem)).toHaveLength(2); - }); - - it('should render two MenuItems if disableNoAccess is false', () => { + it('should render three PermissionOptions if no data options, but all metadata options are available', () => { permissionPickerComponent = renderComponent({ ...permissionPickerProps, - disableNoAccess: true, + accessOptions: { + meta: { canView: true, canEdit: true, noAccess: true }, + }, }); - expect(permissionPickerComponent.find(MenuItem)).toHaveLength(2); + expect(permissionPickerComponent.find(PermissionOption)).toHaveLength(3); }); - it('should render one MenuItems if disableWritePermission and disableNoAccess are both false', () => { - permissionPickerComponent = renderComponent({ - ...permissionPickerProps, - disableWritePermission: true, - disableNoAccess: true, - }); - - expect(permissionPickerComponent.find(MenuItem)).toHaveLength(1); + it('should render six PermissionOptions if all access options are available', () => { + expect(permissionPickerComponent.find(PermissionOption)).toHaveLength(6); }); it('should render the checkmark FontIcon according to the permission values', () => { - expect(permissionPickerComponent.find(MenuItem).at(0).props().leftIcon.props.children).toBe('done'); - expect(permissionPickerComponent.find(MenuItem).at(1).props().leftIcon.props.children).toBe(''); - expect(permissionPickerComponent.find(MenuItem).at(2).props().leftIcon.props.children).toBe(''); - permissionPickerComponent = renderComponent({ - ...permissionPickerProps, - accessOptions: { canEdit: false, canView: true }, - }); - expect(permissionPickerComponent.find(MenuItem).at(0).props().leftIcon.props.children).toBe(''); - expect(permissionPickerComponent.find(MenuItem).at(1).props().leftIcon.props.children).toBe('done'); - }); + expect(permissionPickerComponent.find(PermissionOption).at(0).props().isSelected).toBe(true); + expect(permissionPickerComponent.find(PermissionOption).at(1).props().isSelected).toBe(false); + expect(permissionPickerComponent.find(PermissionOption).at(2).props().isSelected).toBe(false); + expect(permissionPickerComponent.find(PermissionOption).at(3).props().isSelected).toBe(true); + expect(permissionPickerComponent.find(PermissionOption).at(4).props().isSelected).toBe(false); + expect(permissionPickerComponent.find(PermissionOption).at(5).props().isSelected).toBe(false); - it('should fire the onChange callback on change', () => { - const onChangeSpy = jest.fn(); permissionPickerComponent = renderComponent({ ...permissionPickerProps, - onChange: onChangeSpy, + access: { + data: { canView: false, canEdit: false }, + meta: { canView: false, canEdit: false }, + }, }); - permissionPickerComponent.simulate('change'); - expect(onChangeSpy).toHaveBeenCalled(); + expect(permissionPickerComponent.find(PermissionOption).at(0).props().isSelected).toBe(false); + expect(permissionPickerComponent.find(PermissionOption).at(1).props().isSelected).toBe(false); + expect(permissionPickerComponent.find(PermissionOption).at(2).props().isSelected).toBe(true); + expect(permissionPickerComponent.find(PermissionOption).at(3).props().isSelected).toBe(false); + expect(permissionPickerComponent.find(PermissionOption).at(4).props().isSelected).toBe(false); + expect(permissionPickerComponent.find(PermissionOption).at(5).props().isSelected).toBe(true); }); }); diff --git a/src/sharing/__tests__/PublicAccess.component.spec.js b/src/sharing/__tests__/PublicAccess.component.spec.js deleted file mode 100644 index bdd34ccc8..000000000 --- a/src/sharing/__tests__/PublicAccess.component.spec.js +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import Rule from '../Rule.component'; -import PublicAccess from '../PublicAccess.component'; -import { getStubContext } from '../../../config/inject-theme'; - -const publicAccessProps = { - canView: true, - canEdit: false, - disabled: false, - onChange: () => {}, -}; - -describe('Sharing: PublicAccess component', () => { - let publicAccessComponent; - - const renderComponent = (props = {}) => { - publicAccessComponent = shallow(, { - context: getStubContext(), - }); - - return publicAccessComponent; - }; - - it('should render a Rule component', () => { - renderComponent(publicAccessProps); - expect(publicAccessComponent.find(Rule)).toHaveLength(1); - }); - - describe('Rule', () => { - let ruleComponent; - - renderComponent(publicAccessProps); - - beforeEach(() => { - ruleComponent = publicAccessComponent.find(Rule); - }); - - it('should have a suitable title', () => { - expect(ruleComponent.props().primaryText).toBe('public_access_translated'); - }); - - it('should pass the disabled prop along', () => { - renderComponent({ ...publicAccessProps, disabled: true }); - ruleComponent = publicAccessComponent.find(Rule); - expect(ruleComponent.props().disabled).toBe(true); - }); - - it('should receive the access type', () => { - expect(ruleComponent.props().accessType).toBe('public'); - }); - - it('should pass along the onChange handler', () => { - const onChangeSpy = jest.fn(); - renderComponent({ ...publicAccessProps, onChange: onChangeSpy }); - ruleComponent = publicAccessComponent.find(Rule); - expect(publicAccessComponent.find(Rule).props().onChange).toBe(onChangeSpy); - }); - - it('should call the change handler when a change event is given', () => { - const onChangeSpy = jest.fn(); - renderComponent({ ...publicAccessProps, onChange: onChangeSpy }); - ruleComponent = publicAccessComponent.find(Rule); - - publicAccessComponent.simulate('change'); - expect(onChangeSpy).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/src/sharing/__tests__/Rule.component.spec.js b/src/sharing/__tests__/Rule.component.spec.js deleted file mode 100644 index 25a2c8d45..000000000 --- a/src/sharing/__tests__/Rule.component.spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import Rule from '../Rule.component'; -import { getStubContext } from '../../../config/inject-theme'; - -describe('Sharing: Rule component', () => { - let ruleComponent; - const renderComponent = (props = {}) => { - ruleComponent = shallow(, { - context: getStubContext(), - }); - }; - - const sharingRule = { - accessType: 'user', - primaryText: 'Tom Wakiki', - secondaryText: 'user', - accessOptions: { - canEdit: true, - canView: true, - }, - onChange: () => {}, - onRemove: () => {}, - disabled: false, - disableWritePermission: false, - disableNoAccess: false, - }; - - renderComponent(sharingRule); - - it('should render subcomponents', () => { - expect(ruleComponent.find('FontIcon')).toHaveLength(1); - expect(ruleComponent.find('PermissionPicker')).toHaveLength(1); - expect(ruleComponent.find('IconButton')).toHaveLength(1); - }); - - it('should show the primary and secondary text', () => { - expect(ruleComponent.text()).toContain(sharingRule.primaryText); - expect(ruleComponent.text()).toContain(sharingRule.secondaryText); - }); -}); diff --git a/src/sharing/__tests__/Sharing.component.spec.js b/src/sharing/__tests__/Sharing.component.spec.js index 4e5a5c3a3..a3bc9aaa1 100644 --- a/src/sharing/__tests__/Sharing.component.spec.js +++ b/src/sharing/__tests__/Sharing.component.spec.js @@ -6,58 +6,57 @@ import Sharing from '../Sharing.component'; import Heading from '../../headings/Heading.component'; import CreatedBy from '../CreatedBy.component'; import UserSearch from '../UserSearch.component'; -import PublicAccess from '../PublicAccess.component'; -import ExternalAccess from '../ExternalAccess.component'; -import UserGroupAccess from '../UserGroupAccess.component'; +import { PublicAccess, ExternalAccess, GroupAccess } from '../Access.component'; const sharingProps = { - authorOfSharableItem: { - id: 'GOLswS44mh8', - name: 'Tom Wakiki', + sharedObject: { + meta: { + allowPublicAccess: true, + allowExternalAccess: false, + }, + object: { + id: 'veGzholzPQm', + name: 'HIV age', + displayName: 'HIV age', + publicAccess: 'rw------', + externalAccess: false, + user: { + id: 'GOLswS44mh8', + name: 'Tom Wakiki', + }, + userGroupAccesses: [{ + id: 'Rg8wusV7QYi', + name: 'HIV Program Coordinators', + displayName: 'HIV Program Coordinators', + access: 'rw------', + }], + userAccesses: [{ + id: 'N3PZBUlN8vq', + name: 'John Kamara', + displayName: 'John Kamara', + access: 'r-------', + }], + }, }, - nameOfSharableItem: 'ANC: Overview Report (HTML-based)', - canSetPublicAccess: true, - canSetExternalAccess: true, - publicCanView: true, - publicCanEdit: true, - isSharedExternally: true, - accesses: [{ - id: 'lFHP5lLkzVr', - name: 'System administrators', - displayName: 'System administrators', - type: 'userGroup', - canView: true, - canEdit: false, - }, { - id: 'rWLrZL8rP3K', - name: 'Guest User', - displayName: 'Guest User', - type: 'user', - canView: true, - canEdit: false, - }], - onSharingChanged: () => {}, + dataShareable: false, + onChange: () => {}, onSearch: () => {}, }; -const exampleUserGroup = { +const exampleUserAccessGroup = { id: 'vAvEltyXGbD', name: 'Africare HQ', displayName: 'Africare HQ', - type: 'userGroup', - canView: true, - canEdit: false, + access: 'r-------', }; describe('Sharing: Sharing component', () => { let sharingComponent; const renderComponent = (props = {}) => { - const sharing = shallow(, { + return shallow(, { context: getStubContext(), }); - - return sharing; }; beforeEach(() => { @@ -66,10 +65,10 @@ describe('Sharing: Sharing component', () => { it('renders the object name using a Heading component', () => { const headerComponent = sharingComponent.find(Heading); - expect(headerComponent.props().text).toBe('ANC: Overview Report (HTML-based)'); + expect(headerComponent.props().text).toBe('HIV age'); }); - it('should render the CreatedBy component with the authorOfSharableItem prop', () => { + it('should render the CreatedBy component with the sharedObject\'s author', () => { const createdByComponent = sharingComponent.find(CreatedBy); expect(createdByComponent.props().user).toEqual(sharingProps.authorOfSharableItem); }); @@ -79,50 +78,15 @@ describe('Sharing: Sharing component', () => { expect(subheaderComponent.childAt(0).text()).toBe('who_has_access_translated'); }); - describe('PublicAccess', () => { - let publicAccessComponent; - - beforeEach(() => { - publicAccessComponent = sharingComponent.find(PublicAccess); - }); - - it('inherits props correctly from parent', () => { - expect(publicAccessComponent.props().canView).toBe(sharingProps.publicCanView); - expect(publicAccessComponent.props().canEdit).toBe(sharingProps.publicCanEdit); - expect(publicAccessComponent.props().disabled).not.toEqual(sharingProps.canSetPublicAccess); - }); - }); - - describe('ExternalAccess', () => { - let externalAccessComponent; - - beforeEach(() => { - externalAccessComponent = sharingComponent.find(ExternalAccess); - }); - - it('inherits the canView and disabled props from parent', () => { - expect(externalAccessComponent.props().canView).toBe(sharingProps.publicCanView); - expect(externalAccessComponent.props().disabled).not.toEqual(sharingProps.canSetPublicAccess); - }); - }); - - describe('UserGroupAccess', () => { + describe('GroupAccess', () => { it('should render once per access', () => { - expect(sharingComponent.find(UserGroupAccess)).toHaveLength(2); + expect(sharingComponent.find(GroupAccess)).toHaveLength(2); - sharingComponent = renderComponent({ - ...sharingProps, accesses: [...sharingProps.accesses, exampleUserGroup], - }); - - expect(sharingComponent.find(UserGroupAccess)).toHaveLength(3); - }); + const newProps = { ...sharingProps }; + newProps.sharedObject.object.userGroupAccesses.push(exampleUserAccessGroup); + sharingComponent = renderComponent(newProps); - it('is passed the correct access props', () => { - const userGroupAccess = sharingComponent.find(UserGroupAccess).at(0); - expect(userGroupAccess.props().nameOfGroup).toBe('System administrators'); - expect(userGroupAccess.props().groupType).toBe('userGroup'); - expect(userGroupAccess.props().canView).toBe(true); - expect(userGroupAccess.props().canEdit).toBe(false); + expect(sharingComponent.find(GroupAccess)).toHaveLength(3); }); }); diff --git a/src/sharing/__tests__/SharingDialog.component.spec.js b/src/sharing/__tests__/SharingDialog.component.spec.js index 910fd4a09..49291fde9 100644 --- a/src/sharing/__tests__/SharingDialog.component.spec.js +++ b/src/sharing/__tests__/SharingDialog.component.spec.js @@ -10,31 +10,45 @@ import Sharing from '../Sharing.component'; import { getStubContext } from '../../../config/inject-theme'; const mockedObject = { - authorOfSharableItem: { - id: 'GOLswS44mh8', - name: 'Tom Wakiki', + sharedObject: { + meta: { + allowPublicAccess: true, + allowExternalAccess: false, + }, + object: { + id: 'veGzholzPQm', + name: 'HIV age', + displayName: 'HIV age', + publicAccess: 'rw------', + externalAccess: false, + user: { + id: 'GOLswS44mh8', + name: 'Tom Wakiki', + }, + userGroupAccesses: [{ + id: 'Rg8wusV7QYi', + name: 'HIV Program Coordinators', + displayName: 'HIV Program Coordinators', + access: 'rw------'} + ], + userAccesses: [{ + id: 'N3PZBUlN8vq', + name: 'John Kamara', + displayName: 'John Kamara', + access: 'r-------', + }], + }, }, - nameOfSharableItem: 'ANC: Overview Report (HTML-based)', - canSetPublicAccess: true, - canSetExternalAccess: true, - publicCanView: true, - publicCanEdit: true, - isSharedExternally: true, - accesses: [{ - id: 'lFHP5lLkzVr', - name: 'System administrators', - displayName: 'System administrators', - type: 'userGroup', - canView: true, - canEdit: false, - }, { - id: 'rWLrZL8rP3K', - name: 'Guest User', - displayName: 'Guest User', - type: 'user', - canView: true, - canEdit: false, - }], + dataShareable: false, + onChange: () => {}, + onSearch: () => {}, +}; + +const sharingDialogProps = { + open: true, + type: 'report', + id: 'AMERNML55Tg', + onRequestClose: () => {}, }; describe('Sharing: SharingDialog component', () => { @@ -48,22 +62,15 @@ describe('Sharing: SharingDialog component', () => { beforeEach(() => { onRequestClose = jest.fn(); - sharingDialogComponent = renderComponent({ - open: true, + ...sharingDialogProps, onRequestClose, - type: 'report', - id: 'AMERNML55Tg', }); }); - jest.spyOn(SharingDialog.prototype, 'loadObjectFromApi'); - it('should show its dialog when objectToShare is defined', () => { sharingDialogComponent.setState({ - api: null, - objectToShare: mockedObject, - fullObjectName: mockedObject.name, + sharedObject: mockedObject, }); expect(sharingDialogComponent.find(Sharing)).toHaveLength(1); @@ -72,9 +79,7 @@ describe('Sharing: SharingDialog component', () => { describe('close action', () => { beforeEach(() => { sharingDialogComponent.setState({ - api: null, - objectToShare: mockedObject, - fullObjectName: mockedObject.name, + sharedObject: mockedObject, }); }); @@ -90,12 +95,6 @@ describe('Sharing: SharingDialog component', () => { }); it('should call onRequestClose from the props when the closeSharingDialog is called', () => { - sharingDialogComponent.setState({ - apiObject: { - object: null, - }, - }); - sharingDialogComponent.instance().closeSharingDialog(); expect(onRequestClose).toHaveBeenCalledTimes(1); }); @@ -106,9 +105,8 @@ describe('Sharing: SharingDialog component', () => { jest.fn(log, 'warn'); }); - it('should render when objectToShare is undefined and dialog is open', () => { - renderComponent({ open: true, onRequestClose: () => {}, type: 'report', id: 'AMERNML55Tg' }); - + it('should render when sharedObject is undefined and dialog is open', () => { + renderComponent(sharingDialogProps); expect(sharingDialogComponent.find(LoadingMask)).toHaveLength(1); }); }); diff --git a/src/sharing/__tests__/UserGroupAccesses.component.spec.js b/src/sharing/__tests__/UserGroupAccesses.component.spec.js deleted file mode 100644 index 85d83a949..000000000 --- a/src/sharing/__tests__/UserGroupAccesses.component.spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import { getStubContext } from '../../../config/inject-theme'; -import UserGroupAccess from '../UserGroupAccess.component'; -import Rule from '../Rule.component'; - -const userGroupAccessProps = { - nameOfGroup: 'System administrators', - groupType: 'userGroup', - canView: true, - canEdit: true, - onChange: () => {}, - onRemove: () => {}, -}; - -describe('Sharing: UserGroupAccess component', () => { - let userGroupAccessComponent; - - const renderComponent = (props = {}) => { - userGroupAccessComponent = shallow(, { - context: getStubContext(), - }); - - return userGroupAccessComponent; - }; - - beforeEach(() => { - renderComponent(userGroupAccessProps); - }); - - it('should render a Rule component', () => { - expect(userGroupAccessComponent.find(Rule)).toHaveLength(1); - }); - - it('should render the primaryText as nameOfGroup', () => { - expect(userGroupAccessComponent.find(Rule).props().primaryText).toBe(userGroupAccessProps.nameOfGroup); - }); - - it('should render the accessOptions according to canView and canEdit', () => { - expect(userGroupAccessComponent.find(Rule).props().accessOptions).toEqual({ - canView: userGroupAccessProps.canView, - canEdit: userGroupAccessProps.canEdit, - }); - }); -}); diff --git a/src/sharing/sharing.actions.js b/src/sharing/sharing.actions.js deleted file mode 100644 index 3a62a3547..000000000 --- a/src/sharing/sharing.actions.js +++ /dev/null @@ -1,119 +0,0 @@ -import { getInstance as getD2 } from 'd2/lib/d2'; -import Action from '../action/Action'; -import sharingStore from './sharing.store'; - -const actions = Action.createActionsFromNames([ - 'externalAccessChanged', - 'loadObjectSharingState', - 'publicAccessChanged', - 'userGroupAcessesChanged', - 'saveChangedState', -]); - -actions.externalAccessChanged - .subscribe(({ data }) => { - sharingStore.setState(Object.assign({}, sharingStore.getState(), { externalAccess: data })); - - actions.saveChangedState(); - }); - -actions.loadObjectSharingState - .subscribe(({ data: sharableObject, complete, error }) => { - if (!sharableObject.modelDefinition || !sharableObject.modelDefinition.name) { - error({ - actionName: 'sharing.loadObjectSharingState', - message: 'shareableObject should contain a modelDefinition property', - }); - } - - const objectType = sharableObject.modelDefinition.name; - - getD2() - .then((d2) => { - const api = d2.Api.getApi(); - - return api.get('sharing', { type: objectType, id: sharableObject.id }, { contentType: 'text/plain' }); - }) - .then(({ meta, object }) => { - const sharableState = { - objectType, - meta, - user: object.user, - externalAccess: object.externalAccess, - publicAccess: object.publicAccess, - userGroupAccesses: object.userGroupAccesses || [], - }; - sharableState.model = sharableObject; - sharableState.isSaving = false; - sharingStore.setState(sharableState); - }) - .then(complete) - .catch(error); - }); - -actions.publicAccessChanged - .subscribe(({ data: publicAccess }) => { - sharingStore.setState(Object.assign({}, sharingStore.getState(), { publicAccess })); - - actions.saveChangedState(); - }); - -actions.userGroupAcessesChanged - .subscribe(({ data: userGroupAccesses }) => { - sharingStore.setState(Object.assign({}, sharingStore.getState(), { userGroupAccesses })); - - actions.saveChangedState(); - }); - -function saveSharingToServer(action) { - return getD2() - .then((d2) => { - const api = d2.Api.getApi(); - const { - meta, - model, - externalAccess, - publicAccess, - userGroupAccesses, - objectType, - } = sharingStore.getState(); - - const sharingDataToPost = { - meta, - object: { - externalAccess, - publicAccess, - userGroupAccesses: userGroupAccesses.filter((userGroupAccess) => { - if (userGroupAccess.access !== '--------') { - return true; - } - return false; - }), - }, - }; - - return api.post(`sharing?type=${objectType}&id=${model.id}`, sharingDataToPost) - .then(({ httpStatus, message }) => { - if (httpStatus === 'OK') { - action.complete(message); - } else { - action.error(message); - } - return message; - }) - .catch(({ message }) => { - action.error(message); - return message; - }); - }); -} - -actions.saveChangedState - .debounceTime(500) - .map(saveSharingToServer) - .concatAll() - .subscribe(() => { - actions.loadObjectSharingState(sharingStore.getState().model); - }); - -export default actions; diff --git a/src/sharing/utils.js b/src/sharing/utils.js new file mode 100644 index 000000000..afb12479f --- /dev/null +++ b/src/sharing/utils.js @@ -0,0 +1,58 @@ +export const cachedAccessTypeToString = (canView, canEdit) => { + if (canView) { + return canEdit + ? 'rw------' + : 'r-------'; + } + + return '--------'; +}; + +export const transformAccessObject = (access, type) => ({ + id: access.id, + name: access.name, + displayName: access.displayName, + type, + canView: access.access && access.access.includes('r'), + canEdit: access.access && access.access.includes('rw'), +}); + +export const accessStringToObject = (access) => { + if (!access) { + return { + data: { canView: false, canEdit: false }, + meta: { canView: false, canEdit: false }, + }; + } + + const metaAccess = access.substring(0, 2); + const dataAccess = access.substring(2, 4); + + return { + meta: { + canView: metaAccess.includes('r'), + canEdit: metaAccess.includes('rw'), + }, + data: { + canView: dataAccess.includes('r'), + canEdit: dataAccess.includes('rw'), + }, + }; +}; + +export const accessObjectToString = (accessObject) => { + const convert = ({ canEdit, canView }) => { + if (canEdit) { + return 'rw'; + } + + return canView ? 'r-' : '--'; + }; + + let accessString = ''; + accessString += convert(accessObject.meta); + accessString += convert(accessObject.data); + accessString += '----'; + + return accessString; +}; diff --git a/yarn.lock b/yarn.lock index a2dd84df2..e1c3ab6e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6831,4 +6831,4 @@ yargs@~3.10.0: camelcase "^1.0.2" cliui "^2.1.0" decamelize "^1.0.0" - window-size "0.1.0" \ No newline at end of file + window-size "0.1.0"