Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ jobs:
with:
repository: opendatateam/udata
path: ${{ env.UDATA_WORKING_DIR }}
ref: main
ref: partial_editors

- name: Set up uv
uses: astral-sh/setup-uv@v6
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
{{ t('Rôle proposé :') }}
<AdminBadge
size="xs"
:type="request.role === 'admin' ? 'primary' : 'secondary'"
:type="request.role === 'admin' ? 'primary' : request.role === 'partial_editor' ? 'default' : 'secondary'"
>
{{ roleLabel }}
</AdminBadge>
Expand Down
2 changes: 1 addition & 1 deletion components/AdminOrgInvitation/AdminOrgInvitation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
{{ t('Rôle proposé :') }}
<AdminBadge
size="xs"
:type="invitation.role === 'admin' ? 'primary' : 'secondary'"
:type="invitation.role === 'admin' ? 'primary' : invitation.role === 'partial_editor' ? 'default' : 'secondary'"
>
{{ roleLabel }}
</AdminBadge>
Expand Down
162 changes: 162 additions & 0 deletions components/DatasetAssignmentSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<template>
<div>
<h3 class="text-sm font-bold uppercase mt-5 mb-3">
{{ t('Choisir les jeux de données éditables par ce membre') }}
</h3>

<div class="mb-3">
<AdminInput
v-model="q"
type="search"
:icon="RiSearchLine"
:placeholder="$t('Rechercher un jeu de données')"
class="w-full"
/>
</div>

<LoadingBlock
v-slot="{ data: slotData }"
:status
:data="pageData"
>
<div v-if="slotData && slotData.total > 0">
<AdminTable>
<thead>
<tr>
<AdminTableTh
scope="col"
class="w-10"
/>
<AdminTableTh scope="col">
{{ t('Titre') }}
</AdminTableTh>
<AdminTableTh
scope="col"
class="w-24"
>
{{ t('Statut') }}
</AdminTableTh>
<AdminTableTh
scope="col"
class="w-28"
>
{{ t('Créé le') }}
</AdminTableTh>
<AdminTableTh
scope="col"
class="w-32"
>
{{ t('Mis à jour le') }}
</AdminTableTh>
</tr>
</thead>
<tbody>
<tr
v-for="dataset in slotData.data"
:key="dataset.id"
class="cursor-pointer"
@click="toggle(dataset.id)"
>
<td class="text-center">
<input
type="checkbox"
:checked="selectedIds.has(dataset.id)"
class="size-4 cursor-pointer"
@click.stop="toggle(dataset.id)"
>
</td>
<td>
{{ dataset.title }}
</td>
<td>
<DatasetBadge :dataset />
</td>
<td>{{ formatDate(dataset.created_at) }}</td>
<td>{{ formatDate(dataset.last_modified) }}</td>
</tr>
</tbody>
</AdminTable>
<Pagination
:page="page"
:page-size="pageSize"
:total-results="slotData.total"
@change="(changedPage: number) => page = changedPage"
/>
</div>
<p
v-else-if="slotData && slotData.total === 0 && q"
class="text-sm text-gray-medium text-center py-4"
>
{{ t('Aucun résultat pour « {q} »', { q }) }}
</p>
<p
v-else-if="slotData && slotData.total === 0"
class="text-sm text-gray-medium text-center py-4"
>
{{ t("Aucun jeu de données dans cette organisation") }}
</p>
</LoadingBlock>

<p class="text-sm text-gray-medium mt-2">
{{ selectedIds.size === 0
? t('Aucun jeu de données sélectionné')
: t('{n} jeu de données sélectionné | {n} jeu de données sélectionné | {n} jeux de données sélectionnés', { n: selectedIds.size })
}}
</p>
</div>
</template>

<script setup lang="ts">
import { LoadingBlock, Pagination, useFormatDate } from '@datagouv/components-next'
import type { DatasetV2 } from '@datagouv/components-next'
import { refDebounced } from '@vueuse/core'
import { computed, ref } from 'vue'
import { RiSearchLine } from '@remixicon/vue'
import AdminTable from '~/components/AdminTable/Table/AdminTable.vue'
import AdminTableTh from '~/components/AdminTable/Table/AdminTableTh.vue'
import DatasetBadge from '~/components/AdminBadge/DatasetBadge.vue'
import type { PaginatedArray } from '~/types/types'

