From 7e15c7f19becf6fc6657270a3f1ef94d8a48f048 Mon Sep 17 00:00:00 2001 From: Thomas Zemp Date: Wed, 13 Nov 2024 09:19:47 +0100 Subject: [PATCH] feat: add data sharing to sharing dialog [LIBS-677] (#1629) --- collections/forms/i18n/en.pot | 4 +- components/sharing-dialog/API.md | 9 +- components/sharing-dialog/i18n/en.pot | 40 +- .../src/access-add/access-add.js | 166 +++- .../src/access-list/access-list.js | 251 +++--- .../src/access-list/list-item-context.js | 43 +- .../src/access-list/list-item.js | 162 +++- .../src/autocomplete/sharing-autocomplete.js | 2 +- .../src/features/access-level-change.feature | 2 +- .../src/features/access-level-change/index.js | 24 +- .../src/features/access-level-remove/index.js | 24 +- .../src/features/add-entity/index.js | 16 +- .../src/features/data-sharing.feature | 91 +++ .../src/features/data-sharing/index.js | 773 ++++++++++++++++++ .../get-object-data-metadata-access.js | 84 ++ .../src/features/fixtures/index.js | 8 + .../src/helpers/__tests__/helpers.test.js | 71 +- .../sharing-dialog/src/helpers/helpers.js | 47 +- .../src/sharing-dialog.e2e.stories.js | 6 + .../sharing-dialog/src/sharing-dialog.js | 122 ++- .../src/sharing-dialog.prod.stories.js | 11 +- .../sharing-dialog/src/tabs/tabbed-content.js | 62 +- components/sharing-dialog/types/index.d.ts | 21 +- 23 files changed, 1717 insertions(+), 322 deletions(-) create mode 100644 components/sharing-dialog/src/features/data-sharing.feature create mode 100644 components/sharing-dialog/src/features/data-sharing/index.js create mode 100644 components/sharing-dialog/src/features/fixtures/get-object-data-metadata-access.js diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot index dd42956f40..33fbbb3db0 100644 --- a/collections/forms/i18n/en.pot +++ b/collections/forms/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-09T12:09:30.724Z\n" -"PO-Revision-Date: 2024-09-09T12:09:30.724Z\n" +"POT-Creation-Date: 2024-11-11T07:36:10.587Z\n" +"PO-Revision-Date: 2024-11-11T07:36:10.588Z\n" msgid "Upload file" msgstr "Upload file" diff --git a/components/sharing-dialog/API.md b/components/sharing-dialog/API.md index c598050621..03071fedc7 100644 --- a/components/sharing-dialog/API.md +++ b/components/sharing-dialog/API.md @@ -16,14 +16,9 @@ import { SharingDialog } from '@dhis2/ui' |---|---|---|---|---| |id|string||*|The id of the object to share| |type|DIALOG_TYPES_LIST||*|The type of object to share| +|dataSharing|boolean|`false`||Whether to expose the ability to set data sharing (in addition to metadata sharing)| |dataTest|string|`'dhis2-uicore-sharingdialog'`||| -|initialSharingSettings|{
"allowPublic": "boolean",
"groups": "objectOf",
"name": "string",
"public": "import {\n ACCESS_NONE,\n ACCESS_VIEW_ONLY,\n ACCESS_VIEW_AND_EDIT,\n DIALOG_TYPES_LIST,\n} from './constants.js' │ import {\n ACCESS_NONE,\n ACCESS_VIEW_ONLY,\n ACCESS_VIEW_AND_EDIT,\n DIALOG_TYPES_LIST,\n} from './constants.js' │ import {\n ACCESS_NONE,\n ACCESS_VIEW_ONLY,\n ACCESS_VIEW_AND_EDIT,\n DIALOG_TYPES_LIST,\n} from './constants.js'",
"users": "objectOf"
}|`{ - name: '', - allowPublic: true, - public: ACCESS_NONE, - groups: {}, - users: {}, -}`||Used to seed the component with data to show whilst loading| +|initialSharingSettings|object|{
"allowPublic": "boolean",
"name": "string",
"public": {"data": 'ACCESS_NONE' \| 'ACCESS_VIEW_ONLY' \| 'ACCESS_VIEW_AND_EDIT',"metadata": 'ACCESS_NONE' \| 'ACCESS_VIEW_ONLY' \| 'ACCESS_VIEW_AND_EDIT'},
"groups": "objectOf (see below)",
"users": "objectOf (see below)",
}

