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