From 84abf1a5e5d7a605e9db57e0264a777d6af42a3d Mon Sep 17 00:00:00 2001 From: Birk Johansson Date: Mon, 4 Mar 2024 21:34:41 +0100 Subject: [PATCH] fix: bulk sharing --- .../sectionList/bulk/Bulk.module.css | 45 +++++- .../sectionList/bulk/BulkActionSummary.tsx | 100 ++++++++++++ .../sectionList/bulk/BulkActions.tsx | 7 - .../sectionList/bulk/BulkSharing.tsx | 144 ++++++++++++++---- .../sectionList/bulk/BulkSharingDialog.tsx | 11 +- .../sectionList/bulk/useBulkSharing.tsx | 30 ++-- src/components/sharing/DataAccessField.tsx | 69 +++++++++ .../sharing/MetadataAccessField.tsx | 25 ++- src/components/sharing/index.ts | 1 + 9 files changed, 357 insertions(+), 75 deletions(-) create mode 100644 src/components/sectionList/bulk/BulkActionSummary.tsx delete mode 100644 src/components/sectionList/bulk/BulkActions.tsx create mode 100644 src/components/sharing/DataAccessField.tsx diff --git a/src/components/sectionList/bulk/Bulk.module.css b/src/components/sectionList/bulk/Bulk.module.css index 9b28fb3f..ebadd8ca 100644 --- a/src/components/sectionList/bulk/Bulk.module.css +++ b/src/components/sectionList/bulk/Bulk.module.css @@ -1,7 +1,7 @@ .bulkSharingWrapper { display: flex; flex-direction: column; - gap: var(--spacers-dp8); + gap: var(--spacers-dp16); } .fieldWrapper { @@ -18,7 +18,46 @@ .selectionWrapper { display: flex; gap: var(--spacers-dp4); - background-color: var(--colors-red050); + background-color: var(--colors-grey050); padding: var(--spacers-dp12); - align-items: flex-end; +} + +.addActionButton { + margin-bottom: -1px; + align-self: flex-end; +} + +.actionCancelButton { + margin-inline-start: auto; +} + +.actionSummary { + display: flex; + align-items: center; + gap: var(--spacers-dp16); + padding: 0 var(--spacers-dp8); +} + +.actionSummaryDisplayName { + max-width: 350px; +} + +.accessSummary { + display: flex; + gap: var(--spacers-dp8); + align-items: center; +} + +.accessSummary span { + display: flex; + gap: var(--spacers-dp4); + align-items: center; +} + +.actionNoAccess { + color: var(--colors-red600); +} + +.actionAccessAdd { + color: var(--colors-green600); } diff --git a/src/components/sectionList/bulk/BulkActionSummary.tsx b/src/components/sectionList/bulk/BulkActionSummary.tsx new file mode 100644 index 00000000..7bd21e51 --- /dev/null +++ b/src/components/sectionList/bulk/BulkActionSummary.tsx @@ -0,0 +1,100 @@ +import i18n from '@dhis2/d2-i18n' +import { Button, Divider, IconAdd16, IconCross16 } from '@dhis2/ui' +import React from 'react' +import { PublicAccessPart, parsePublicAccessString } from '../../../lib' +import css from './Bulk.module.css' +import type { SharingAction } from './BulkSharing' + +type ActionSummaryProps = { + action: SharingAction + dataShareable: boolean + onRemove: () => void +} +export const ActionSummary = ({ + action, + dataShareable, + onRemove, +}: ActionSummaryProps) => ( +
+
+ + {action.sharingEntity.displayName} + + + +
+ +
+) + +type ActionAccessSummaryProps = { + action: SharingAction + dataShareable: boolean +} +const ActionAccessSummary = ({ + action, + dataShareable, +}: ActionAccessSummaryProps) => { + const parsed = parsePublicAccessString(action.access) + + if (parsed === null) { + return null + } + + return ( + + + {dataShareable && } + + ) +} + +const MetadataAccess = ({ access }: { access: PublicAccessPart }) => { + const noAccess = access.read === false + + if (noAccess) { + return + } + const label = access.write + ? i18n.t('Metadata view and edit') + : i18n.t('Metadata view only') + + return +} + +const DataAccess = ({ access }: { access: PublicAccessPart }) => { + const noAccess = access.read === false + + if (noAccess) { + return + } + const label = access.write + ? i18n.t('Data view and capture') + : i18n.t('Data view only') + + return +} + +const AddAccess = ({ label }: { label: string }) => { + return ( + + {label} + + ) +} + +const NoAccess = ({ label }: { label: string }) => { + return ( + + {label} + + ) +} diff --git a/src/components/sectionList/bulk/BulkActions.tsx b/src/components/sectionList/bulk/BulkActions.tsx deleted file mode 100644 index 59bb315d..00000000 --- a/src/components/sectionList/bulk/BulkActions.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import i18n from '@dhis2/d2-i18n' -import { Button } from '@dhis2/ui' -import React from 'react' - -export const UpdateSharingButton = () => { - return -} diff --git a/src/components/sectionList/bulk/BulkSharing.tsx b/src/components/sectionList/bulk/BulkSharing.tsx index 193a2f58..eb54a29b 100644 --- a/src/components/sectionList/bulk/BulkSharing.tsx +++ b/src/components/sectionList/bulk/BulkSharing.tsx @@ -1,14 +1,16 @@ +import { useAlert } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' -import { Button, Divider, Field, SingleSelect } from '@dhis2/ui' -import React, { useState } from 'react' +import { Button, Divider, Field, NoticeBox } from '@dhis2/ui' +import React, { FormEvent, useState } from 'react' import { useSchemaFromHandle } from '../../../lib' -import { JsonPatchOperation } from '../../../types' import { SharingSearchSelect, MetadataAccessField, SharingSearchResult, + DataAccessField, } from '../../sharing' import css from './Bulk.module.css' +import { ActionSummary } from './BulkActionSummary' import { SharingJsonPatchOperation, useBulkSharingMutation, @@ -16,7 +18,14 @@ import { type BulkSharingProps = { selectedModels: Set - children: ({ handleSave }: { handleSave: () => void }) => React.ReactNode + onSaved: () => void + children: ({ + submitting, + disableSave, + }: { + submitting: boolean + disableSave: boolean + }) => React.ReactNode } export type SharingAction = { @@ -41,37 +50,108 @@ const actionToJsonPatchOperation = ( } } -export const BulkSharing = ({ children, selectedModels }: BulkSharingProps) => { +/* + Usage of React-Final-Form does not seem necessary because we're not using + validation or initialState. And the result of the form (list of added SharingActions) are not kept in form-state. + However, we still need some metastate for the form. + */ +type FormMetaState = { + submitting: boolean + error: undefined | string +} + +export const BulkSharing = ({ + children, + selectedModels, + onSaved, +}: BulkSharingProps) => { const schema = useSchemaFromHandle() - const dataShareable = schema?.dataShareable + const dataShareable = !schema?.dataShareable const mutation = useBulkSharingMutation({ modelNamePlural: schema.plural }) + const [sharingActions, setSharingActions] = useState([]) + const [metaState, setMetaState] = useState({ + submitting: false, + error: undefined, + }) + + const { show: showSuccessAlert } = useAlert( + i18n.t('Successfully updated sharing for {{number}} items', { + number: selectedModels.size, + }), + { success: true } + ) - const handleSave = () => { + const handleSave = async (e: FormEvent) => { + e.preventDefault() const ids = Array.from(selectedModels) const operations = sharingActions.map(actionToJsonPatchOperation) - mutation(ids, operations) + setMetaState({ submitting: true, error: undefined }) + try { + await mutation(ids, operations) + setMetaState({ submitting: false, error: undefined }) + showSuccessAlert() + onSaved() + } catch (e) { + console.error(e) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const error = e as any + if ('message' in error) { + setMetaState({ submitting: false, error: error.message }) + } else { + setMetaState({ + submitting: false, + error: i18n.t('An unknown error occurred'), + }) + } + } + } + + const handleAddSharingAction = (action: SharingAction) => { + setSharingActions((prev) => { + const actionExists = sharingActions.some( + (a) => a.sharingEntity.id === action.sharingEntity.id + ) + if (actionExists) { + // if the user/group already exists, update with new action + return sharingActions.map((a) => + a.sharingEntity.id === action.sharingEntity.id ? action : a + ) + } + return [action, ...prev] + }) } return ( -
+
- setSharingActions((prev) => [action, ...prev]) - } + onAddSharingAction={handleAddSharingAction} /> setSharingActions((prev) => prev.filter((a) => a !== action) ) } /> - {children({ handleSave })} -
+ {metaState.error && ( + + {metaState.error} + + )} + + {children({ + submitting: metaState.submitting, + disableSave: sharingActions.length < 1, + })} + ) } @@ -88,8 +168,7 @@ const SharingSelection = ({ SharingSearchResult | undefined >() - const [metadataAccess, setMetadataAccess] = useState('r-------') - const [dataAccess, setDataAccess] = useState('--------') + const [accessString, setAccessString] = useState('r-------') const handleSelectSharingEntity = (selected: SharingSearchResult) => { setSelectedSharingEntity(selected) @@ -102,7 +181,7 @@ const SharingSelection = ({ const action = { op: 'replace', sharingEntity: selectedSharingEntity, - access: metadataAccess, + access: accessString, } as const onAddSharingAction(action) @@ -122,21 +201,20 @@ const SharingSelection = ({ setMetadataAccess(selected)} + value={accessString} + onChange={(selected) => setAccessString(selected)} /> {dataShareable && ( - {}} - dataTest="dhis2-uicore-singleselect" + setAccessString(selected)} /> )} - diff --git a/src/components/sectionList/bulk/useBulkSharing.tsx b/src/components/sectionList/bulk/useBulkSharing.tsx index cea28ecb..360fd77e 100644 --- a/src/components/sectionList/bulk/useBulkSharing.tsx +++ b/src/components/sectionList/bulk/useBulkSharing.tsx @@ -1,19 +1,19 @@ -import { useConfig, useDataEngine, useDataMutation } from '@dhis2/app-runtime' +import { useConfig } from '@dhis2/app-runtime' import { useCallback } from 'react' -import { JsonPatchOperation, SchemaName } from '../../../types' +import { JsonPatchOperation } from '../../../types' -type Mutation = Parameters[0] +// TODO: use this when mutation support objects as json-patch +//type Mutation = Parameters[0] +// const query = { +// type: 'json-patch', +// data: ({ data }: Record) => data, +// } as const -const query = { - type: 'json-patch', - data: ({ data }: Record) => data, -} as const - -const createQuery = (modelName: string): Mutation => ({ - ...query, - resource: modelName, - id: 'sharing', -}) +// const createQuery = (modelName: string): Mutation => ({ +// ...query, +// resource: modelName, +// id: 'sharing', +// }) export type SharingJsonPatchOperation = Omit & { value?: { @@ -52,7 +52,9 @@ export const useBulkSharingMutation = ({ body: JSON.stringify(data), } ) - return request.then((r) => (r.ok ? r.json() : Promise.reject(r))) + return request.then((r) => + r.ok ? r.json() : r.json().then((e) => Promise.reject(e)) + ) }, [config, modelNamePlural] ) diff --git a/src/components/sharing/DataAccessField.tsx b/src/components/sharing/DataAccessField.tsx new file mode 100644 index 00000000..c0e9c284 --- /dev/null +++ b/src/components/sharing/DataAccessField.tsx @@ -0,0 +1,69 @@ +import i18n from '@dhis2/d2-i18n' +import { SingleSelect, SingleSelectOption } from '@dhis2/ui' +import React from 'react' +import { + PublicAccess, + PublicAccessPart, + formatPublicAccess, + parsePublicAccessString, +} from '../../lib' + +const defaultParsedAccess: PublicAccess = { + metadata: { read: false, write: false }, + data: { read: false, write: false }, +} + +type DataAccessFieldProps = { + // value is a full access string, with data-access if applicable + value?: string | undefined + // selected is full access string, with updated data access + onChange: (selected: string) => void +} + +export const DataAccessField = ({ onChange, value }: DataAccessFieldProps) => { + const parsed = value + ? parsePublicAccessString(value) || defaultParsedAccess + : defaultParsedAccess + + // selected is here is metadata access string only (data will always be --) + const handleChange = ({ selected }: { selected: string }) => { + const selectedDataAccess = parsePublicAccessString(selected) + ?.data as NonNullable + + const formatted = formatPublicAccess({ + metadata: parsed.metadata, + data: selectedDataAccess, + }) + + onChange(formatted) + } + + const valueWithOnlyData = formatPublicAccess({ + metadata: defaultParsedAccess.metadata, + data: parsed.data, + }) + + return ( + + + + + + ) +} diff --git a/src/components/sharing/MetadataAccessField.tsx b/src/components/sharing/MetadataAccessField.tsx index 6e41452e..b2f80016 100644 --- a/src/components/sharing/MetadataAccessField.tsx +++ b/src/components/sharing/MetadataAccessField.tsx @@ -3,25 +3,16 @@ import { SingleSelect, SingleSelectOption } from '@dhis2/ui' import React from 'react' import { PublicAccess, + PublicAccessPart, formatPublicAccess, parsePublicAccessString, } from '../../lib' -const constants = { - 'rw------': i18n.t('Can edit and view'), - 'r-------': i18n.t('Can view only'), - '--------': i18n.t('Public cannot access'), -} - const defaultParsedAccess: PublicAccess = { metadata: { read: true, write: false }, data: { read: false, write: false }, } -const OPTIONS = Object.entries(constants).map(([value, label]) => ( - -)) - type MetadataAccessFieldProps = { // value is a full access string, with data-access if applicable value?: string | undefined @@ -33,23 +24,25 @@ export const MetadataAccessField = ({ onChange, value, }: MetadataAccessFieldProps) => { - const parsed = value ? parsePublicAccessString(value) : defaultParsedAccess + const parsed = value + ? parsePublicAccessString(value) || defaultParsedAccess + : defaultParsedAccess // selected is here is metadata access string only (data will always be --) const handleChange = ({ selected }: { selected: string }) => { const selectedMetadataAccess = parsePublicAccessString(selected) - ?.metadata as NonNullable + ?.metadata as NonNullable const formatted = formatPublicAccess({ metadata: selectedMetadataAccess, - data: parsed?.data || defaultParsedAccess.data, + data: parsed.data, }) onChange(formatted) } const valueWithOnlyMetadata = formatPublicAccess({ - metadata: parsed?.metadata || defaultParsedAccess.metadata, + metadata: parsed.metadata, data: defaultParsedAccess.data, }) @@ -62,12 +55,12 @@ export const MetadataAccessField = ({ ) diff --git a/src/components/sharing/index.ts b/src/components/sharing/index.ts index 8e0e1072..75bdd969 100644 --- a/src/components/sharing/index.ts +++ b/src/components/sharing/index.ts @@ -1,2 +1,3 @@ export * from './SharingSearchSelect' export * from './MetadataAccessField' +export * from './DataAccessField'