diff --git a/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx b/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx index 91c2cf91..ec4639d6 100644 --- a/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx +++ b/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx @@ -141,6 +141,7 @@ export const SearchableSingleSelect = ({
setFilterValue(value ?? '')} placeholder={i18n.t('Filter options')} diff --git a/src/components/sectionList/SectionListWrapper.tsx b/src/components/sectionList/SectionListWrapper.tsx index 1f1c9052..4732f357 100644 --- a/src/components/sectionList/SectionListWrapper.tsx +++ b/src/components/sectionList/SectionListWrapper.tsx @@ -2,6 +2,7 @@ import { FetchError } from '@dhis2/app-runtime' import React, { useMemo, useState } from 'react' import { useSchemaFromHandle } from '../../lib' import { Pager, ModelCollection } from '../../types/models' +import { SectionListHeaderBulk } from './bulk' import { DetailsPanel, DefaultDetailsPanelContent } from './detailsPanel' import { FilterWrapper } from './filters/FilterWrapper' import { useModelListView } from './listView' @@ -77,7 +78,14 @@ export const SectionListWrapper = ({
- + {selectedModels.size > 0 ? ( + setSelectedModels(new Set())} + /> + ) : ( + + )} { + return +} diff --git a/src/components/sectionList/bulk/BulkSharing.tsx b/src/components/sectionList/bulk/BulkSharing.tsx new file mode 100644 index 00000000..193a2f58 --- /dev/null +++ b/src/components/sectionList/bulk/BulkSharing.tsx @@ -0,0 +1,183 @@ +import i18n from '@dhis2/d2-i18n' +import { Button, Divider, Field, SingleSelect } from '@dhis2/ui' +import React, { useState } from 'react' +import { useSchemaFromHandle } from '../../../lib' +import { JsonPatchOperation } from '../../../types' +import { + SharingSearchSelect, + MetadataAccessField, + SharingSearchResult, +} from '../../sharing' +import css from './Bulk.module.css' +import { + SharingJsonPatchOperation, + useBulkSharingMutation, +} from './useBulkSharing' + +type BulkSharingProps = { + selectedModels: Set + children: ({ handleSave }: { handleSave: () => void }) => React.ReactNode +} + +export type SharingAction = { + op: 'remove' | 'replace' + sharingEntity: SharingSearchResult + access: string +} + +const actionToJsonPatchOperation = ( + action: SharingAction +): SharingJsonPatchOperation => { + const { op, sharingEntity, access } = action + const value = { + access: access, + id: sharingEntity.id, + } + + return { + op, + path: `/sharing/${sharingEntity.entity}/${sharingEntity.id}`, + ...(op === 'remove' ? undefined : { value }), + } +} + +export const BulkSharing = ({ children, selectedModels }: BulkSharingProps) => { + const schema = useSchemaFromHandle() + const dataShareable = schema?.dataShareable + const mutation = useBulkSharingMutation({ modelNamePlural: schema.plural }) + const [sharingActions, setSharingActions] = useState([]) + + const handleSave = () => { + const ids = Array.from(selectedModels) + const operations = sharingActions.map(actionToJsonPatchOperation) + mutation(ids, operations) + } + + return ( +
+ + setSharingActions((prev) => [action, ...prev]) + } + /> + + setSharingActions((prev) => + prev.filter((a) => a !== action) + ) + } + /> + {children({ handleSave })} +
+ ) +} + +type SharingSelectionProps = { + dataShareable: boolean + onAddSharingAction: (action: SharingAction) => void +} + +const SharingSelection = ({ + dataShareable, + onAddSharingAction, +}: SharingSelectionProps) => { + const [selectedSharingEntity, setSelectedSharingEntity] = useState< + SharingSearchResult | undefined + >() + + const [metadataAccess, setMetadataAccess] = useState('r-------') + const [dataAccess, setDataAccess] = useState('--------') + + const handleSelectSharingEntity = (selected: SharingSearchResult) => { + setSelectedSharingEntity(selected) + } + + const handleAddSharingAction = () => { + if (!selectedSharingEntity) { + return + } + const action = { + op: 'replace', + sharingEntity: selectedSharingEntity, + access: metadataAccess, + } as const + + onAddSharingAction(action) + } + + return ( +
+ + {i18n.t('Update sharing for users and groups')} + +
+ + + + + setMetadataAccess(selected)} + /> + + {dataShareable && ( + + {}} + dataTest="dhis2-uicore-singleselect" + /> + + )} + +
+
+ ) +} + +const SharingSummary = ({ + numberOfSelectedModels, + sharingActions, + onRemoveSharingAction, +}: { + numberOfSelectedModels: number + sharingActions: SharingAction[] + onRemoveSharingAction: (action: SharingAction) => void +}) => { + return ( +
+ + {i18n.t('Access to be updated for {{number}} items', { + number: numberOfSelectedModels, + })}{' '} + +
+ {sharingActions.map((action, index) => ( +
+ {action.sharingEntity.name} - {action.access} +
+ ))} + +
+
+ ) +} + +const SharingSubTitle = ({ children }: { children: React.ReactNode }) => ( + + {children} + + +) diff --git a/src/components/sectionList/bulk/BulkSharingDialog.tsx b/src/components/sectionList/bulk/BulkSharingDialog.tsx new file mode 100644 index 00000000..f75943b5 --- /dev/null +++ b/src/components/sectionList/bulk/BulkSharingDialog.tsx @@ -0,0 +1,47 @@ +import i18n from '@dhis2/d2-i18n' +import { + Button, + ButtonStrip, + Modal, + ModalActions, + ModalContent, + ModalTitle, +} from '@dhis2/ui' +import React from 'react' +import { BulkSharing } from './BulkSharing' + +type BulkSharingDialogProps = { + onClose: () => void + selectedModels: Set +} + +export const BulkSharingDialog = ({ + onClose, + selectedModels, +}: BulkSharingDialogProps) => { + return ( + + + {i18n.t('Update sharing for {{number}} items', { + number: selectedModels.size, + })} + + + + {({ handleSave }) => ( + + + + + + + )} + + + + ) +} diff --git a/src/components/sectionList/bulk/SectionListHeaderBulk.module.css b/src/components/sectionList/bulk/SectionListHeaderBulk.module.css new file mode 100644 index 00000000..eb35e784 --- /dev/null +++ b/src/components/sectionList/bulk/SectionListHeaderBulk.module.css @@ -0,0 +1,11 @@ +.listHeaderBulk { + background-color: var(--colors-green050); + width: 100%; + height: 24px; + display: flex; + align-items: center; + height: 48px; + padding: var(--spacers-dp8); + gap: var(--spacers-dp8); + border: 2px solid var(--colors-green800) !important; +} diff --git a/src/components/sectionList/bulk/SectionListHeaderBulk.tsx b/src/components/sectionList/bulk/SectionListHeaderBulk.tsx new file mode 100644 index 00000000..ddb5337d --- /dev/null +++ b/src/components/sectionList/bulk/SectionListHeaderBulk.tsx @@ -0,0 +1,42 @@ +import i18n from '@dhis2/d2-i18n' +import { Button, DataTableToolbar } from '@dhis2/ui' +import React from 'react' +import { useSchemaFromHandle } from '../../../lib' +import { BulkSharingDialog } from './BulkSharingDialog' +import css from './SectionListHeaderBulk.module.css' + +type SectionListHeaderBulkProps = { + selectedModels: Set + onDeselectAll: () => void +} + +export const SectionListHeaderBulk = ({ + selectedModels, + onDeselectAll, +}: SectionListHeaderBulkProps) => { + const [sharingDialogOpen, setSharingDialogOpen] = React.useState(false) + const sharable = useSchemaFromHandle().shareable + + const handleClose = () => setSharingDialogOpen(false) + return ( + + + {i18n.t('{{number}} selected', { number: selectedModels.size })} + + {sharable && ( + + )} + + {sharingDialogOpen && ( + + )} + + ) +} diff --git a/src/components/sectionList/bulk/index.ts b/src/components/sectionList/bulk/index.ts new file mode 100644 index 00000000..140f4f62 --- /dev/null +++ b/src/components/sectionList/bulk/index.ts @@ -0,0 +1 @@ +export * from './SectionListHeaderBulk' diff --git a/src/components/sectionList/bulk/useBulkSharing.tsx b/src/components/sectionList/bulk/useBulkSharing.tsx new file mode 100644 index 00000000..cea28ecb --- /dev/null +++ b/src/components/sectionList/bulk/useBulkSharing.tsx @@ -0,0 +1,61 @@ +import { useConfig, useDataEngine, useDataMutation } from '@dhis2/app-runtime' +import { useCallback } from 'react' +import { JsonPatchOperation, SchemaName } from '../../../types' + +type Mutation = Parameters[0] + +const query = { + type: 'json-patch', + data: ({ data }: Record) => data, +} as const + +const createQuery = (modelName: string): Mutation => ({ + ...query, + resource: modelName, + id: 'sharing', +}) + +export type SharingJsonPatchOperation = Omit & { + value?: { + access: string + id: string + } +} + +export const useBulkSharingMutation = ({ + modelNamePlural, +}: { + modelNamePlural: string +}) => { + const config = useConfig() + + const updateSharing = useCallback( + (modelIds: string[], operations: SharingJsonPatchOperation[]) => { + const data = { + [modelNamePlural]: modelIds, + patch: operations, + } + + // engine.mutate enforces body to be an array, which does not match the API + // so we cant use engine for this mutation + const request = fetch( + `${config.baseUrl}/api/${modelNamePlural}/sharing`, + { + credentials: 'include', + headers: { + 'Content-Type': 'application/json-patch+json', + Accept: 'application/json', + // credentials: 'include', + 'X-Requested-With': 'XMLHttpRequest', + }, + method: 'PATCH', + body: JSON.stringify(data), + } + ) + return request.then((r) => (r.ok ? r.json() : Promise.reject(r))) + }, + [config, modelNamePlural] + ) + + return updateSharing +} diff --git a/src/components/sectionList/index.ts b/src/components/sectionList/index.ts index f930c6b6..32bab90f 100644 --- a/src/components/sectionList/index.ts +++ b/src/components/sectionList/index.ts @@ -4,3 +4,4 @@ export * from './SectionListWrapper' export type * from './types' export * from './filters' export * from './SectionListPagination' +export * from './bulk' diff --git a/src/components/sharing/MetadataAccessField.tsx b/src/components/sharing/MetadataAccessField.tsx new file mode 100644 index 00000000..6e41452e --- /dev/null +++ b/src/components/sharing/MetadataAccessField.tsx @@ -0,0 +1,74 @@ +import i18n from '@dhis2/d2-i18n' +import { SingleSelect, SingleSelectOption } from '@dhis2/ui' +import React from 'react' +import { + PublicAccess, + 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 + // selected is full access string, with updated metadata access + onChange: (selected: string) => void +} + +export const MetadataAccessField = ({ + onChange, + value, +}: MetadataAccessFieldProps) => { + const parsed = value ? parsePublicAccessString(value) : 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 + + const formatted = formatPublicAccess({ + metadata: selectedMetadataAccess, + data: parsed?.data || defaultParsedAccess.data, + }) + + onChange(formatted) + } + + const valueWithOnlyMetadata = formatPublicAccess({ + metadata: parsed?.metadata || defaultParsedAccess.metadata, + data: defaultParsedAccess.data, + }) + + return ( + + + + + ) +} diff --git a/src/components/sharing/SharingSearchSelect.tsx b/src/components/sharing/SharingSearchSelect.tsx new file mode 100644 index 00000000..1e830d6d --- /dev/null +++ b/src/components/sharing/SharingSearchSelect.tsx @@ -0,0 +1,126 @@ +import { useDataQuery } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { IconUser16, IconUserGroup16 } from '@dhis2/ui' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Sharing } from '../../types/generated' +import { SearchableSingleSelect } from '../SearchableSingleSelect' + +const query = { + result: { + resource: 'sharing/search', + params: ({ searchFilter }: Record) => ({ + key: searchFilter, + }), + }, +} + +type SharingUserResult = { + id: string + name: string + displayName: string + username: string +} + +type SharingUserGroupResult = { + id: string + name: string + displayName: string +} + +export type SharingSearchResult = + | (SharingUserResult & { entity: 'users' }) + | (SharingUserGroupResult & { + entity: 'userGroups' + }) + +type SharingSearchResponse = { + result: { + users: SharingUserResult[] + userGroups: SharingUserGroupResult[] + } +} + +type SharingSearchSelectProps = { + onChange: (value: SharingSearchResult) => void +} + +export const SharingSearchSelect = ({ onChange }: SharingSearchSelectProps) => { + const [selected, setSelected] = useState('') + + const { data, refetch, error, loading } = + useDataQuery(query, { + variables: { searchFilter: '' }, + }) + + const formattedData: SharingSearchResult[] = useMemo(() => { + if (!data?.result) { + return [] + } + + const users = data.result.users.map( + (user) => + ({ + ...user, + entity: 'users', + label: user.displayName, + value: user.id, + } as const) + ) + const userGroups = data.result.userGroups.map( + (group) => + ({ + ...group, + label: group.displayName, + entity: 'userGroups', + value: group.id, + } as const) + ) + + return [...users, ...userGroups].sort((a, b) => + a.label.localeCompare(b.label) + ) + }, [data]) + + const handleChange = useCallback( + ({ selected }: { selected: string }) => { + if (formattedData.length < 1) { + return + } + const selectedResult = formattedData.find( + (res) => res.id === selected + ) + + if (selectedResult) { + setSelected(selectedResult.id) + onChange(selectedResult) + } + }, + [formattedData, setSelected, onChange] + ) + + const handleFetch = useCallback( + ({ value }: { value: string }) => { + refetch({ searchFilter: value.trim() }) + }, + [refetch] + ) + + return ( + ({ + label: res.displayName, + value: res.id, + }))} + onChange={handleChange} + onRetryClick={() => refetch()} + loading={loading} + onEndReached={() => {}} + showEndLoader={loading} + error={error?.message} + /> + ) +} diff --git a/src/components/sharing/index.ts b/src/components/sharing/index.ts new file mode 100644 index 00000000..8e0e1072 --- /dev/null +++ b/src/components/sharing/index.ts @@ -0,0 +1,2 @@ +export * from './SharingSearchSelect' +export * from './MetadataAccessField' diff --git a/src/lib/models/index.ts b/src/lib/models/index.ts index cdba6bb1..7cc218e1 100644 --- a/src/lib/models/index.ts +++ b/src/lib/models/index.ts @@ -1,8 +1,5 @@ export { isValidUid } from './uid' -export { - parsePublicAccessString, - formatPublicAccess, -} from './parsePublicAccess' +export * from './parsePublicAccess' export { getIn, stringToPathArray, getFieldFilterFromPath } from './path' export { useIsFieldValueUnique } from './useIsFieldValueUnique' export { useCheckMaxLengthFromSchema } from './useCheckMaxLengthFromSchema' diff --git a/src/lib/useLoadApp.ts b/src/lib/useLoadApp.ts index acaa2b95..3f288bac 100644 --- a/src/lib/useLoadApp.ts +++ b/src/lib/useLoadApp.ts @@ -16,6 +16,8 @@ const schemaFields = [ 'singular', 'translatable', 'properties', + 'shareable', + 'dataShareable', ] as const // workaround to widen the type, because useQuery() does not allow for