users and group objects have format of:
{"name": "string",
"id": "string",
"access": {"data": 'ACCESS_NONE' \| 'ACCESS_VIEW_ONLY' \| 'ACCESS_VIEW_AND_EDIT',"metadata": 'ACCESS_NONE' \| 'ACCESS_VIEW_ONLY' \| 'ACCESS_VIEW_AND_EDIT'}}||Used to seed the component with data to show whilst loading| |onClose|function|`() => {}`||| |onError|function|`() => {}`||| |onSave|function|`() => {}`||| diff --git a/components/sharing-dialog/i18n/en.pot b/components/sharing-dialog/i18n/en.pot index f6909d3814..dd190099bd 100644 --- a/components/sharing-dialog/i18n/en.pot +++ b/components/sharing-dialog/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2021-11-25T09:59:40.995Z\n" -"PO-Revision-Date: 2021-11-25T09:59:40.995Z\n" +"POT-Creation-Date: 2024-11-07T15:20:34.373Z\n" +"PO-Revision-Date: 2024-11-07T15:20:34.375Z\n" msgid "View only" msgstr "View only" @@ -14,18 +14,27 @@ msgstr "View only" msgid "View and edit" msgstr "View and edit" +msgid "No access" +msgstr "No access" + msgid "Give access to a user or group" msgstr "Give access to a user or group" -msgid "Access level" -msgstr "Access level" +msgid "Data access level" +msgstr "Data access level" -msgid "Select a level" -msgstr "Select a level" +msgid "Choose a level" +msgstr "Choose a level" msgid "Not available offline" msgstr "Not available offline" +msgid "Metadata access level" +msgstr "Metadata access level" + +msgid "Access level" +msgstr "Access level" + msgid "Give access" msgstr "Give access" @@ -38,21 +47,24 @@ msgstr "User / Group" msgid "All users" msgstr "All users" -msgid "No access" -msgstr "No access" +msgid "Anyone logged in" +msgstr "Anyone logged in" -msgid "Can view" -msgstr "Can view" +msgid "User group" +msgstr "User group" -msgid "Can view and edit" -msgstr "Can view and edit" +msgid "User" +msgstr "User" -msgid "Metadata" -msgstr "Metadata" +msgid "Data" +msgstr "Data" msgid "Remove access" msgstr "Remove access" +msgid "Metadata" +msgstr "Metadata" + msgid "User or group" msgstr "User or group" diff --git a/components/sharing-dialog/src/access-add/access-add.js b/components/sharing-dialog/src/access-add/access-add.js index b1d7f6c4f0..56ad8da119 100644 --- a/components/sharing-dialog/src/access-add/access-add.js +++ b/components/sharing-dialog/src/access-add/access-add.js @@ -5,15 +5,20 @@ import { SingleSelectField, SingleSelectOption } from '@dhis2-ui/select' import PropTypes from 'prop-types' import React, { useState, useContext } from 'react' import { SharingAutocomplete } from '../autocomplete/index.js' -import { ACCESS_VIEW_ONLY, ACCESS_VIEW_AND_EDIT } from '../constants.js' +import { + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, +} from '../constants.js' import { FetchingContext } from '../fetching-context/index.js' import i18n from '../locales/index.js' import { Title } from '../text/index.js' -export const AccessAdd = ({ onAdd }) => { +export const AccessAdd = ({ onAdd, dataSharing }) => { const isFetching = useContext(FetchingContext) const [entity, setEntity] = useState(null) - const [access, setAccess] = useState('') + const [dataAccess, setDataAccess] = useState('') + const [metadataAccess, setMetadataAccess] = useState('') const { isDisconnected: offline } = useDhis2ConnectionStatus() const onSubmit = (e) => { @@ -23,14 +28,15 @@ export const AccessAdd = ({ onAdd }) => { type: entity.type, id: entity.id, name: entity.displayName || entity.name, - access, + access: { data: dataAccess, metadata: metadataAccess }, }) setEntity(null) - setAccess('') + setDataAccess('') + setMetadataAccess('') } - const accessOptions = [ + const accessOptionsMetadata = [ { value: ACCESS_VIEW_ONLY, label: i18n.t('View only'), @@ -41,41 +47,109 @@ export const AccessAdd = ({ onAdd }) => { }, ] + const accessOptionsData = [ + ...accessOptionsMetadata, + { + value: ACCESS_NONE, + label: i18n.t('No access'), + }, + ] + return ( <> {i18n.t('Give access to a user or group')}
- -
- + +
+
+ {dataSharing && ( +
+ + setDataAccess(selected) + } + > + {accessOptionsData.map(({ value, label }) => ( + + ))} + +
+ )} +
+ + setMetadataAccess(selected) + } + > + {(dataSharing + ? accessOptionsData + : accessOptionsMetadata + ).map(({ value, label }) => ( + + ))} + +
+
- ) @@ -99,4 +194,5 @@ export const AccessAdd = ({ onAdd }) => { AccessAdd.propTypes = { onAdd: PropTypes.func.isRequired, + dataSharing: PropTypes.bool, } diff --git a/components/sharing-dialog/src/access-list/access-list.js b/components/sharing-dialog/src/access-list/access-list.js index 8949cf73ba..e71faa95a6 100644 --- a/components/sharing-dialog/src/access-list/access-list.js +++ b/components/sharing-dialog/src/access-list/access-list.js @@ -20,121 +20,178 @@ export const AccessList = ({ allowPublicAccess, users, groups, -}) => ( - <> - {i18n.t('Users and groups that currently have access')} -
-
{i18n.t('User / Group')}
-
{i18n.t('Access level')}
-
-
- - onChange({ type: 'public', access: newAccess }) - } - /> - {groups.map(({ id, name, access }) => ( + dataSharing, +}) => { + const accessOptions = [ACCESS_NONE, ACCESS_VIEW_ONLY, ACCESS_VIEW_AND_EDIT] + return ( + <> + + {i18n.t('Users and groups that currently have access')} + +
+
+ {i18n.t('User / Group')} +
+
+ {i18n.t('Access level')} +
+
+
- onChange({ - type: 'group', - id, - access: newAccess, - }) + onChange({ type: 'public', access: newAccess }) } - onRemove={() => onRemove({ type: 'group', id })} + dataSharing={dataSharing} + allUsersItem={true} /> - ))} - {users.map( - ({ id, name, access }) => - access && ( - - onChange({ - type: 'user', - id, - access: newAccess, - }) - } - onRemove={() => onRemove({ type: 'user', id })} - /> - ) - )} -
- - -) + .header-end-column-data { + margin-inline-start: auto; + width: 65%; + } + + .hea { + display: inline-block; + margin-inline-start: 8px; + } + + .list { + display: flex; + flex-direction: column; + overflow-y: auto; + } + `} + + ) +} AccessList.propTypes = { allowPublicAccess: PropTypes.bool.isRequired, + dataSharing: PropTypes.bool.isRequired, groups: PropTypes.arrayOf( PropTypes.shape({ - access: PropTypes.oneOf([ - ACCESS_NONE, - ACCESS_VIEW_ONLY, - ACCESS_VIEW_AND_EDIT, - ]).isRequired, id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, + access: PropTypes.shape({ + data: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + metadata: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + }), }) ).isRequired, - publicAccess: PropTypes.oneOf([ - ACCESS_NONE, - ACCESS_VIEW_ONLY, - ACCESS_VIEW_AND_EDIT, - ]).isRequired, + publicAccess: PropTypes.shape({ + data: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + metadata: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + }).isRequired, users: PropTypes.arrayOf( PropTypes.shape({ - access: PropTypes.oneOf([ - ACCESS_NONE, - ACCESS_VIEW_ONLY, - ACCESS_VIEW_AND_EDIT, - ]).isRequired, + access: PropTypes.shape({ + data: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + metadata: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + }).isRequired, id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, }) diff --git a/components/sharing-dialog/src/access-list/list-item-context.js b/components/sharing-dialog/src/access-list/list-item-context.js index e996cfee97..894fa4a73a 100644 --- a/components/sharing-dialog/src/access-list/list-item-context.js +++ b/components/sharing-dialog/src/access-list/list-item-context.js @@ -2,32 +2,22 @@ import { colors } from '@dhis2/ui-constants' import PropTypes from 'prop-types' import React from 'react' import { - ACCESS_NONE, - ACCESS_VIEW_ONLY, - ACCESS_VIEW_AND_EDIT, + SHARE_TARGET_PUBLIC, + SHARE_TARGET_GROUP, + SHARE_TARGET_USER, } from '../constants.js' import i18n from '../locales/index.js' -export const ListItemContext = ({ access }) => { - let message - - switch (access) { - case ACCESS_NONE: - message = i18n.t('No access') - break - case ACCESS_VIEW_ONLY: - message = i18n.t('Can view') - break - case ACCESS_VIEW_AND_EDIT: - message = i18n.t('Can view and edit') - break - default: - message = '' - } +const LABELS = { + [SHARE_TARGET_PUBLIC]: i18n.t('Anyone logged in'), + [SHARE_TARGET_GROUP]: i18n.t('User group'), + [SHARE_TARGET_USER]: i18n.t('User'), +} +export const ListItemContext = ({ target, id }) => { return ( -

- {message} + <> +

{target === SHARE_TARGET_USER ? id : LABELS[target] ?? ''}

-

+ ) } ListItemContext.propTypes = { - access: PropTypes.oneOf([ - ACCESS_NONE, - ACCESS_VIEW_ONLY, - ACCESS_VIEW_AND_EDIT, + target: PropTypes.oneOf([ + SHARE_TARGET_PUBLIC, + SHARE_TARGET_GROUP, + SHARE_TARGET_USER, ]).isRequired, + id: PropTypes.string, } diff --git a/components/sharing-dialog/src/access-list/list-item.js b/components/sharing-dialog/src/access-list/list-item.js index 207824b1a4..e9c7809531 100644 --- a/components/sharing-dialog/src/access-list/list-item.js +++ b/components/sharing-dialog/src/access-list/list-item.js @@ -19,14 +19,24 @@ import i18n from '../locales/index.js' import { ListItemContext } from './list-item-context.js' import { ListItemIcon } from './list-item-icon.js' +const isRemoveEnabled = ({ dataSharing, accessOtherField }) => { + if (!dataSharing) { + return true + } + return accessOtherField === ACCESS_NONE +} + export const ListItem = ({ name, + id, target, access, accessOptions, disabled, onChange, onRemove, + dataSharing, + allUsersItem = false, }) => { const isFetching = useContext(FetchingContext) const { isDisconnected: offline } = useDhis2ConnectionStatus() @@ -39,38 +49,97 @@ export const ListItem = ({ return ( <>
-
+

{name}

- +
-
- onChange(selected)} - > - {accessOptions.map((value) => ( - - ))} - {isRemovableTarget(target) && ( - - )} - +
+ {dataSharing && ( +
+ + onChange({ ...access, data: selected }) + } + > + {accessOptions.map((value) => ( + + ))} + {isRemovableTarget(target) && + isRemoveEnabled({ + accessOtherField: access.metadata, + dataSharing, + }) && ( + + )} + +
+ )} +
+ + onChange({ ...access, metadata: selected }) + } + > + {accessOptions.map((value) => ( + + ))} + {isRemovableTarget(target) && + isRemoveEnabled({ + accessOtherField: access.data, + dataSharing, + }) && ( + + )} + +
@@ -78,11 +147,17 @@ export const ListItem = ({ .wrapper { display: flex; padding: 4px 8px; + justify-content: space-between; + } + + .detailsMetadata { + display: flex; + width: 65%; } - .details { + .detailsWithData { display: flex; - flex: 2; + width: 35%; } .details-text { @@ -97,7 +172,16 @@ export const ListItem = ({ padding: 0; } + .selectWrapperMetadata { + display: flex; + width: 35%; + } + .selectWrapperWithData { + display: flex; + width: 65%; + } .select { + margin-inline-start: 8px; flex: 1; } `} @@ -106,14 +190,22 @@ export const ListItem = ({ } ListItem.propTypes = { - access: PropTypes.oneOf([ - ACCESS_NONE, - ACCESS_VIEW_ONLY, - ACCESS_VIEW_AND_EDIT, - ]).isRequired, + access: PropTypes.shape({ + data: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + metadata: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + }).isRequired, accessOptions: PropTypes.arrayOf( PropTypes.oneOf([ACCESS_NONE, ACCESS_VIEW_ONLY, ACCESS_VIEW_AND_EDIT]) ).isRequired, + dataSharing: PropTypes.bool.isRequired, name: PropTypes.string.isRequired, target: PropTypes.oneOf([ SHARE_TARGET_PUBLIC, @@ -121,6 +213,8 @@ ListItem.propTypes = { SHARE_TARGET_USER, ]).isRequired, onChange: PropTypes.func.isRequired, + allUsersItem: PropTypes.bool, disabled: PropTypes.bool, + id: PropTypes.string, onRemove: PropTypes.func, } diff --git a/components/sharing-dialog/src/autocomplete/sharing-autocomplete.js b/components/sharing-dialog/src/autocomplete/sharing-autocomplete.js index 45b45fe2f7..6a64510c20 100644 --- a/components/sharing-dialog/src/autocomplete/sharing-autocomplete.js +++ b/components/sharing-dialog/src/autocomplete/sharing-autocomplete.js @@ -70,7 +70,7 @@ export const SharingAutocomplete = ({ selected, onSelection }) => { return ( item with is visible When the user sets the access level to Then the access control should be set to - And the section should be labeled for + And the section should be labeled for Scenarios: | initial | target | changed | diff --git a/components/sharing-dialog/src/features/access-level-change/index.js b/components/sharing-dialog/src/features/access-level-change/index.js index 099ceecc1c..3245328b1c 100644 --- a/components/sharing-dialog/src/features/access-level-change/index.js +++ b/components/sharing-dialog/src/features/access-level-change/index.js @@ -22,7 +22,7 @@ Given('a sharing dialog with all users item with no access is visible', () => { cy.contains('.details-text', 'All users') .should('be.visible') - .contains('No access') + .contains('Anyone logged in') .should('be.visible') .closest('.wrapper') .as('all-users-list-item') @@ -45,7 +45,7 @@ Given('a sharing dialog with user item with view is visible', () => { cy.contains('.details-text', 'A user') .should('be.visible') - .contains('Can view') + .contains('user-1') .should('be.visible') .closest('.wrapper') .as('user-list-item') @@ -68,7 +68,7 @@ Given('a sharing dialog with group item with view is visible', () => { cy.contains('.details-text', 'A group') .should('be.visible') - .contains('Can view') + .contains('User group') .should('be.visible') .closest('.wrapper') .as('group-list-item') @@ -235,26 +235,20 @@ Then('the group access control should be set to view and edit', () => { * the section should be labeled for */ -Then('the all users section should be labeled for view only', () => { +Then('the all users section should be labeled for all users', () => { cy.get('@all-users-list-item') - .contains('.details-text', 'Can view') + .contains('.details-text', 'Anyone logged in') .should('be.visible') }) -Then('the all users section should be labeled for view and edit', () => { - cy.get('@all-users-list-item') - .contains('.details-text', 'Can view and edit') - .should('be.visible') -}) - -Then('the user section should be labeled for view and edit', () => { +Then('the user section should be labeled for user', () => { cy.get('@user-list-item') - .contains('.details-text', 'Can view and edit') + .contains('.details-text', 'user-1') .should('be.visible') }) -Then('the group section should be labeled for view and edit', () => { +Then('the group section should be labeled for group', () => { cy.get('@group-list-item') - .contains('.details-text', 'Can view and edit') + .contains('.details-text', 'User group') .should('be.visible') }) diff --git a/components/sharing-dialog/src/features/access-level-remove/index.js b/components/sharing-dialog/src/features/access-level-remove/index.js index 998224fdba..9029c1560a 100644 --- a/components/sharing-dialog/src/features/access-level-remove/index.js +++ b/components/sharing-dialog/src/features/access-level-remove/index.js @@ -21,10 +21,14 @@ Given('a sharing dialog with user item with view is visible', () => { cy.contains('.details-text', 'A user') .should('be.visible') - .contains('Can view') + .contains('user-1') .should('be.visible') .closest('.wrapper') .as('user-list-item') + + cy.get('@user-list-item') + .contains('[data-test="dhis2-uicore-singleselect"]', 'View only') + .should('be.visible') }) Given('a sharing dialog with user item with view and edit is visible', () => { @@ -36,10 +40,14 @@ Given('a sharing dialog with user item with view and edit is visible', () => { cy.contains('.details-text', 'A user') .should('be.visible') - .contains('Can view and edit') + .contains('user-1') .should('be.visible') .closest('.wrapper') .as('user-list-item') + + cy.get('@user-list-item') + .contains('[data-test="dhis2-uicore-singleselect"]', 'View and edit') + .should('be.visible') }) Given('a sharing dialog with group item with view is visible', () => { @@ -51,10 +59,14 @@ Given('a sharing dialog with group item with view is visible', () => { cy.contains('.details-text', 'A group') .should('be.visible') - .contains('Can view') + .contains('User group') .should('be.visible') .closest('.wrapper') .as('group-list-item') + + cy.get('@group-list-item') + .contains('[data-test="dhis2-uicore-singleselect"]', 'View only') + .should('be.visible') }) Given('a sharing dialog with group item with view and edit is visible', () => { @@ -66,10 +78,14 @@ Given('a sharing dialog with group item with view and edit is visible', () => { cy.contains('.details-text', 'A group') .should('be.visible') - .contains('Can view and edit') + .contains('User group') .should('be.visible') .closest('.wrapper') .as('group-list-item') + + cy.get('@group-list-item') + .contains('[data-test="dhis2-uicore-singleselect"]', 'View and edit') + .should('be.visible') }) /** diff --git a/components/sharing-dialog/src/features/add-entity/index.js b/components/sharing-dialog/src/features/add-entity/index.js index b64dbc7d61..8dfea087db 100644 --- a/components/sharing-dialog/src/features/add-entity/index.js +++ b/components/sharing-dialog/src/features/add-entity/index.js @@ -42,7 +42,7 @@ When('the user gives user view only access', () => { cy.get('[placeholder="Search"]').type('A user') cy.contains('[data-test="dhis2-uicore-menuitem"]', 'A user').click() - cy.contains('Select a level').click() + cy.contains('Choose a level').click() cy.contains( '[data-test="dhis2-uicore-singleselectoption"]', 'View only' @@ -69,7 +69,7 @@ When('the user gives user view and edit access', () => { cy.get('[placeholder="Search"]').type('A user') cy.contains('[data-test="dhis2-uicore-menuitem"]', 'A user').click() - cy.contains('Select a level').click() + cy.contains('Choose a level').click() cy.contains( '[data-test="dhis2-uicore-singleselectoption"]', 'View and edit' @@ -96,7 +96,7 @@ When('the user gives group view only access', () => { cy.get('[placeholder="Search"]').type('A group') cy.contains('[data-test="dhis2-uicore-menuitem"]', 'A group').click() - cy.contains('Select a level').click() + cy.contains('Choose a level').click() cy.contains( '[data-test="dhis2-uicore-singleselectoption"]', 'View only' @@ -123,7 +123,7 @@ When('the user gives group view and edit access', () => { cy.get('[placeholder="Search"]').type('A group') cy.contains('[data-test="dhis2-uicore-menuitem"]', 'A group').click() - cy.contains('Select a level').click() + cy.contains('Choose a level').click() cy.contains( '[data-test="dhis2-uicore-singleselectoption"]', 'View and edit' @@ -152,7 +152,7 @@ Then( () => { cy.contains('.wrapper', 'A user').should('be.visible').as('user-item') - cy.get('@user-item').contains('Can view').should('be.visible') + cy.get('@user-item').contains('user-1').should('be.visible') cy.get('@user-item').contains('View only').should('be.visible') } ) @@ -162,7 +162,7 @@ Then( () => { cy.contains('.wrapper', 'A user').should('be.visible').as('user-item') - cy.get('@user-item').contains('Can view and edit').should('be.visible') + cy.get('@user-item').contains('user-1').should('be.visible') cy.get('@user-item').contains('View and edit').should('be.visible') } ) @@ -172,7 +172,7 @@ Then( () => { cy.contains('.wrapper', 'A group').should('be.visible').as('group-item') - cy.get('@group-item').contains('Can view').should('be.visible') + cy.get('@group-item').contains('User group').should('be.visible') cy.get('@group-item').contains('View only').should('be.visible') } ) @@ -182,7 +182,7 @@ Then( () => { cy.contains('.wrapper', 'A group').should('be.visible').as('group-item') - cy.get('@group-item').contains('Can view and edit').should('be.visible') + cy.get('@group-item').contains('User group').should('be.visible') cy.get('@group-item').contains('View and edit').should('be.visible') } ) diff --git a/components/sharing-dialog/src/features/data-sharing.feature b/components/sharing-dialog/src/features/data-sharing.feature new file mode 100644 index 0000000000..35f8d0f70b --- /dev/null +++ b/components/sharing-dialog/src/features/data-sharing.feature @@ -0,0 +1,91 @@ +Feature: Setting data sharing is possible when sharing dialog is set to include data sharing + + + + Scenario Outline: User can add a new entity with specified data and metadata sharing + Given a sharing dialog that allows adding user and group entities with data sharing is visible + When the user selects a new entity + And the user chooses metadata access + And the user chooses data access + And the user clicks Give access button to give target data access and metadata access + Then the should be added to the access list + And the should have metadata access + And the should have data access + And the autocomplete input should be cleared + + Scenarios: + | target | datalevel | metadatalevel | datalevelstring | metadatalevelstring | + | user | view only | view only | "View only" | "View only" | + | group | view only | view only | "View only" | "View only" | + +# Scenario: user can change access type + + Scenario Outline: User can change the data access level from to for with metadata access + Given a sharing dialog with item with data access and metadata access + When the user sets the data access level to , and leaves metadata as + Then the data access control should be set to + And the metadata access control should remain + + Scenarios: + | target | initial | changed | metadata | + | all users | "No access" | "View and edit" | "No access" | + | user | "View only" | "View and edit" | "No access" | + | group | "View and edit" | "View only" | "View only" | + + +# Scenario: user can remove access from data access select if metadata is No access + + Scenario Outline: User can remove access for a if metadata access is No access and data access is + Given a sharing dialog with item with data access and "No access" metadata access + When the user clicks to remove the access for the from the "Data" access select + Then the item should be removed + + Scenarios: + | target | data-access-level | + | user | "View only" | + | group | "View only" | + +# Scenario: user can remove access from metadata access select if data is No access + Scenario Outline: User can remove access for a if data access is No access and metadata access is + Given a sharing dialog with item with "No access" data access and metadata access + When the user clicks to remove the access for the from the "Metadata" access select + Then the item should be removed + + Scenarios: + | target | metadata-access-level | + | user | "View only" | + | group | "View and edit" | + +# Scenario: user cannot remove access from Data access level by if and are both not No access + + Scenario Outline: User cannot remove access for a from Data access when metadata access is set to something other than No access + Given a sharing dialog with item with data access and metadata access + Then the "Data" access level options do not contain Remove access + + Scenarios: + | target | data-access-level | metadata-access-level | + | user | "View only" | "View only" | + | group | "View and edit" | "View only" | + +# Scenario: user cannot remove access from Data access level for all users + + Scenario Outline: User cannot remove access for all users + Given a sharing dialog with item with data access and metadata access + Then the "Data" access level options do not contain Remove access + + Scenarios: + | target | data-access-level | metadata-access-level | + | all users | "No access" | "No access" | + +# Scenario: user can remove access from data access level by first setting metadata to No Access + + Scenario Outline: User can remove access for a from data access when metadata access is set to No access + Given a sharing dialog with item with data access and metadata access + When the user sets the metadata access level to No access and leaves data access as + And the user clicks to remove the access for the from the access select + Then the item should be removed + + Scenarios: + | target | data-access-level | metadata-access-level | type | + | user | "View only" | "View only" | "Data" | + | group | "View only" | "View and edit" | "Data" | diff --git a/components/sharing-dialog/src/features/data-sharing/index.js b/components/sharing-dialog/src/features/data-sharing/index.js new file mode 100644 index 0000000000..96d4e634c3 --- /dev/null +++ b/components/sharing-dialog/src/features/data-sharing/index.js @@ -0,0 +1,773 @@ +import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor' +import { + noAccess, + searchUser, + userNoAccess, + searchGroup, + getGroupWithDataAndMetadataAccess, + getUserWithDataAndMetadataAccess, + getAllUsersWithDataAndMetadataAccess, + groupNoAccess, +} from '../fixtures/index.js' + +/** + * a sharing dialog that allows adding entities is visible + */ + +Given( + 'a sharing dialog that allows adding user and group entities with data sharing is visible', + () => { + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: noAccess, + }) + + cy.visitStory('sharing-dialog', 'data') + cy.contains('Give access to a user or group').should('be.visible') + cy.contains('Data access level').should('be.visible') + cy.contains('Metadata access level').should('be.visible') + } +) + +When('the user selects a new user entity', () => { + cy.intercept('GET', '/api/38/sharing/search?key=A%20user', { + body: searchUser, + }) + cy.get('[placeholder="Search"]').type('A user') + cy.contains('[data-test="dhis2-uicore-menuitem"]', 'A user').click() +}) + +When('the user selects a new group entity', () => { + cy.intercept('GET', '/api/38/sharing/search?key=A%20group', { + body: searchGroup, + }) + cy.get('[placeholder="Search"]').type('A group') + cy.contains('[data-test="dhis2-uicore-menuitem"]', 'A group').click() +}) + +When('the user chooses view only data access', () => { + cy.contains('Data access level') + .parent() + .within(() => { + cy.contains('Choose a level').click() + }) + cy.contains( + '[data-test="dhis2-uicore-singleselectoption"]', + 'View only' + ).click() +}) + +When('the user chooses view only metadata access', () => { + cy.contains('Metadata access level') + .parent() + .within(() => { + cy.contains('Choose a level').click() + }) + cy.contains( + '[data-test="dhis2-uicore-singleselectoption"]', + 'View only' + ).click() +}) + +When( + 'the user clicks Give access button to give target user {string} data access and {string} metadata access', + (dataaccess, metadataaccess) => { + cy.intercept( + 'PUT', + '/api/38/sharing?type=visualization&id=id', + (req) => { + const expected = { + object: getUserWithDataAndMetadataAccess( + metadataaccess, + dataaccess + )?.object, + } + expect(req.body).to.deep.equal(expected) + req.reply({ statusCode: 200 }) + } + ) + + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getUserWithDataAndMetadataAccess(metadataaccess, dataaccess), + }) + + cy.contains('button', 'Give access').click() + } +) + +When( + 'the user clicks Give access button to give target group {string} data access and {string} metadata access', + (dataaccess, metadataaccess) => { + cy.intercept( + 'PUT', + '/api/38/sharing?type=visualization&id=id', + (req) => { + const expected = { + object: getGroupWithDataAndMetadataAccess( + metadataaccess, + dataaccess + )?.object, + } + expect(req.body).to.deep.equal(expected) + req.reply({ statusCode: 200 }) + } + ) + + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getGroupWithDataAndMetadataAccess(metadataaccess, dataaccess), + }) + + cy.contains('button', 'Give access').click() + } +) + +Then('the user should be added to the access list', () => { + cy.contains('.wrapper', 'A user').should('be.visible').as('user-item') + + cy.get('@user-item').contains('user-1').should('be.visible') +}) + +Then('the group should be added to the access list', () => { + cy.contains('.wrapper', 'A group').should('be.visible').as('group-item') + + cy.get('@group-item').contains('User group').should('be.visible') +}) + +Then('the user should have {string} metadata access', (metadataString) => { + cy.contains('.wrapper', 'A user').as('user-item') + + cy.get('@user-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).within(() => { + cy.contains(metadataString).should('be.visible') + }) + }) +}) + +Then('the group should have {string} metadata access', (metadataString) => { + cy.contains('.wrapper', 'A group').as('group-item') + + cy.get('@group-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).within(() => { + cy.contains(metadataString).should('be.visible') + }) + }) +}) + +Then('the user should have {string} data access', (dataString) => { + cy.contains('.wrapper', 'A user').as('user-item') + + cy.get('@user-item').within(() => { + cy.contains('[data-test="dhis2-uicore-singleselect"]', 'Data').within( + () => { + cy.contains(dataString).should('be.visible') + } + ) + }) +}) + +Then('the group should have {string} data access', (dataString) => { + cy.contains('.wrapper', 'A group').as('group-item') + + cy.get('@group-item').within(() => { + cy.contains('[data-test="dhis2-uicore-singleselect"]', 'Data').within( + () => { + cy.contains(dataString).should('be.visible') + } + ) + }) +}) + +Then('the autocomplete input should be cleared', () => { + cy.get('form input').invoke('val').should('be.empty') +}) + +Given( + 'a sharing dialog with user item with {string} data access and {string} metadata access', + (datalevel, metadatalevel) => { + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getUserWithDataAndMetadataAccess(metadatalevel, datalevel), + }) + cy.visitStory('sharing-dialog', 'data') + cy.contains('Sharing and access').should('be.visible') + + cy.contains('.details-text', 'A user') + .should('be.visible') + .contains('user-1') + .should('be.visible') + .closest('.wrapper') + .as('user-list-item') + + cy.get('@user-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).within(() => { + cy.contains(datalevel).should('be.visible') + }) + }) + + cy.get('@user-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).within(() => { + cy.contains(metadatalevel).should('be.visible') + }) + }) + } +) + +Given( + 'a sharing dialog with group item with {string} data access and {string} metadata access', + (datalevel, metadatalevel) => { + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getGroupWithDataAndMetadataAccess(metadatalevel, datalevel), + }) + cy.visitStory('sharing-dialog', 'data') + cy.contains('Sharing and access').should('be.visible') + + cy.contains('.details-text', 'A group') + .should('be.visible') + .contains('User group') + .should('be.visible') + .closest('.wrapper') + .as('group-list-item') + + cy.get('@group-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).within(() => { + cy.contains(datalevel).should('be.visible') + }) + }) + + cy.get('@group-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).within(() => { + cy.contains(metadatalevel).should('be.visible') + }) + }) + } +) + +Given( + 'a sharing dialog with all users item with {string} data access and {string} metadata access', + (datalevel, metadatalevel) => { + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getAllUsersWithDataAndMetadataAccess( + metadatalevel, + datalevel + ), + }) + cy.visitStory('sharing-dialog', 'data') + cy.contains('Sharing and access').should('be.visible') + + cy.contains('.details-text', 'All users') + .should('be.visible') + .contains('Anyone logged in') + .should('be.visible') + .closest('.wrapper') + .as('all-users-list-item') + + cy.get('@all-users-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).within(() => { + cy.contains(datalevel).should('be.visible') + }) + }) + + cy.get('@all-users-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).within(() => { + cy.contains(metadatalevel).should('be.visible') + }) + }) + } +) + +When( + 'the user sets the user data access level to {string}, and leaves metadata as {string}', + (dataAccess, metadataAccess) => { + cy.intercept( + 'PUT', + '/api/38/sharing?type=visualization&id=id', + (req) => { + const expected = { + object: getUserWithDataAndMetadataAccess( + metadataAccess, + dataAccess + )?.object, + } + expect(req.body).to.deep.equal(expected) + req.reply({ statusCode: 200 }) + } + ) + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getUserWithDataAndMetadataAccess(metadataAccess, dataAccess), + }) + + cy.get('@user-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).click() + }) + + cy.contains( + '[data-test="dhis2-uicore-select-menu-menuwrapper"] [data-test="dhis2-uicore-singleselectoption"]', + dataAccess + ) + .should('be.visible') + .click() + + // Menu should be closed before continuing + cy.get('[data-test="dhis2-uicore-select-menu-menuwrapper"]').should( + 'not.exist' + ) + } +) + +When( + 'the user sets the group data access level to {string}, and leaves metadata as {string}', + (dataAccess, metadataAccess) => { + cy.intercept( + 'PUT', + '/api/38/sharing?type=visualization&id=id', + (req) => { + const expected = { + object: getGroupWithDataAndMetadataAccess( + metadataAccess, + dataAccess + )?.object, + } + expect(req.body).to.deep.equal(expected) + req.reply({ statusCode: 200 }) + } + ) + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getGroupWithDataAndMetadataAccess(metadataAccess, dataAccess), + }) + + cy.get('@group-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).click() + }) + + cy.contains( + '[data-test="dhis2-uicore-select-menu-menuwrapper"] [data-test="dhis2-uicore-singleselectoption"]', + dataAccess + ) + .should('be.visible') + .click() + + // Menu should be closed before continuing + cy.get('[data-test="dhis2-uicore-select-menu-menuwrapper"]').should( + 'not.exist' + ) + } +) + +When( + 'the user sets the all users data access level to {string}, and leaves metadata as {string}', + (dataAccess, metadataAccess) => { + cy.intercept( + 'PUT', + '/api/38/sharing?type=visualization&id=id', + (req) => { + const expected = { + object: getAllUsersWithDataAndMetadataAccess( + metadataAccess, + dataAccess + )?.object, + } + expect(req.body).to.deep.equal(expected) + req.reply({ statusCode: 200 }) + } + ) + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getAllUsersWithDataAndMetadataAccess( + metadataAccess, + dataAccess + ), + }) + + cy.get('@all-users-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).click() + }) + + cy.contains( + '[data-test="dhis2-uicore-select-menu-menuwrapper"] [data-test="dhis2-uicore-singleselectoption"]', + dataAccess + ) + .should('be.visible') + .click() + + // Menu should be closed before continuing + cy.get('[data-test="dhis2-uicore-select-menu-menuwrapper"]').should( + 'not.exist' + ) + } +) + +Then( + 'the user data access control should be set to {string}', + (newDataAccess) => { + cy.get('@user-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).within(() => { + cy.contains(newDataAccess).should('be.visible') + }) + }) + } +) + +Then( + 'the group data access control should be set to {string}', + (newDataAccess) => { + cy.get('@group-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).within(() => { + cy.contains(newDataAccess).should('be.visible') + }) + }) + } +) + +Then( + 'the all users data access control should be set to {string}', + (newDataAccess) => { + cy.get('@all-users-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).within(() => { + cy.contains(newDataAccess).should('be.visible') + }) + }) + } +) + +Then( + 'the user metadata access control should remain {string}', + (metadataAccess) => { + cy.get('@user-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).within(() => { + cy.contains(metadataAccess).should('be.visible') + }) + }) + } +) + +Then( + 'the group metadata access control should remain {string}', + (metadataAccess) => { + cy.get('@group-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).within(() => { + cy.contains(metadataAccess).should('be.visible') + }) + }) + } +) + +Then( + 'the all users metadata access control should remain {string}', + (metadataAccess) => { + cy.get('@all-users-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).within(() => { + cy.contains(metadataAccess).should('be.visible') + }) + }) + } +) + +// a sharing dialog with item with for data and No access for metadata is visible + +When( + 'a sharing dialog with user item with {string} for data and No access for metadata is visible', + (dataAccess) => { + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getUserWithDataAndMetadataAccess('No access', dataAccess), + }) + cy.visitStory('sharing-dialog', 'data') + cy.contains('Sharing and access').should('be.visible') + + cy.contains('.details-text', 'A user') + .should('be.visible') + .contains('user-1') + .should('be.visible') + .closest('.wrapper') + .as('user-list-item') + + cy.get('@user-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).within(() => { + cy.contains(dataAccess).should('be.visible') + }) + }) + + cy.get('@user-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).within(() => { + cy.contains('No access').should('be.visible') + }) + }) + } +) + +When( + 'a sharing dialog with group item with {string} for data and No access for metadata is visible', + (dataAccess) => { + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getGroupWithDataAndMetadataAccess('No access', dataAccess), + }) + cy.visitStory('sharing-dialog', 'data') + cy.contains('Sharing and access').should('be.visible') + + cy.contains('.details-text', 'A group') + .should('be.visible') + .contains('User group') + .should('be.visible') + .closest('.wrapper') + .as('group-list-item') + + cy.get('@group-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Data' + ).within(() => { + cy.contains(dataAccess).should('be.visible') + }) + }) + + cy.get('@group-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).within(() => { + cy.contains('No access').should('be.visible') + }) + }) + } +) + +When( + 'the user clicks to remove the access for the user from the {string} access select', + (type) => { + cy.intercept( + 'PUT', + '/api/38/sharing?type=visualization&id=id', + (req) => { + const expected = { + object: userNoAccess.object, + } + expect(req.body).to.deep.equal(expected) + req.reply({ statusCode: 200 }) + } + ) + + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: userNoAccess, + }) + + cy.get('@user-list-item').within(() => { + cy.contains('[data-test="dhis2-uicore-singleselect"]', type).click() + }) + + cy.contains('Remove access').should('be.visible').click() + } +) + +When( + 'the user clicks to remove the access for the group from the {string} access select', + (type) => { + cy.intercept( + 'PUT', + '/api/38/sharing?type=visualization&id=id', + (req) => { + const expected = { + object: groupNoAccess.object, + } + expect(req.body).to.deep.equal(expected) + req.reply({ statusCode: 200 }) + } + ) + + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: groupNoAccess, + }) + + cy.get('@group-list-item').within(() => { + cy.contains('[data-test="dhis2-uicore-singleselect"]', type).click() + }) + + cy.contains('Remove access').should('be.visible').click() + } +) + +Then('the user item should be removed', () => { + cy.contains('.details-text', 'A user').should('not.exist') +}) + +Then('the group item should be removed', () => { + cy.contains('.details-text', 'A group').should('not.exist') +}) + +When( + 'the user sets the user metadata access level to No access and leaves data access as {string}', + (dataAccess) => { + cy.intercept( + 'PUT', + '/api/38/sharing?type=visualization&id=id', + (req) => { + const expected = { + object: getUserWithDataAndMetadataAccess( + 'No access', + dataAccess + )?.object, + } + expect(req.body).to.deep.equal(expected) + req.reply({ statusCode: 200 }) + } + ) + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getUserWithDataAndMetadataAccess('No access', dataAccess), + }) + + cy.get('@user-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).click() + }) + + cy.contains( + '[data-test="dhis2-uicore-select-menu-menuwrapper"] [data-test="dhis2-uicore-singleselectoption"]', + 'No access' + ) + .should('be.visible') + .click() + + // Menu should be closed before continuing + cy.get('[data-test="dhis2-uicore-select-menu-menuwrapper"]').should( + 'not.exist' + ) + } +) + +When( + 'the user sets the group metadata access level to No access and leaves data access as {string}', + (dataAccess) => { + cy.intercept( + 'PUT', + '/api/38/sharing?type=visualization&id=id', + (req) => { + const expected = { + object: getGroupWithDataAndMetadataAccess( + 'No access', + dataAccess + )?.object, + } + expect(req.body).to.deep.equal(expected) + req.reply({ statusCode: 200 }) + } + ) + cy.intercept('GET', '/api/38/sharing?type=visualization&id=id', { + body: getGroupWithDataAndMetadataAccess('No access', dataAccess), + }) + + cy.get('@group-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + 'Metadata' + ).click() + }) + + cy.contains( + '[data-test="dhis2-uicore-select-menu-menuwrapper"] [data-test="dhis2-uicore-singleselectoption"]', + 'No access' + ) + .should('be.visible') + .click() + + // Menu should be closed before continuing + cy.get('[data-test="dhis2-uicore-select-menu-menuwrapper"]').should( + 'not.exist' + ) + } +) + +Then( + 'the user {string} access level options do not contain Remove access', + (sharingType) => { + cy.get('@user-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + sharingType + ).click() + }) + + cy.contains('Remove access').should('not.exist') + } +) + +Then( + 'the group {string} access level options do not contain Remove access', + (sharingType) => { + cy.get('@group-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + sharingType + ).click() + }) + + cy.contains('Remove access').should('not.exist') + } +) + +Then( + 'the all users {string} access level options do not contain Remove access', + (sharingType) => { + cy.get('@all-users-list-item').within(() => { + cy.contains( + '[data-test="dhis2-uicore-singleselect"]', + sharingType + ).click() + }) + + cy.contains('Remove access').should('not.exist') + } +) diff --git a/components/sharing-dialog/src/features/fixtures/get-object-data-metadata-access.js b/components/sharing-dialog/src/features/fixtures/get-object-data-metadata-access.js new file mode 100644 index 0000000000..144fa4c196 --- /dev/null +++ b/components/sharing-dialog/src/features/fixtures/get-object-data-metadata-access.js @@ -0,0 +1,84 @@ +const convertAccess = (access) => { + if (access === 'View only') { + return 'r-' + } + if (access === 'View and edit') { + return 'rw' + } + return '--' +} + +export const getUserWithDataAndMetadataAccess = ( + metadataaccess, + dataaccess +) => ({ + meta: { + allowExternalAccess: false, + allowPublicAccess: false, + }, + object: { + id: 'id', + name: '', + displayName: '', + externalAccess: false, + publicAccess: '--------', + userAccesses: [ + { + id: 'user-1', + name: 'A user', + access: `${convertAccess(metadataaccess)}${convertAccess( + dataaccess + )}----`, + }, + ], + userGroupAccesses: [], + }, +}) + +export const getGroupWithDataAndMetadataAccess = ( + metadataaccess, + dataaccess +) => ({ + meta: { + allowExternalAccess: false, + allowPublicAccess: false, + }, + object: { + id: 'id', + name: '', + displayName: '', + externalAccess: false, + publicAccess: '--------', + userAccesses: [], + userGroupAccesses: [ + { + id: 'group-1', + name: 'A group', + access: `${convertAccess(metadataaccess)}${convertAccess( + dataaccess + )}----`, + }, + ], + }, +}) + +export const getAllUsersWithDataAndMetadataAccess = ( + metadataaccess, + dataaccess +) => ({ + meta: { + allowExternalAccess: true, + allowPublicAccess: true, + }, + object: { + id: 'id', + name: '', + displayName: '', + externalAccess: false, + publicAccess: `${convertAccess(metadataaccess)}${convertAccess( + dataaccess + )}----`, + userAccesses: [], + userGroupAccesses: [], + }, +}) diff --git a/components/sharing-dialog/src/features/fixtures/index.js b/components/sharing-dialog/src/features/fixtures/index.js index c29b40e121..b6b41b2039 100644 --- a/components/sharing-dialog/src/features/fixtures/index.js +++ b/components/sharing-dialog/src/features/fixtures/index.js @@ -4,6 +4,11 @@ import allUsersViewEditAccess from './all-users-view-edit-access.json' import dashboardSharing from './dashboard-sharing.json' import dashboards from './dashboards.json' import disabledAccess from './disabled-access.json' +import { + getUserWithDataAndMetadataAccess, + getGroupWithDataAndMetadataAccess, + getAllUsersWithDataAndMetadataAccess, +} from './get-object-data-metadata-access.js' import groupNoAccess from './group-no-access.json' import groupViewAccess from './group-view-access.json' import groupViewEditAccess from './group-view-edit-access.json' @@ -34,4 +39,7 @@ export { userViewEditAccess, withDisplayname, withoutDisplayname, + getGroupWithDataAndMetadataAccess, + getUserWithDataAndMetadataAccess, + getAllUsersWithDataAndMetadataAccess, } diff --git a/components/sharing-dialog/src/helpers/__tests__/helpers.test.js b/components/sharing-dialog/src/helpers/__tests__/helpers.test.js index a1d95b1d9a..7c8679dce4 100644 --- a/components/sharing-dialog/src/helpers/__tests__/helpers.test.js +++ b/components/sharing-dialog/src/helpers/__tests__/helpers.test.js @@ -6,70 +6,85 @@ import { SHARE_TARGET_PUBLIC, } from '../../constants.js' import { - convertAccessToConstant, - convertConstantToAccess, + convertAccessToConstantObject, + convertConstantObjectToAccess, isRemovableTarget, } from '../helpers.js' describe('helpers', () => { - describe('convertAccessToConstant', () => { + describe('convertAccessToConstantObject', () => { it('disallows access if the access string is undefined', () => { - expect(convertAccessToConstant()).toEqual(ACCESS_NONE) + const NO_ACCESS_OBJECT = { + data: ACCESS_NONE, + metadata: ACCESS_NONE, + } + expect(convertAccessToConstantObject()).toEqual(NO_ACCESS_OBJECT) }) it('disallows access if the access string is invalid', () => { - expect(convertAccessToConstant('invalid-access-string')).toEqual( - ACCESS_NONE - ) + const NO_ACCESS_OBJECT = { + data: ACCESS_NONE, + metadata: ACCESS_NONE, + } + expect( + convertAccessToConstantObject('invalid-access-string') + ).toEqual(NO_ACCESS_OBJECT) }) const cases = [ - ['--------', ACCESS_NONE], - ['r-------', ACCESS_VIEW_ONLY], - ['r-r-----', ACCESS_VIEW_ONLY], - ['rw------', ACCESS_VIEW_AND_EDIT], - ['rwrw----', ACCESS_VIEW_AND_EDIT], + ['--------', { data: ACCESS_NONE, metadata: ACCESS_NONE }], + ['r-------', { data: ACCESS_NONE, metadata: ACCESS_VIEW_ONLY }], + [ + 'r-r-----', + { data: ACCESS_VIEW_ONLY, metadata: ACCESS_VIEW_ONLY }, + ], + ['rw------', { data: ACCESS_NONE, metadata: ACCESS_VIEW_AND_EDIT }], + [ + 'rwrw----', + { data: ACCESS_VIEW_AND_EDIT, metadata: ACCESS_VIEW_AND_EDIT }, + ], ] it.each(cases)( 'parses the metadata portion of the access string correctly for %s', (accessString, accessConstant) => { - expect(convertAccessToConstant(accessString)).toEqual( + expect(convertAccessToConstantObject(accessString)).toEqual( accessConstant ) } ) }) - describe('convertConstantToAccess', () => { + describe('convertConstantObjectToAccess', () => { it('returns the default access string if the access constant is not recognised', () => { const expected = '--------' - expect(convertConstantToAccess('NOT_RECOGNISED')).toEqual(expected) + expect( + convertConstantObjectToAccess({ + data: 'NOT_RECOGNISED', + metadata: 'NOT_RECOGNISED', + }) + ).toEqual(expected) }) const cases = [ - [ACCESS_NONE, '--------', false], - [ACCESS_VIEW_ONLY, 'r-------', true], - [ACCESS_VIEW_AND_EDIT, 'rw------', true], + [{ data: ACCESS_NONE, metadata: ACCESS_NONE }, '--------'], + [{ data: ACCESS_NONE, metadata: ACCESS_VIEW_ONLY }, 'r-------'], + [{ data: ACCESS_NONE, metadata: ACCESS_VIEW_AND_EDIT }, 'rw------'], + [{ data: ACCESS_VIEW_ONLY, metadata: ACCESS_NONE }, '--r-----'], + [ + { data: ACCESS_VIEW_AND_EDIT, metadata: ACCESS_VIEW_AND_EDIT }, + 'rwrw----', + ], ] it.each(cases)( 'returns the correct metadata access string for %s', (accessConstant, accessString) => { - expect(convertConstantToAccess(accessConstant)).toEqual( + expect(convertConstantObjectToAccess(accessConstant)).toEqual( accessString ) } ) - - it.each(cases)( - 'returns the correct boolean value for %s', - (accessConstant, accessString, accessBoolean) => { - expect(convertConstantToAccess(accessConstant, true)).toEqual( - accessBoolean - ) - } - ) }) describe('isRemovableTarget', () => { diff --git a/components/sharing-dialog/src/helpers/helpers.js b/components/sharing-dialog/src/helpers/helpers.js index 5fcd25529c..56a928c082 100644 --- a/components/sharing-dialog/src/helpers/helpers.js +++ b/components/sharing-dialog/src/helpers/helpers.js @@ -41,7 +41,7 @@ export const debounce = (func, wait, immediate) => { * Access and constant conversion */ -export const convertAccessToConstant = (access) => { +const convertAccessStringToConstant = (access) => { if (access === undefined) { return ACCESS_NONE } @@ -59,26 +59,48 @@ export const convertAccessToConstant = (access) => { } } -export const convertConstantToAccess = (constant, useBoolean) => { +export const convertAccessToConstantObject = (accessString) => { + if (typeof accessString === 'boolean') { + return { + data: ACCESS_NONE, + metadata: convertAccessStringToConstant(accessString), + } + } + const metadataAccessString = accessString?.substring(0, 2) + const dataAccessString = accessString?.substring(2, 4) + + return { + data: convertAccessStringToConstant(dataAccessString), + metadata: convertAccessStringToConstant(metadataAccessString), + } +} + +export const convertConstantToAccessString = (constant) => { switch (constant) { case ACCESS_NONE: - return useBoolean ? false : '--------' + return '--' case ACCESS_VIEW_ONLY: - return useBoolean ? true : 'r-------' + return 'r-' case ACCESS_VIEW_AND_EDIT: - return useBoolean ? true : 'rw------' + return 'rw' default: - return useBoolean ? false : '--------' + return '--' } } +export const convertConstantObjectToAccess = (accessObject) => { + return `${convertConstantToAccessString( + accessObject.metadata + )}${convertConstantToAccessString(accessObject.data)}----` +} + /** * Replaces access property with constants used internally */ export const replaceAccessWithConstant = ({ access, ...rest }) => ({ ...rest, - access: convertAccessToConstant(access), + access: convertAccessToConstantObject(access), }) /** @@ -102,7 +124,7 @@ export const createOnChangePayload = ({ object, type, access, id }) => { const data = { object: { ...object, - publicAccess: convertConstantToAccess(access), + publicAccess: convertConstantObjectToAccess(access), }, } return data @@ -115,7 +137,7 @@ export const createOnChangePayload = ({ object, type, access, id }) => { return { ...group, - access: convertConstantToAccess(access), + access: convertConstantObjectToAccess(access), } }) const data = { @@ -124,6 +146,7 @@ export const createOnChangePayload = ({ object, type, access, id }) => { userGroupAccesses, }, } + console.log(data) return data } case 'user': { @@ -134,7 +157,7 @@ export const createOnChangePayload = ({ object, type, access, id }) => { return { ...user, - access: convertConstantToAccess(access), + access: convertConstantObjectToAccess(access), } }) const data = { @@ -159,7 +182,7 @@ export const createOnAddPayload = ({ object, type, id, access, name }) => { { id, name, - access: convertConstantToAccess(access), + access: convertConstantObjectToAccess(access), }, ], }, @@ -175,7 +198,7 @@ export const createOnAddPayload = ({ object, type, id, access, name }) => { { id, name, - access: convertConstantToAccess(access), + access: convertConstantObjectToAccess(access), }, ], }, diff --git a/components/sharing-dialog/src/sharing-dialog.e2e.stories.js b/components/sharing-dialog/src/sharing-dialog.e2e.stories.js index 2410271765..94b19b743c 100644 --- a/components/sharing-dialog/src/sharing-dialog.e2e.stories.js +++ b/components/sharing-dialog/src/sharing-dialog.e2e.stories.js @@ -23,3 +23,9 @@ export const Dashboard = () => ( ) + +export const Data = () => ( + + + +) diff --git a/components/sharing-dialog/src/sharing-dialog.js b/components/sharing-dialog/src/sharing-dialog.js index 21490420ab..e5412d72c8 100644 --- a/components/sharing-dialog/src/sharing-dialog.js +++ b/components/sharing-dialog/src/sharing-dialog.js @@ -9,7 +9,7 @@ import { } from './constants.js' import { FetchingContext } from './fetching-context/index.js' import { - convertAccessToConstant, + convertAccessToConstantObject, replaceAccessWithConstant, createOnChangePayload, createOnAddPayload, @@ -39,13 +39,49 @@ const mutation = { } const emptyFunction = () => {} + const defaultInitialSharingSettings = { name: '', allowPublic: true, - public: ACCESS_NONE, - groups: {}, - users: {}, + public: { data: ACCESS_NONE, metadata: ACCESS_NONE }, + groups: [], + users: [], } + +const mapInitialSharingSettings = (originalSharingSettings) => { + const mappedSharingSettings = { ...originalSharingSettings } + if ( + originalSharingSettings.public && + typeof originalSharingSettings.public === 'string' + ) { + mappedSharingSettings.public = { + data: ACCESS_NONE, + metadata: originalSharingSettings.public, + } + } + mappedSharingSettings.groups = originalSharingSettings.groups.map( + (group) => { + if (group.access && typeof group.access === 'string') { + return { + ...group, + access: { data: ACCESS_NONE, metadata: group.access }, + } + } + return group + } + ) + mappedSharingSettings.users = originalSharingSettings.users.map((user) => { + if (user.access && typeof user.access === 'string') { + return { + ...user, + access: { data: ACCESS_NONE, metadata: user.access }, + } + } + return user + }) + return mappedSharingSettings +} + export const SharingDialog = ({ id, type, @@ -54,8 +90,12 @@ export const SharingDialog = ({ onSave = emptyFunction, initialSharingSettings = defaultInitialSharingSettings, dataTest = 'dhis2-uicore-sharingdialog', + dataSharing = false, }) => { const { show: showError } = useAlert((error) => error, { critical: true }) + const mappedInitialSharingSettings = mapInitialSharingSettings( + initialSharingSettings + ) /** * Data fetching @@ -112,12 +152,15 @@ export const SharingDialog = ({ id={id} users={users} groups={groups} - publicAccess={initialSharingSettings.public} - allowPublicAccess={initialSharingSettings.allowPublic} + publicAccess={mappedInitialSharingSettings.public} + allowPublicAccess={ + mappedInitialSharingSettings.allowPublic + } type={type} onAdd={() => {}} onChange={() => {}} onRemove={() => {}} + dataSharing={dataSharing} /> @@ -125,7 +168,7 @@ export const SharingDialog = ({ } const { object, meta } = data.sharing - const publicAccess = convertAccessToConstant(object.publicAccess) + const publicAccess = convertAccessToConstantObject(object.publicAccess) const users = object.userAccesses.map(replaceAccessWithConstant) const groups = object.userGroupAccesses.map(replaceAccessWithConstant) @@ -180,6 +223,7 @@ export const SharingDialog = ({ onAdd={onAdd} onChange={onChange} onRemove={onRemove} + dataSharing={dataSharing} /> @@ -191,28 +235,80 @@ SharingDialog.propTypes = { id: PropTypes.string.isRequired, /** The type of object to share */ type: PropTypes.oneOf(DIALOG_TYPES_LIST).isRequired, + /** Whether to expose the ability to set data sharing (in addition to metadata sharing) */ + dataSharing: PropTypes.bool, dataTest: PropTypes.string, /** Used to seed the component with data to show whilst loading */ initialSharingSettings: PropTypes.shape({ allowPublic: PropTypes.bool.isRequired, groups: PropTypes.objectOf( PropTypes.shape({ - access: PropTypes.string.isRequired, id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, + access: PropTypes.oneOfType([ + PropTypes.shape({ + data: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + metadata: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + }), + PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + ]), }) ), name: PropTypes.string, - public: PropTypes.oneOf([ - ACCESS_NONE, - ACCESS_VIEW_ONLY, - ACCESS_VIEW_AND_EDIT, + public: PropTypes.oneOfType([ + PropTypes.shape({ + data: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + metadata: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + }), + PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), ]), users: PropTypes.objectOf( PropTypes.shape({ - access: PropTypes.string.isRequired, id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, + access: PropTypes.oneOfType([ + PropTypes.shape({ + data: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + metadata: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + }), + PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + ]), }) ), }), diff --git a/components/sharing-dialog/src/sharing-dialog.prod.stories.js b/components/sharing-dialog/src/sharing-dialog.prod.stories.js index 72181e4479..232f09dd9b 100644 --- a/components/sharing-dialog/src/sharing-dialog.prod.stories.js +++ b/components/sharing-dialog/src/sharing-dialog.prod.stories.js @@ -129,7 +129,7 @@ const customDataWithUserGroupAccesses = { { id: 'user-1', name: 'Kvist', - access: 'rw------', + access: 'rwr-----', }, ], userGroupAccesses: [ @@ -200,6 +200,15 @@ export const WithUserAndGroupAccesses = (args) => ( ) WithUserAndGroupAccesses.storyName = 'With user and group accesses' +export const WithDataUserAndGroupAccesses = (args) => ( + + + +) +WithDataUserAndGroupAccesses.storyName = + 'With data sharing, user and group accesses' +WithDataUserAndGroupAccesses.args = { dataSharing: true } + export const ForDashboard = (args) => ( diff --git a/components/sharing-dialog/src/tabs/tabbed-content.js b/components/sharing-dialog/src/tabs/tabbed-content.js index 82c6d9cf73..76de4ffec9 100644 --- a/components/sharing-dialog/src/tabs/tabbed-content.js +++ b/components/sharing-dialog/src/tabs/tabbed-content.js @@ -23,6 +23,7 @@ export const TabbedContent = ({ onAdd, onChange, onRemove, + dataSharing, }) => { const [activeTabIndex, setActiveTabIndex] = useState(0) @@ -46,7 +47,10 @@ export const TabbedContent = ({
{activeTabIndex === 0 && ( <> - + )} @@ -70,7 +75,7 @@ export const TabbedContent = ({ return ( <> - + ) @@ -85,31 +91,53 @@ export const TabbedContent = ({ TabbedContent.propTypes = { allowPublicAccess: PropTypes.bool.isRequired, + dataSharing: PropTypes.bool.isRequired, groups: PropTypes.arrayOf( PropTypes.shape({ - access: PropTypes.oneOf([ - ACCESS_NONE, - ACCESS_VIEW_ONLY, - ACCESS_VIEW_AND_EDIT, - ]).isRequired, + access: PropTypes.shape({ + data: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + metadata: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + }).isRequired, id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, }) ).isRequired, id: PropTypes.string.isRequired, - publicAccess: PropTypes.oneOf([ - ACCESS_NONE, - ACCESS_VIEW_ONLY, - ACCESS_VIEW_AND_EDIT, - ]).isRequired, + publicAccess: PropTypes.shape({ + data: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + metadata: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + }).isRequired, type: PropTypes.oneOf(DIALOG_TYPES_LIST).isRequired, users: PropTypes.arrayOf( PropTypes.shape({ - access: PropTypes.oneOf([ - ACCESS_NONE, - ACCESS_VIEW_ONLY, - ACCESS_VIEW_AND_EDIT, - ]).isRequired, + access: PropTypes.shape({ + data: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + metadata: PropTypes.oneOf([ + ACCESS_NONE, + ACCESS_VIEW_ONLY, + ACCESS_VIEW_AND_EDIT, + ]), + }).isRequired, id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, }) diff --git a/components/sharing-dialog/types/index.d.ts b/components/sharing-dialog/types/index.d.ts index 1cdecbddf6..90b68873ec 100644 --- a/components/sharing-dialog/types/index.d.ts +++ b/components/sharing-dialog/types/index.d.ts @@ -1,13 +1,19 @@ import * as React from 'react' -import { ModalOnCloseEventHandler } from '../modal' +import { LayerBackdropClickHandler } from '@dhis2-ui/layer' -interface SharingObject { - access: string +type ModalOnCloseEventHandler = LayerBackdropClickHandler + +type SharingAccess = { + data: 'ACCESS_NONE' | 'ACCESS_VIEW_ONLY' | 'ACCESS_VIEW_AND_EDIT' + metadata: 'ACCESS_NONE' | 'ACCESS_VIEW_ONLY' | 'ACCESS_VIEW_AND_EDIT' +} + +type SharingObject = { + access: SharingAccess id: string name: string } -type SharingPublic = 'ACCESS_NONE' | 'ACCESS_VIEW_ONLY' | 'ACCESS_VIEW_AND_EDIT' type SharingType = | 'aggregateDataExchange' | 'apiToken' @@ -67,10 +73,10 @@ type SharingType = export interface SharingDialogInitialSharingSettings { allowPublic: boolean - groups?: SharingObject + groups?: SharingObject[] name?: string - public?: SharingPublic - users?: SharingObject + public?: SharingAccess + users?: SharingObject[] } export interface SharingDialogProps { @@ -86,6 +92,7 @@ export interface SharingDialogProps { * Used to seed the component with data to show whilst loading */ initialSharingSettings?: SharingDialogInitialSharingSettings + dataSharing?: boolean onClose?: ModalOnCloseEventHandler onError?: (error: any) => void onSave?: () => void