const props = defineProps<{
organizationId: string
}>()

const selectedIds = defineModel<Set<string>>({ required: true })

const { t } = useTranslation()
const { formatDate } = useFormatDate()
const config = useRuntimeConfig()

const page = ref(1)
const pageSize = 20
const q = ref('')
const qDebounced = refDebounced(q, config.public.searchDebounce)

watch(qDebounced, () => {
page.value = 1
})

const params = computed(() => ({
organization: props.organizationId,
page: page.value,
page_size: pageSize,
q: qDebounced.value,
sort: '-created',
}))

const { data: pageData, status } = await useAPI<PaginatedArray<DatasetV2>>('/api/2/datasets/', {
lazy: true,
query: params,
})

function toggle(datasetId: string) {
const next = new Set(selectedIds.value)
if (next.has(datasetId)) {
next.delete(datasetId)
}
else {
next.add(datasetId)
}
selectedIds.value = next
}
</script>
2 changes: 1 addition & 1 deletion datagouv-components/src/types/organizations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { User } from './users'
import type { Badges } from './badges'

export type MemberRole = 'admin' | 'editor'
export type MemberRole = 'admin' | 'editor' | 'partial_editor'

export type Member = {
role: MemberRole
Expand Down
96 changes: 85 additions & 11 deletions pages/admin/organizations/[oid]/members.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
>
<ModalWithButton
:title="t(`Inviter un membre`)"
size="lg"
size="xl"
@open="resetInviteForm"
>
<template #button="{ attrs, listeners }">
Expand Down Expand Up @@ -141,6 +141,12 @@
:hint-text="$t(`Ce message sera inclus dans l'email d'invitation`)"
/>
</div>

<DatasetAssignmentSelector
v-if="inviteForm.role === 'partial_editor' && currentOrganization"
v-model="inviteSelectedDatasetIds"
:organization-id="currentOrganization.id"
/>
</form>
</template>

Expand Down Expand Up @@ -200,7 +206,10 @@
>
{{ t("Dernière connexion") }}
</AdminTableTh>
<AdminTableTh scope="col">
<AdminTableTh
scope="col"
class="w-0"
>
{{ t("Actions") }}
</AdminTableTh>
</tr>
Expand Down Expand Up @@ -254,8 +263,8 @@
<ModalWithButton
v-if="organization.permissions.members"
:title="$t('Modifier le membre')"
size="lg"
@open="newRole = member.role"
size="xl"
@open="openEditModal(member)"
>
<template #button="{ attrs, listeners }">
<BrandedButton
Expand Down Expand Up @@ -309,6 +318,12 @@
</BrandedButton>
</div>
</form>
<DatasetAssignmentSelector
v-if="newRole === 'partial_editor' && currentOrganization"
v-model="editSelectedDatasetIds"
:organization-id="currentOrganization.id"
/>

<BannerAction
class="mt-4"
type="danger"
Expand Down Expand Up @@ -340,7 +355,7 @@
import { Avatar, BannerAction, BrandedButton, LoadingBlock, SearchableSelect, SelectGroup, useFormatDate, useGetUserAvatar, type Member, type Organization } from '@datagouv/components-next'
import { computed, ref } from 'vue'
import { RiEyeLine, RiLogoutBoxRLine, RiPencilLine, RiUserAddLine } from '@remixicon/vue'
import type { AdminBadgeType, MemberRole, PendingMembershipRequest, UserSuggest } from '~/types/types'
import type { AdminBadgeType, Assignment, MemberRole, PendingMembershipRequest, UserSuggest } from '~/types/types'
import AdminTable from '~/components/AdminTable/Table/AdminTable.vue'
import AdminTableTh from '~/components/AdminTable/Table/AdminTableTh.vue'
import ModalWithButton from '~/components/Modal/ModalWithButton.vue'
Expand Down Expand Up @@ -409,7 +424,26 @@ const rolesOptions = computed(() => {
const loading = ref(false)

function getStatusType(role: MemberRole): AdminBadgeType {
return role === 'admin' ? 'primary' : 'secondary'
if (role === 'admin') return 'primary'
if (role === 'partial_editor') return 'default'
return 'secondary'
}

const editSelectedDatasetIds = ref<Set<string>>(new Set())
const editOriginalAssignments = ref<Array<Assignment>>([])

const openEditModal = async (member: Member) => {
newRole.value = member.role
editSelectedDatasetIds.value = new Set()
editOriginalAssignments.value = []

if (member.role === 'partial_editor' && currentOrganization.value) {
const assignments = await $api<Array<Assignment>>(`/api/1/organizations/${currentOrganization.value.id}/assignments/`, {
query: { user: member.user.id },
})
editOriginalAssignments.value = assignments.filter(a => a.subject.class === 'Dataset')
editSelectedDatasetIds.value = new Set(editOriginalAssignments.value.map(a => a.subject.id))
}
}

const removeMemberFromOrganization = async (member: Member, close: () => void) => {
Expand All @@ -424,18 +458,48 @@ const removeMemberFromOrganization = async (member: Member, close: () => void) =
}
}

const syncAssignments = async (member: Member) => {
const orgId = currentOrganization.value!.id
const originalIds = new Set(editOriginalAssignments.value.map(a => a.subject.id))
const added = [...editSelectedDatasetIds.value].filter(id => !originalIds.has(id))
const removed = editOriginalAssignments.value.filter(a => !editSelectedDatasetIds.value.has(a.subject.id))

const createPromises = added.map(id =>
$api(`/api/1/organizations/${orgId}/assignments/`, {
method: 'POST',
body: JSON.stringify({ user: member.user.id, subject: { class: 'Dataset', id } }),
}),
)

const deletePromises = removed.map(a =>
$api(`/api/1/organizations/${orgId}/assignments/${a.id}/`, { method: 'DELETE' }),
)

await Promise.all([...createPromises, ...deletePromises])
}

const updateRole = async (member: Member, close: () => void) => {
if (member.role === newRole.value) {
const roleChanged = member.role !== newRole.value
const isPartialEditor = newRole.value === 'partial_editor'
const originalIds = new Set(editOriginalAssignments.value.map(a => a.subject.id))
const assignmentsChanged = isPartialEditor && !setsEqual(editSelectedDatasetIds.value, originalIds)

if (!roleChanged && !assignmentsChanged) {
close()
return
}

try {
loading.value = true
await $api(`/api/1/organizations/${currentOrganization.value!.id}/member/${member.user.id}`, {
method: 'PUT',
body: JSON.stringify({ role: newRole.value }),
})
if (roleChanged) {
await $api(`/api/1/organizations/${currentOrganization.value!.id}/member/${member.user.id}`, {
method: 'PUT',
body: JSON.stringify({ role: newRole.value }),
})
}
if (isPartialEditor && assignmentsChanged) {
await syncAssignments(member)
}
await refresh()
close()
}
Expand Down Expand Up @@ -494,13 +558,20 @@ const { form: inviteForm, getFirstError, validate, removeErrorsAndWarnings, touc
email: [email(), value => isEmailAlreadyInvited(value)],
})

const inviteSelectedDatasetIds = ref<Set<string>>(new Set())

watch(() => inviteForm.value.role, () => {
inviteSelectedDatasetIds.value = new Set()
})

const resetInviteForm = () => {
inviteForm.value = {
role: null,
user: null,
email: '',
comment: '',
}
inviteSelectedDatasetIds.value = new Set()
removeErrorsAndWarnings()
}

Expand All @@ -525,6 +596,9 @@ const submitInvitation = async (close: () => void) => {
email: inviteForm.value.email || undefined,
role: inviteForm.value.role,
comment: inviteForm.value.comment || undefined,
assignments: inviteForm.value.role === 'partial_editor'
? [...inviteSelectedDatasetIds.value].map(id => ({ class: 'Dataset', id }))
: undefined,
}),
})
await refreshAll()
Expand Down
Loading
Loading