diff --git a/components/AdminTable/AdminCommunityResourcesTable/AdminCommunityResourcesTable.vue b/components/AdminTable/AdminCommunityResourcesTable/AdminCommunityResourcesTable.vue index 81a4101f4..ac5dd290c 100644 --- a/components/AdminTable/AdminCommunityResourcesTable/AdminCommunityResourcesTable.vue +++ b/components/AdminTable/AdminCommunityResourcesTable/AdminCommunityResourcesTable.vue @@ -6,41 +6,43 @@ - - {{ t("Titre de la ressource") }} - - - {{ t("Statut") }} - - - {{ t("Format") }} - - - {{ t("Créé le") }} - - - {{ t("Modifié le") }} - - - {{ t("Action") }} - + + + {{ t("Titre de la ressource") }} + + + {{ t("Statut") }} + + + {{ t("Format") }} + + + {{ t("Créé le") }} + + + {{ t("Modifié le") }} + + + {{ t("Action") }} + + - - {{ t("Nom du point de contact") }} - - - {{ t("Rôle") }} - - - {{ t("E-mail du contact") }} - - - {{ t("Url du point de contact") }} - - - {{ t("Action") }} - + + + {{ t("Nom du point de contact") }} + + + {{ t("Rôle") }} + + + {{ t("E-mail du contact") }} + + + {{ t("Url du point de contact") }} + + + {{ t("Action") }} + + - - {{ t('Nouvelle attribution') }} - - {{ t('Nouveau point de contact') }} + {{ t('Nouvelle attribution') }} @@ -49,7 +46,6 @@ class="p-3 bg-gray-some grid grid-cols-2 gap-3 mt-2" >

{{ t("Rôle:") }} @@ -152,7 +147,6 @@ const contact = defineModel() const props = defineProps<{ organization: Organization | OrganizationReference - showAttributions?: boolean errorText?: string | null warningText?: string | null }>() @@ -203,8 +197,7 @@ function getRole(role: string) { } const contactsWithNewOption = computed>(() => { - const attributions = [...contacts.value?.data ?? [], newContactForm.value] - return props.showAttributions ? attributions : attributions.filter(c => c.role === 'contact') + return [...contacts.value?.data ?? [], newContactForm.value] }) async function suggestContactPoint(query: string): Promise> { diff --git a/components/Dataservices/AdminUpdateDataservicePage.vue b/components/Dataservices/AdminUpdateDataservicePage.vue index cece20e6f..9e5bccf79 100644 --- a/components/Dataservices/AdminUpdateDataservicePage.vue +++ b/components/Dataservices/AdminUpdateDataservicePage.vue @@ -7,7 +7,6 @@ `/api/1/dataservices/${route.params.id}`) const { data: dataservice, status, refresh } = await useAPI(url, { redirectOn404: true }) const dataserviceForm = ref(null) -const harvested = ref(false) watchEffect(() => { if (!dataservice.value) return dataserviceForm.value = dataserviceToForm(dataservice.value) - harvested.value = isHarvested(dataservice.value) }) async function save() { diff --git a/components/Dataservices/DescribeDataservice.vue b/components/Dataservices/DescribeDataservice.vue index 907e0db0f..0308eca20 100644 --- a/components/Dataservices/DescribeDataservice.vue +++ b/components/Dataservices/DescribeDataservice.vue @@ -349,7 +349,7 @@ class="fr-fieldset__legend" >

- {{ harvested ? t("Attributions et points de contacts") : t("Points de contact") }} + {{ t("Points de contact et attributions") }}

- {{ harvested ? t('Nouvelle attribution') : t('Nouveau contact') }} + {{ t('Nouvelle attribution') }} @@ -561,7 +559,6 @@ import ProducerSelect from '~/components/ProducerSelect.vue' import type { DataserviceForm } from '~/types/types' const props = defineProps<{ - harvested?: boolean type: 'create' | 'update' }>() const dataserviceForm = defineModel({ required: true }) diff --git a/components/Datasets/AdminUpdateDatasetPage.vue b/components/Datasets/AdminUpdateDatasetPage.vue index eb289d205..33894c3c3 100644 --- a/components/Datasets/AdminUpdateDatasetPage.vue +++ b/components/Datasets/AdminUpdateDatasetPage.vue @@ -8,7 +8,6 @@ v-if="datasetForm" v-model="datasetForm" type="update" - :harvested :badges="dataset.badges" :submit-label="t('Sauvegarder')" :can-edit="dataset.permissions.edit" @@ -158,12 +157,10 @@ const { data: dataset, status, refresh } = await useAPI }) const datasetForm = ref(null) -const harvested = ref(false) const pendingBadges = ref | null>(null) watchEffect(() => { if (dataset.value) { datasetForm.value = datasetToForm(dataset.value) - harvested.value = isHarvested(dataset.value) } }) diff --git a/components/Datasets/DescribeDataset.vue b/components/Datasets/DescribeDataset.vue index 45e98f9fa..7583a58f3 100644 --- a/components/Datasets/DescribeDataset.vue +++ b/components/Datasets/DescribeDataset.vue @@ -531,7 +531,7 @@ class="fr-fieldset__legend" >

