Skip to content
Merged
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: 2 additions & 0 deletions .agents/skills/pr-ready/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ Required content:

Length guidance: as compact as the change allows. A typo fix is one sentence. A multi-part feature is a short summary plus a tight bullet list. **No filler, no test-plan boilerplate, no marketing tone.** If you find yourself padding, stop.

Line breaks: **no superfluous line breaks.** Only insert a blank line where it marks a genuine section delimitation (e.g. between the summary and a `**Why:**` block, or before a bullet list). Do not separate every sentence with a blank line, do not pad between bullets, and do not add leading or trailing blank lines.

Example, for a small feature:

```markdown
Expand Down
20 changes: 16 additions & 4 deletions api/types/artefact/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,18 @@ export default {
type: 'string',
enum: ['processing', 'catalog', 'application', 'tileset', 'maplibre-style', 'other']
},
// Mirror-aware visibility (driven by the admin form's VJSF context):
// context.mirrored -> artefact is mirrored from a remote registry
// context.accessOnly -> this form instance edits only local access
// (public / privateAccess)
// Remote-owned fields are shown read-only in the metadata section and
// hidden from the access-only section; public/privateAccess are the
// inverse. Both context flags are absent (falsy) for a normal,
// non-mirrored artefact, so the single form shows everything.
title: {
type: 'object',
additionalProperties: false,
layout: { if: '!context.accessOnly' },
properties: {
en: { type: 'string', title: 'Title - English', 'x-i18n-title': { fr: 'Titre - Anglais' }, layout: { cols: { md: 6 } } },
fr: { type: 'string', title: 'Title - French', 'x-i18n-title': { fr: 'Titre - Français' }, layout: { cols: { md: 6 } } }
Expand All @@ -31,6 +40,7 @@ export default {
description: {
type: 'object',
additionalProperties: false,
layout: { if: '!context.accessOnly' },
properties: {
en: { type: 'string', title: 'Description - English', 'x-i18n-title': { fr: 'Description - Anglais' }, layout: { comp: 'textarea', props: { autoGrow: true, rows: 3 }, cols: { md: 6 } } },
fr: { type: 'string', title: 'Description - French', 'x-i18n-title': { fr: 'Description - Français' }, layout: { comp: 'textarea', props: { autoGrow: true, rows: 3 }, cols: { md: 6 } } }
Expand All @@ -39,6 +49,7 @@ export default {
group: {
type: 'object',
additionalProperties: false,
layout: { if: '!context.accessOnly' },
properties: {
en: {
type: 'string',
Expand Down Expand Up @@ -83,21 +94,21 @@ export default {
type: 'boolean',
title: 'Deprecated',
'x-i18n-title': { fr: 'Déprécié' },
layout: 'switch',
layout: { comp: 'switch', if: '!context.accessOnly' },
default: false
},
public: {
type: 'boolean',
title: 'Public',
'x-i18n-title': { fr: 'Public' },
layout: 'switch',
layout: { comp: 'switch', if: 'context.accessOnly || !context.mirrored' },
default: false
},
privateAccess: {
type: 'array',
title: 'Private access',
'x-i18n-title': { fr: 'Accès privés' },
layout: { if: '!parent.data?.public' },
layout: { if: '(context.accessOnly || !context.mirrored) && !parent.data?.public' },
items: {
type: 'object',
title: 'Account',
Expand Down Expand Up @@ -125,7 +136,8 @@ export default {
type: 'string',
format: 'uri',
title: 'Documentation URL',
'x-i18n-title': { fr: 'URL de documentation' }
'x-i18n-title': { fr: 'URL de documentation' },
layout: { if: '!context.accessOnly' }
},
origin: { type: 'string', readOnly: true },
// `fileName` is only used by format=file.
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion skills-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"source": "data-fair/lib",
"sourceType": "github",
"skillPath": "skills/pr-ready/SKILL.md",
"computedHash": "dd62e51657c77f3f44972b9ee62d3082a0503b3deb10f40d9fc64b45cd778451"
"computedHash": "88ee2f1b52748972924a4c8755be1be39916005a08cf97e6aa3b3076d38e27b3"
},
"upgrade-scripts": {
"source": "data-fair/lib",
Expand Down
67 changes: 66 additions & 1 deletion tests/artefact-admin.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { test, expect, type Page } from '@playwright/test'
import FormData from 'form-data'
import { superAdmin, axiosWithApiKey, clean } from './support/axios.ts'
import { superAdmin, axiosWithApiKey, clean, setArtefactOrigin } from './support/axios.ts'
import { createTestTarball } from './support/test-tarball.ts'

const pkgId = '@test/grouped-pkg@1'
const mirrorId = '@test/mirror-demo@3'

test.beforeAll(async () => {
await clean()
Expand All @@ -22,6 +23,17 @@ test.beforeAll(async () => {
group: { en: 'Geo tools', fr: 'Outils géo' },
documentation: 'https://example.com/docs'
})

// A mirrored artefact: metadata is owned by the (simulated) remote registry,
// only local access (public/privateAccess) may be edited here.
const mirrorForm = new FormData()
mirrorForm.append('file', await createTestTarball({ name: '@test/mirror-demo', version: '3.4.5' }), { filename: 'p.tgz', contentType: 'application/gzip' })
await upload.post('/api/v1/artefacts/npm/' + encodeURIComponent(mirrorId), mirrorForm, { headers: mirrorForm.getHeaders() })
await ax.patch('/api/v1/artefacts/' + encodeURIComponent(mirrorId), {
title: { en: 'Mirror Demo', fr: 'Démo Miroir' },
group: { en: 'Statistics', fr: 'Statistiques' }
})
await setArtefactOrigin(mirrorId, 'https://upstream.example.com')
})

test.describe('Artefact admin metadata editing', () => {
Expand Down Expand Up @@ -51,6 +63,59 @@ test.describe('Artefact admin metadata editing', () => {
await page.reload()
await expect(page.getByLabel('Groupe - Anglais')).toHaveValue('Mapping tools')
})

test('warns before navigating away with unsaved changes', async ({ page }) => {
await page.goto('/registry/artefacts/' + encodeURIComponent(pkgId))
await expect(page.locator('#artefact-admin')).toBeVisible()

const groupEn = page.getByLabel('Groupe - Anglais')
await groupEn.fill('Unsaved change')
await groupEn.blur()

// Dismissing the confirmation keeps us on the page.
page.once('dialog', dialog => dialog.dismiss())
await page.getByRole('link', { name: 'Administration' }).click()
await expect(page.locator('#artefact-admin')).toBeVisible()

// Accepting it lets the navigation through.
page.once('dialog', dialog => dialog.accept())
await page.getByRole('link', { name: 'Administration' }).click()
await expect(page).toHaveURL(/\/admin/)
})
})

test.describe('Artefact admin metadata editing for a mirrored artefact', () => {
test.beforeEach(async ({ page }) => {
await loginAdmin(page)
})

test('remote-owned metadata is read-only while local access stays editable', async ({ page }) => {
await page.goto('/registry/artefacts/' + encodeURIComponent(mirrorId))
await expect(page.locator('#artefact-admin')).toBeVisible()

// Mirror notice is shown and the remote-owned metadata appears read-only.
await expect(page.getByText('mirroré depuis un registre distant')).toBeVisible()
const groupEn = page.getByLabel('Groupe - Anglais')
await expect(groupEn).toHaveValue('Statistics')
await expect(groupEn).toBeDisabled()

// The local access switch (Public) is editable.
await expect(page.getByLabel('Public')).toBeEnabled()
})

test('toggling local access and saving succeeds (only public/privateAccess are sent)', async ({ page }) => {
await page.goto('/registry/artefacts/' + encodeURIComponent(mirrorId))
await expect(page.locator('#artefact-admin')).toBeVisible()

// Before the fix the form sent the remote-owned fields too and the API
// answered 403; toggling Public and saving must now succeed.
await page.getByLabel('Public').click()
await page.locator('#artefact-admin').getByRole('button', { name: 'Enregistrer' }).click()
await expect(page.getByText('Modifications enregistrées')).toBeVisible()

await page.reload()
await expect(page.getByLabel('Public')).toBeChecked()
})
})

// Logs in as an admin (admin mode on) by replaying the simple-directory password
Expand Down
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@unhead/vue": "^2.1.10",
"dayjs": "^1.11.19",
"debug": "^4.4.3",
"fast-deep-equal": "^3.1.3",
"vue": "^3.5.13",
"vue-router": "^4.5.1",
"vuetify": "^4.0.1"
Expand Down
83 changes: 72 additions & 11 deletions ui/src/components/artefact-admin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@
<v-card class="mb-4">
<v-card-title>{{ t('editableMetadata') }}</v-card-title>
<v-card-text>
<!-- Mirrored artefacts: the remote registry owns the metadata, so show
it read-only. Only local access (public / privateAccess) below is
editable here. -->
<template v-if="artefact.origin">
<v-alert
type="info"
variant="tonal"
density="compact"
class="mb-4"
:text="t('mirroredNotice')"
/>
<vjsf-patch-req
:model-value="readonlyData"
:locale="locale"
:options="readonlyVjsfOptions"
/>
</template>
<v-form v-model="valid">
<vjsf-patch-req
v-model="editData"
Expand Down Expand Up @@ -132,6 +149,7 @@ fr:
replace: Remplacer
remove: Retirer
editableMetadata: Métadonnées éditables
mirroredNotice: Cet artefact est mirroré depuis un registre distant. Ses métadonnées sont en lecture seule ; seul l'accès local (public / accès privés) est modifiable ici.
save: Enregistrer
saved: Modifications enregistrées
dangerZone: Zone de danger
Expand All @@ -149,6 +167,7 @@ en:
replace: Replace
remove: Remove
editableMetadata: Editable Metadata
mirroredNotice: This artefact is mirrored from a remote registry. Its metadata is read-only; only local access (public / private access) can be edited here.
save: Save
saved: Changes saved
dangerZone: Danger Zone
Expand All @@ -165,6 +184,9 @@ import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { mdiImage } from '@mdi/js'
import equal from 'fast-deep-equal'
import { computedDeepDiff } from '@data-fair/lib-vue/deep-diff.js'
import { useLeaveGuard } from '@data-fair/lib-vue/leave-guard.js'
import type { VjsfOptions } from '@koumoul/vjsf/types.js'
import type { Artefact } from '#api/types'

Expand All @@ -174,12 +196,44 @@ const emit = defineEmits<{ changed: [] }>()
const { t, locale } = useI18n()
const router = useRouter()

// Mirrored artefacts: the remote registry owns the metadata. Only `public`
// and `privateAccess` can be patched locally (the API rejects anything else
// with 403), so the editable form is restricted to those fields.
const isMirror = computed(() => !!artefact.origin)

const editData = ref<Record<string, any>>({})
const originalEditData = ref('')
const readonlyData = ref<Record<string, any>>({})
const valid = ref(true)
const confirmDelete = ref(false)

const hasDiff = computed(() => JSON.stringify(editData.value) !== originalEditData.value)
// Build the normalized patch payload from a source (the live form, or the
// saved artefact). The same shape is used both to compute the diff and as the
// PATCH body, so they can never drift. On a mirror only the access fields are
// included — the remote registry owns the rest.
const buildPayload = (src: Record<string, any>) => {
const payload: Record<string, any> = {
public: src.public ?? false,
privateAccess: src.privateAccess?.length ? src.privateAccess : null
}
if (isMirror.value) return payload
payload.title = (src.title?.fr || src.title?.en) ? src.title : null
payload.description = (src.description?.fr || src.description?.en) ? src.description : null
payload.group = (src.group?.fr || src.group?.en) ? src.group : null
payload.documentation = src.documentation || null
payload.deprecated = src.deprecated ?? false
return payload
}

// computedDeepDiff keeps the reference stable across VJSF's frequent re-emits
// (it returns the previous value when the new one is deeply equal), so the
// payload only changes identity on a real edit.
const editablePayload = computedDeepDiff(() => buildPayload(editData.value))
const savedPayload = computed(() => buildPayload(artefact))

const hasDiff = computed(() => !equal(editablePayload.value, savedPayload.value))

// Warn before navigating away (route change or tab close) with unsaved edits.
useLeaveGuard(hasDiff, { locale })

const vjsfOptions = computed<Partial<VjsfOptions>>(() => ({
validateOn: 'input',
Expand All @@ -189,7 +243,16 @@ const vjsfOptions = computed<Partial<VjsfOptions>>(() => ({
initialValidation: 'always',
locale: locale.value,
xI18n: true,
context: { category: artefact.category, apiPath: $apiPath }
// accessOnly mirrors mirrored: on a mirror the editable form shows only the
// local access fields; on a normal artefact it shows everything.
context: { category: artefact.category, apiPath: $apiPath, mirrored: isMirror.value, accessOnly: isMirror.value }
}))

// Read-only display of the remote-owned metadata, shown for mirrors only.
const readonlyVjsfOptions = computed<Partial<VjsfOptions>>(() => ({
...vjsfOptions.value,
readOnly: true,
context: { category: artefact.category, apiPath: $apiPath, mirrored: true, accessOnly: false }
}))

// Re-seed the edit form whenever the artefact is (re)loaded by the parent.
Expand All @@ -203,18 +266,16 @@ watch(() => artefact, () => {
public: artefact.public ?? false,
privateAccess: artefact.privateAccess ? [...artefact.privateAccess] : []
}
originalEditData.value = JSON.stringify(editData.value)
// Frozen snapshot for the read-only metadata form (mirrors only); kept
// separate from editData so the editable access form can never mutate it.
readonlyData.value = { ...editData.value }
}, { immediate: true })

const patchAction = useAsyncAction(
async () => {
const body = { ...editData.value }
if (body.title && !body.title.fr && !body.title.en) body.title = null
if (body.description && !body.description.fr && !body.description.en) body.description = null
if (body.group && !body.group.fr && !body.group.en) body.group = null
if (body.privateAccess && body.privateAccess.length === 0) body.privateAccess = null

await $fetch(`/v1/artefacts/${encodeURIComponent(artefact._id)}`, { method: 'PATCH', body })
// The payload already excludes the remote-owned fields on a mirror, so the
// API never sees a forbidden key (which it would answer with 403).
await $fetch(`/v1/artefacts/${encodeURIComponent(artefact._id)}`, { method: 'PATCH', body: editablePayload.value })
emit('changed')
},
{ success: t('saved') }
Expand Down
Loading
Loading