- {{ harvested ? t("Attributions et points de contact") : t("Points de contact") }} + {{ t("Points de contact et attributions") }}

- {{ harvested ? t('Nouvelle attribution') : t('Nouveau contact') }} + {{ t('Nouvelle attribution') }} @@ -789,7 +787,6 @@ const datasetForm = defineModel({ required: true }) const props = withDefaults(defineProps<{ submitLabel: string type: 'create' | 'update' - harvested?: boolean badges?: Array canEdit?: boolean readOnlyMessage?: string diff --git a/tests/contact-points.spec.ts b/tests/contact-points.spec.ts new file mode 100644 index 000000000..c5140bddc --- /dev/null +++ b/tests/contact-points.spec.ts @@ -0,0 +1,182 @@ +import { randomUUID } from 'node:crypto' +import type { APIRequestContext, Locator, Page } from '@playwright/test' +import { test, expect } from './base' +import { createContactPoint, createDataset, createOrganization, deleteContactPointsOf, deleteDatasets, deleteOrganizations } from './helpers' + +// Attributions used to be reserved to harvested datasets (datagouv/data.gouv.fr#2062). +// These tests cover them on a dataset published directly on data.gouv.fr: the form +// offers every role, and the public page displays the role whatever the origin. + +const createdDatasets: Array = [] +const createdOrganizations: Array = [] + +test.afterEach(async ({ request }) => { + await deleteDatasets(request, createdDatasets) + await deleteContactPointsOf(request, createdOrganizations) + await deleteOrganizations(request, createdOrganizations) +}) + +// Several fieldsets of the dataset form share the same `aria-labelledby` target, so +// `getByRole('group')` resolves them all to the first legend: match on the legend text. +const contactPointsSection = (page: Page) => + page.locator('fieldset').filter({ hasText: 'Points de contact et attributions' }) + +// The role of a selected attribution is shown as a badge inside a "Rôle:" paragraph. +// The section title itself contains "contact", so a bare text match is ambiguous. +const selectedRole = (section: Locator) => + section.getByRole('paragraph').filter({ hasText: 'Rôle:' }) + +// Workers run in parallel and organization slugs are unique in udata: a timestamp is +// not enough to tell two organizations created in the same millisecond apart. +const uniqueSuffix = () => randomUUID().slice(0, 8) + +async function createOrganizationWithDataset(request: APIRequestContext, uniqueId: string, contactPoints: Array = []) { + // The admin creating the organization becomes a member, so the same user can edit + // the dataset and manage the organization's contact points. + const organization = await createOrganization(request, `Org attributions ${uniqueId}`) + createdOrganizations.push(organization.id) + + const dataset = await createDataset( + request, + `Dataset attributions ${uniqueId}`, + 'Jeu de données pour tester les attributions', + { organization: organization.id, contactPoints }, + ) + createdDatasets.push(dataset.id) + + return { organization, dataset } +} + +test('can add an attribution with a specific role to a directly published dataset', async ({ page, request }) => { + const uniqueId = uniqueSuffix() + const { dataset } = await createOrganizationWithDataset(request, uniqueId) + + await page.goto(`/admin/datasets/${dataset.id}/`) + await page.waitForLoadState('networkidle') + + const section = contactPointsSection(page) + await expect(section.getByRole('heading', { name: 'Points de contact et attributions' })).toBeVisible() + + await section.getByRole('button', { name: 'Nouvelle attribution' }).click() + + // "Contact" is the role selected by default when adding an attribution + await expect(section.getByLabel('Rôle *')).toHaveValue('contact') + + await section.getByLabel('Rôle *').selectOption('creator') + await section.getByLabel('Nom *').fill(`Service producteur ${uniqueId}`) + await section.getByLabel('E-mail').fill(`producteur-${uniqueId}@example.org`) + await section.getByRole('button', { name: 'Enregistrer' }).click() + + // Saving the contact point selects it and replaces the form by its summary + await expect(selectedRole(section)).toContainText('Créateur') + + await page.getByRole('button', { name: 'Sauvegarder' }).click() + await expect(page.getByText('Jeu de données mis à jour !')).toBeVisible() + + await page.goto(`/datasets/${dataset.id}/`) + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Attributions', { exact: true })).toBeVisible() + await expect(page.getByRole('link', { name: `Service producteur ${uniqueId}` })).toBeVisible() + await expect(page.getByText('(Créateur)')).toBeVisible() + // A dataset carrying a role other than "contact" presents its organization as the + // distributor, the producer being the attribution. + await expect(page.getByText('Diffuseur', { exact: true })).toBeVisible() +}) + +test('keeps the plain contact wording when the default role is kept', async ({ page, request }) => { + const uniqueId = uniqueSuffix() + const { dataset } = await createOrganizationWithDataset(request, uniqueId) + + await page.goto(`/admin/datasets/${dataset.id}/`) + await page.waitForLoadState('networkidle') + + const section = contactPointsSection(page) + await section.getByRole('button', { name: 'Nouvelle attribution' }).click() + await section.getByLabel('Nom *').fill(`Guichet unique ${uniqueId}`) + await section.getByLabel('E-mail').fill(`guichet-${uniqueId}@example.org`) + await section.getByRole('button', { name: 'Enregistrer' }).click() + await expect(selectedRole(section)).toContainText('Contact') + + await page.getByRole('button', { name: 'Sauvegarder' }).click() + await expect(page.getByText('Jeu de données mis à jour !')).toBeVisible() + + await page.goto(`/datasets/${dataset.id}/`) + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Contacts', { exact: true })).toBeVisible() + await expect(page.getByRole('link', { name: `Guichet unique ${uniqueId}` })).toBeVisible() + await expect(page.getByText('(Contact)')).toBeVisible() + await expect(page.getByText('Producteur', { exact: true })).toBeVisible() +}) + +test('can select an attribution already created by the organization', async ({ page, request }) => { + const uniqueId = uniqueSuffix() + const { organization, dataset } = await createOrganizationWithDataset(request, uniqueId) + const contactPoint = await createContactPoint(request, organization.id, { + name: `Cellule juridique ${uniqueId}`, + email: `juridique-${uniqueId}@example.org`, + role: 'rightsHolder', + }) + + await page.goto(`/admin/datasets/${dataset.id}/`) + await page.waitForLoadState('networkidle') + + const section = contactPointsSection(page) + await section.getByTestId('searchable-select-choisissez-l-attribution-avec-laquelle-vous-voulez-publier').click() + await page.getByRole('option', { name: contactPoint.name }).click() + + await expect(selectedRole(section)).toContainText('Détenteur des droits') + + await page.getByRole('button', { name: 'Sauvegarder' }).click() + await expect(page.getByText('Jeu de données mis à jour !')).toBeVisible() + + await page.goto(`/datasets/${dataset.id}/`) + await page.waitForLoadState('networkidle') + + await expect(page.getByRole('link', { name: contactPoint.name })).toBeVisible() + await expect(page.getByText('(Détenteur des droits)')).toBeVisible() +}) + +test('can update a contact point and see the new role on the dataset page', async ({ page, request }) => { + const uniqueId = uniqueSuffix() + const organization = await createOrganization(request, `Org attributions ${uniqueId}`) + createdOrganizations.push(organization.id) + + const contactPoint = await createContactPoint(request, organization.id, { + name: `Support ${uniqueId}`, + email: `support-${uniqueId}@example.org`, + role: 'contact', + }) + const dataset = await createDataset( + request, + `Dataset attributions ${uniqueId}`, + 'Jeu de données pour tester les attributions', + { organization: organization.id, contactPoints: [contactPoint.id] }, + ) + createdDatasets.push(dataset.id) + + await page.goto(`/admin/organizations/${organization.id}/profile/contacts`) + await page.waitForLoadState('networkidle') + + const row = page.getByRole('row').filter({ hasText: `Support ${uniqueId}` }) + await expect(row.getByText('Contact')).toBeVisible() + await expect(row.getByText(`support-${uniqueId}@example.org`)).toBeVisible() + + await row.getByRole('button', { name: 'Modifier' }).click() + const dialog = page.getByRole('dialog') + await dialog.getByLabel('Nom *').fill(`Direction juridique ${uniqueId}`) + await dialog.getByLabel('Rôle *').selectOption('rightsHolder') + await dialog.getByRole('button', { name: 'Sauvegarder' }).click() + + await expect(page.getByText('Point de contact mis à jour !')).toBeVisible() + const updatedRow = page.getByRole('row').filter({ hasText: `Direction juridique ${uniqueId}` }) + await expect(updatedRow.getByText('Détenteur des droits')).toBeVisible() + + await page.goto(`/datasets/${dataset.id}/`) + await page.waitForLoadState('networkidle') + + await expect(page.getByText('Attributions', { exact: true })).toBeVisible() + await expect(page.getByRole('link', { name: `Direction juridique ${uniqueId}` })).toBeVisible() + await expect(page.getByText('(Détenteur des droits)')).toBeVisible() +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 50297df3c..d48509822 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -7,6 +7,7 @@ export type ApiDataset = { id: string, title: string, slug: string } export type ApiResource = { id: string, title: string, latest: string, url: string } export type ApiHarvestSource = { id: string, name: string, backend: string, schedule: string | null, config: Record } export type ApiOrganization = { id: string, name: string } +export type ApiContactPoint = { id: string, name: string, email: string | null, contact_form: string | null, role: string } export async function createHarvestSource(request: APIRequestContext, name: string, backend: string, config: Record = {}): Promise { const response = await request.post(`${API_BASE}/api/1/harvest/sources/`, { @@ -29,12 +30,14 @@ export async function deleteHarvestSources(request: APIRequestContext, ids: Arra } } -export async function createDataset(request: APIRequestContext, title: string, description: string): Promise { +export async function createDataset(request: APIRequestContext, title: string, description: string, owned: { organization?: string, contactPoints?: Array } = {}): Promise { const response = await request.post(`${API_BASE}/api/1/datasets/`, { data: { title, description, frequency: 'unknown', + organization: owned.organization, + contact_points: owned.contactPoints, }, }) if (!response.ok()) { @@ -91,6 +94,30 @@ export async function createOrganization(request: APIRequestContext, name: strin return await response.json() } +export async function createContactPoint(request: APIRequestContext, organizationId: string, contactPoint: { name: string, email: string, role: string }): Promise { + const response = await request.post(`${API_BASE}/api/1/contacts/`, { + data: { ...contactPoint, organization: organizationId }, + }) + if (!response.ok()) { + throw new Error(`Failed to create contact point "${contactPoint.name}": ${response.status()} ${(await response.text()).slice(0, 300)}`) + } + return await response.json() +} + +// Contact points created through the UI have no id to collect, so they are cleaned up +// from their organization rather than from a list built by the test. +export async function deleteContactPointsOf(request: APIRequestContext, organizationIds: Array): Promise { + for (const organizationId of organizationIds) { + const response = await request.get(`${API_BASE}/api/1/organizations/${organizationId}/contacts/`) + if (!response.ok()) continue + + const { data } = await response.json() as { data: Array } + for (const contactPoint of data) { + await request.delete(`${API_BASE}/api/1/contacts/${contactPoint.id}/`) + } + } +} + export async function deleteOrganizations(request: APIRequestContext, ids: Array): Promise { for (const id of ids.splice(0)) { await request.delete(`${API_BASE}/api/1/organizations/${id}/`) diff --git a/utils/harvesters.ts b/utils/harvesters.ts index 717ce4565..5c57c4521 100644 --- a/utils/harvesters.ts +++ b/utils/harvesters.ts @@ -1,4 +1,3 @@ -import type { Dataservice, Dataset, DatasetV2, DatasetV2WithFullObject } from '@datagouv/components-next' import cloneDeep from 'lodash-es/cloneDeep' import type { HarvestBackend, HarvesterForm, HarvesterJob, HarvesterSource, HarvestSourceConfig, HarvestSourceFilter, NewHarvesterForApi } from '~/types/harvesters' @@ -10,10 +9,6 @@ export function getHarvesterJobAdminUrl(harvester: HarvesterSource, job: Harvest return `${getHarvesterAdminUrl(harvester)}/jobs/${job.id}` } -export function isHarvested(dataset: Dataservice | Dataset | DatasetV2 | DatasetV2WithFullObject) { - return !!dataset.harvest?.source_id || false -} - export function harvesterToForm(harvester: HarvesterSource): HarvesterForm { return { owned: harvester.organization ? { organization: harvester.organization, owner: null } : { owner: harvester.owner, organization: null },