From 4871064f28c019e1f9b65f82507032ce10d6e71b Mon Sep 17 00:00:00 2001 From: Mihai Lefter Date: Tue, 15 Sep 2026 22:22:52 +0200 Subject: [PATCH 01/38] Show ownership badges on resource pages --- src/assets/main.css | 60 ++++++- src/components/OwnersBadge.vue | 60 +++++++ src/composables/fdpApi.ts | 83 ++++++++++ src/composables/useMembers.ts | 56 +++++++ src/composables/useMeta.ts | 51 ++++++ src/composables/useResourceView.ts | 14 +- src/views/ResourceView.vue | 27 +++- tests/composables/fdpApi.test.ts | 173 +++++++++++++++++++- tests/composables/useMembers.test.ts | 179 +++++++++++++++++++++ tests/composables/useMeta.test.ts | 185 ++++++++++++++++++++++ tests/composables/useResourceView.test.ts | 42 +++++ 11 files changed, 922 insertions(+), 8 deletions(-) create mode 100644 src/components/OwnersBadge.vue create mode 100644 src/composables/useMembers.ts create mode 100644 src/composables/useMeta.ts create mode 100644 tests/composables/useMembers.test.ts create mode 100644 tests/composables/useMeta.test.ts diff --git a/src/assets/main.css b/src/assets/main.css index 73a0d1e..247bcb2 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -100,8 +100,66 @@ a:hover { line-height: 1.2; } -.resource-title { +.resource-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--fdp-space-3); margin: 0 0 var(--fdp-space-3); +} + +.membership-badge { + padding: var(--fdp-space-1) var(--fdp-space-3); + border: 1px solid var(--fdp-color-muted); + border-radius: var(--fdp-radius-pill); + color: var(--fdp-color-muted); + font-size: var(--fdp-font-size-sm); +} + +.resource-badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--fdp-space-2); + margin-left: auto; +} + +.owners-badge { + position: relative; + display: inline-flex; +} + +.owners-badge__trigger { + background: none; + cursor: pointer; +} + +.owners-badge__tooltip { + position: absolute; + top: calc(100% + var(--fdp-space-1)); + right: 0; + z-index: 10; + width: max-content; + max-width: min(20rem, calc(100vw - 2 * var(--fdp-space-4))); + padding: var(--fdp-space-2) var(--fdp-space-3); + border-radius: var(--fdp-radius-sm); + background: var(--fdp-color-text); + color: var(--fdp-color-surface); + font-size: var(--fdp-font-size-sm); +} + +/* Keep the gap hoverable when moving from the trigger onto the tooltip. */ +.owners-badge__tooltip::before { + content: ''; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + height: var(--fdp-space-1); +} + +.resource-title { + margin: 0; font-size: var(--fdp-font-size-2xl); line-height: 1.15; } diff --git a/src/components/OwnersBadge.vue b/src/components/OwnersBadge.vue new file mode 100644 index 0000000..51157ec --- /dev/null +++ b/src/components/OwnersBadge.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/composables/fdpApi.ts b/src/composables/fdpApi.ts index f8e2d34..dc6c49b 100644 --- a/src/composables/fdpApi.ts +++ b/src/composables/fdpApi.ts @@ -1,6 +1,70 @@ import { bindOperation, type OperationBinding } from './apiDocs' import { authHeaders, request } from './fetchUtils' +/** Identifies a typed resource; null explicitly identifies the FDP root. */ +export type ResourceIdentifier = { resourceType: string; id: string } | null + +// Fields used from the backend's member, meta and resource-definition responses. +export type ResourceMember = { + user: { uuid: string; firstName: string; lastName: string } + membership: { + name: string + permissions: { code: string }[] + } +} + +export type ResourceMeta = { + member: { membership: ResourceMember['membership'] | null } | null +} + +type ResourceDefinition = { + uuid: string + name: string + urlPrefix: string +} + +/** Reads resource definitions through their advertised operation. */ +async function fetchResourceDefinitions(): Promise { + const { url, method } = await bindOperation('getResourceDefinitions') + const response = await request(url, { method, headers: { Accept: 'application/json' } }) + return response.json() as Promise +} + +// Share concurrent root lookups (meta and members), but don't retain definitions across visits. +let rootDefinitionRequest: Promise | null = null + +function fetchRootDefinition(): Promise { + rootDefinitionRequest ??= fetchResourceDefinitions() + .then((definitions) => { + const root = definitions.find((definition) => definition.urlPrefix === '') + if (!root) throw new Error('The FDP has no root resource definition') + return root + }) + .finally(() => { + rootDefinitionRequest = null + }) + return rootDefinitionRequest +} + +/** + * Selects an operation ID from the capitalized resource prefix or root definition name. + * bindOperation resolves its endpoint URL from the API docs. + */ +export async function getResourceOperation( + resource: ResourceIdentifier, + action: 'meta' | 'members', +) { + const suffix = action === 'meta' ? 'Meta' : 'Members' + if (resource === null) { + const root = await fetchRootDefinition() + return { operationId: `get${root.name}${suffix}` } + } + + const { resourceType, id } = resource + const resourceName = resourceType.charAt(0).toUpperCase() + resourceType.slice(1) + return { operationId: `get${resourceName}${suffix}`, pathParams: { uuid: id } } +} + /** * For /users/current, the signed-in user's profile is edited via current-user operations. * Admin routes (/users/:id) use uuid-based user operations instead. @@ -45,6 +109,25 @@ export async function deleteUser(uuid: string): Promise { await request(url, { method }) } +/** + * Fetches a resource's meta; pass null for the root. + * ResourceMeta describes only the membership fields currently used by the client. + */ +export async function fetchMeta(resource: ResourceIdentifier): Promise { + const { operationId, pathParams } = await getResourceOperation(resource, 'meta') + const { url, method } = await bindOperation(operationId, pathParams) + const response = await request(url, { method, headers: { Accept: 'application/json' } }) + return response.json() as Promise +} + +/** Fetches the members of a resource, each with their user and membership; pass null for the root. */ +export async function fetchMembers(resource: ResourceIdentifier): Promise { + const { operationId, pathParams } = await getResourceOperation(resource, 'members') + const { url, method } = await bindOperation(operationId, pathParams) + const response = await request(url, { method, headers: { Accept: 'application/json' } }) + return response.json() as Promise +} + /** Fetches a single user's profile. */ export async function fetchUser(uuid?: string): Promise { const { url } = await bindUserOperation('getUserCurrent', 'getUser', uuid) diff --git a/src/composables/useMembers.ts b/src/composables/useMembers.ts new file mode 100644 index 0000000..6b9a702 --- /dev/null +++ b/src/composables/useMembers.ts @@ -0,0 +1,56 @@ +import { computed, ref, watch, type Ref } from 'vue' +import { fetchMembers, type ResourceIdentifier, type ResourceMember } from './fdpApi' +import { useAuth } from './useAuth' + +/** + * Names of the owners of the resource (null for the root) other than the current user, + * shown to admins only. + */ +export function useMembers(resource: Ref) { + const { isLoggedIn, isAdmin, user } = useAuth() + const loading = ref(false) + const error = ref(null) + // Whether the owners list was fetched for the current resource, so an empty list is meaningful. + const loaded = ref(false) + const owners = ref([]) + const otherOwnerNames = computed(() => + owners.value + .filter((owner) => owner.uuid !== user.value?.uuid) + .map((owner) => `${owner.firstName} ${owner.lastName}`), + ) + + // Identifies the newest check, so a slower older one cannot overwrite its result. + let generation = 0 + + watch( + [resource, isLoggedIn, isAdmin], + async ([currentResource, loggedIn, admin], _previous, onCleanup) => { + const current = ++generation + onCleanup(() => { + generation++ + }) + owners.value = [] + error.value = null + loaded.value = false + loading.value = loggedIn && admin + if (!loggedIn || !admin) return + try { + const members = await fetchMembers(currentResource) + if (current !== generation) return + owners.value = members + .filter((member) => member.membership.name === 'Owner') + .map((member) => member.user) + loaded.value = true + } catch (err) { + if (current === generation) { + error.value = err instanceof Error ? err.message : 'Unable to get resource members.' + } + } finally { + if (current === generation) loading.value = false + } + }, + { immediate: true }, + ) + + return { otherOwnerNames, loaded, loading, error } +} diff --git a/src/composables/useMeta.ts b/src/composables/useMeta.ts new file mode 100644 index 0000000..00d919b --- /dev/null +++ b/src/composables/useMeta.ts @@ -0,0 +1,51 @@ +import { computed, ref, watch, type Ref } from 'vue' +import { fetchMeta, type ResourceIdentifier } from './fdpApi' +import { useAuth } from './useAuth' + +/** + * The current user's membership on the resource (null for the root), and whether they may + * write it. Admins may write everything; others need W in their membership on this exact resource. + */ +export function useMeta(resource: Ref) { + const { isLoggedIn, isAdmin } = useAuth() + const loading = ref(false) + const error = ref(null) + const membershipName = ref(null) + const hasWrite = ref(false) + // Permission only: an edit action must also check operation availability and loading/error state. + const canWrite = computed(() => isLoggedIn.value && (isAdmin.value || hasWrite.value)) + + // Identifies the newest check, so a slower older one cannot overwrite its result. + let generation = 0 + + watch( + [resource, isLoggedIn], + async ([currentResource, loggedIn], _previous, onCleanup) => { + const current = ++generation + onCleanup(() => { + generation++ + }) + membershipName.value = null + hasWrite.value = false + error.value = null + loading.value = loggedIn + if (!loggedIn) return + try { + const meta = await fetchMeta(currentResource) + if (current !== generation) return + const membership = meta.member?.membership + membershipName.value = membership?.name ?? null + hasWrite.value = membership?.permissions.some((p) => p.code === 'W') ?? false + } catch (err) { + if (current === generation) { + error.value = err instanceof Error ? err.message : 'Unable to get resource metadata.' + } + } finally { + if (current === generation) loading.value = false + } + }, + { immediate: true }, + ) + + return { membershipName, canWrite, loading, error } +} diff --git a/src/composables/useResourceView.ts b/src/composables/useResourceView.ts index 9f076d6..4568e22 100644 --- a/src/composables/useResourceView.ts +++ b/src/composables/useResourceView.ts @@ -15,6 +15,7 @@ import { } from './rdfUtils' import { useRdfLoader, type ChildSummary } from './useRdfLoader' import { getBaseUrl } from './urlUtils' +import type { ResourceIdentifier } from './fdpApi' export type { ChildSummary } @@ -28,17 +29,23 @@ export function useResourceView() { const fdpUri = `${fdpBaseUri}/` - const resourceUri = computed(() => { + const resource = computed((previous) => { const resourceType = route.params.resourceType const id = route.params.id if (typeof resourceType === 'string' && typeof id === 'string') { - return `${fdpBaseUri}/${resourceType}/${id}` + // Same resource, same object: watchers compare by identity and would refetch. + if (previous?.resourceType === resourceType && previous.id === id) return previous + return { resourceType, id } } - return `${fdpBaseUri}/` + return null }) + const resourceUri = computed(() => + resource.value ? `${fdpBaseUri}/${resource.value.resourceType}/${resource.value.id}` : fdpUri, + ) + const { loading, error, @@ -133,6 +140,7 @@ export function useResourceView() { loading, error, rawTurtle, + resource, resourceUri, currentNodeUri, title, diff --git a/src/views/ResourceView.vue b/src/views/ResourceView.vue index e711b9e..de9504d 100644 --- a/src/views/ResourceView.vue +++ b/src/views/ResourceView.vue @@ -1,15 +1,19 @@ + + diff --git a/src/views/ResourceView.vue b/src/views/ResourceView.vue index de9504d..6e4c128 100644 --- a/src/views/ResourceView.vue +++ b/src/views/ResourceView.vue @@ -8,6 +8,7 @@ import { useRawFormat, formats } from '../composables/useRawFormat' import { internalHref } from '../composables/urlUtils' import RdfGraph from '../components/RdfGraph.vue' import OwnersBadge from '../components/OwnersBadge.vue' +import IconEdit from '../assets/icons/edit.svg?component' const { loading, @@ -29,10 +30,14 @@ const { resourceLabel, } = useResourceView() -const { membershipName } = useMeta(resource) +const { membershipName, canEdit } = useMeta(resource) const { otherOwnerNames, loaded: ownersLoaded } = useMembers(resource) const isOwner = computed(() => membershipName.value === 'Owner') +const editRoute = computed(() => + resource.value ? { name: 'resource-edit', params: resource.value } : { name: 'fdp-root-edit' }, +) + const showUnknown = ref(false) const showGraph = ref(false) @@ -88,18 +93,24 @@ const {

{{ title ?? resourceLabel(resourceUri) }}

-
- - {{ - membershipName - }} - +
+
+ + {{ + membershipName + }} + +
+ + + Edit +

{{ description }}

diff --git a/tests/composables/fdpApi.test.ts b/tests/composables/fdpApi.test.ts index 9c6ffb6..a32eb5a 100644 --- a/tests/composables/fdpApi.test.ts +++ b/tests/composables/fdpApi.test.ts @@ -142,6 +142,39 @@ describe('resource operations', () => { expect(isOperationOffered(absent.operationId)).toBe(false) }) + it('resolves put operation IDs the same way, with no suffix', async () => { + vi.stubGlobal('fetch', mockApiFetch(okJson(definitions), resourceApiDocs)) + const { getResourceOperation } = await import('../../src/composables/fdpApi') + const { apiDocsReady, isOperationOffered } = await import('../../src/composables/apiDocs') + await apiDocsReady + + const typed = await getResourceOperation({ resourceType: 'catalog', id: 'abc' }, 'put') + expect(typed.operationId).toBe('putCatalog') + expect(isOperationOffered(typed.operationId)).toBe(false) + }) + + it('resolves root PUT from the configured definition name without path parameters', async () => { + vi.stubGlobal( + 'fetch', + mockApiFetch(okJson(definitions), { + ...resourceApiDocs, + paths: { + ...resourceApiDocs.paths, + '/root-update': { put: { operationId: 'putResearch Hub' } }, + }, + }), + ) + const { getResourceOperation } = await import('../../src/composables/fdpApi') + const { bindOperation } = await import('../../src/composables/apiDocs') + + const operation = await getResourceOperation(null, 'put') + expect(operation).toEqual({ operationId: 'putResearch Hub' }) + expect(await bindOperation(operation.operationId)).toEqual({ + url: 'http://localhost/root-update', + method: 'PUT', + }) + }) + it('refuses an unadvertised operation even if a conventional endpoint path exists', async () => { const handleRequest = vi.fn(okJson(meta)) vi.stubGlobal( diff --git a/tests/composables/useMeta.test.ts b/tests/composables/useMeta.test.ts index 5cee635..6052614 100644 --- a/tests/composables/useMeta.test.ts +++ b/tests/composables/useMeta.test.ts @@ -1,11 +1,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { effectScope, nextTick, ref, type EffectScope } from 'vue' import { useMeta } from '../../src/composables/useMeta' -import { fetchMeta, type ResourceMeta } from '../../src/composables/fdpApi' +import { fetchMeta, getResourceOperation, type ResourceMeta } from '../../src/composables/fdpApi' +import { isOperationOffered } from '../../src/composables/apiDocs' const auth = vi.hoisted(() => ({ isLoggedIn: { value: false }, isAdmin: { value: false } })) -vi.mock('../../src/composables/fdpApi', () => ({ fetchMeta: vi.fn() })) +vi.mock('../../src/composables/fdpApi', () => ({ + fetchMeta: vi.fn(), + getResourceOperation: vi.fn(), +})) +vi.mock('../../src/composables/apiDocs', () => ({ + apiDocsReady: Promise.resolve(), + isOperationOffered: vi.fn(), +})) vi.mock('../../src/composables/useAuth', async () => { const { ref } = await import('vue') auth.isLoggedIn = ref(false) @@ -24,6 +32,8 @@ let scope: EffectScope beforeEach(() => { scope = effectScope() vi.mocked(fetchMeta).mockReset() + vi.mocked(getResourceOperation).mockReset().mockResolvedValue({ operationId: 'putCatalog' }) + vi.mocked(isOperationOffered).mockReset().mockReturnValue(false) auth.isLoggedIn.value = false auth.isAdmin.value = false }) @@ -56,6 +66,131 @@ describe('useMeta', () => { expect(canWrite.value).toBe(true) }) + it('can edit only when it can write and the put operation is advertised', async () => { + auth.isLoggedIn.value = true + vi.mocked(fetchMeta).mockResolvedValue(metaWith('Owner', ['W'])) + vi.mocked(isOperationOffered).mockReturnValue(true) + const { canWrite, canEdit } = scope.run(() => + useMeta(ref({ resourceType: 'catalog', id: 'abc' })), + )! + await flushPromises() + expect(getResourceOperation).toHaveBeenCalledWith({ resourceType: 'catalog', id: 'abc' }, 'put') + expect(canWrite.value).toBe(true) + expect(canEdit.value).toBe(true) + // canEdit is a lazy computed: isOperationOffered is only called once something reads it. + expect(isOperationOffered).toHaveBeenCalledWith('putCatalog') + }) + + it('cannot edit when it can write but the put operation is not advertised', async () => { + auth.isLoggedIn.value = true + vi.mocked(fetchMeta).mockResolvedValue(metaWith('Owner', ['W'])) + const { canWrite, canEdit } = scope.run(() => + useMeta(ref({ resourceType: 'catalog', id: 'abc' })), + )! + await flushPromises() + expect(canWrite.value).toBe(true) + expect(canEdit.value).toBe(false) + }) + + it('reacts to PUT availability changes without fetching metadata again', async () => { + auth.isLoggedIn.value = true + vi.mocked(fetchMeta).mockResolvedValue(metaWith('Owner', ['W'])) + const offeredOperations = ref([]) + vi.mocked(isOperationOffered).mockImplementation((operationId) => + offeredOperations.value.includes(operationId), + ) + const { canEdit } = scope.run(() => useMeta(ref({ resourceType: 'catalog', id: 'abc' })))! + await flushPromises() + expect(canEdit.value).toBe(false) + + offeredOperations.value = ['putCatalog'] + await nextTick() + expect(canEdit.value).toBe(true) + + offeredOperations.value = [] + await nextTick() + expect(canEdit.value).toBe(false) + expect(fetchMeta).toHaveBeenCalledTimes(1) + expect(getResourceOperation).toHaveBeenCalledTimes(1) + }) + + it.each([true, false])( + 'admin editing without membership follows PUT availability (%s)', + async (offered) => { + auth.isLoggedIn.value = true + auth.isAdmin.value = true + vi.mocked(fetchMeta).mockResolvedValue(metaWith(null)) + vi.mocked(isOperationOffered).mockReturnValue(offered) + const { canEdit } = scope.run(() => useMeta(ref(null)))! + await flushPromises() + expect(canEdit.value).toBe(offered) + }, + ) + + it('cannot edit with read-only membership even when PUT is advertised', async () => { + auth.isLoggedIn.value = true + vi.mocked(fetchMeta).mockResolvedValue(metaWith('Reader', ['R'])) + vi.mocked(isOperationOffered).mockReturnValue(true) + const { canEdit } = scope.run(() => useMeta(ref({ resourceType: 'catalog', id: 'abc' })))! + await flushPromises() + expect(canEdit.value).toBe(false) + }) + + it('keeps editing unavailable to an admin when metadata fails', async () => { + auth.isLoggedIn.value = true + auth.isAdmin.value = true + vi.mocked(fetchMeta).mockRejectedValue(new Error('HTTP 403')) + vi.mocked(isOperationOffered).mockReturnValue(true) + const { canWrite, canEdit, loading, error } = scope.run(() => useMeta(ref(null)))! + await flushPromises() + expect(canWrite.value).toBe(true) + expect(canEdit.value).toBe(false) + expect(loading.value).toBe(false) + expect(error.value).toBe('HTTP 403') + }) + + it('clears editing availability on logout', async () => { + auth.isLoggedIn.value = true + vi.mocked(fetchMeta).mockResolvedValue(metaWith('Owner', ['W'])) + vi.mocked(isOperationOffered).mockReturnValue(true) + const { canEdit } = scope.run(() => useMeta(ref(null)))! + await flushPromises() + expect(canEdit.value).toBe(true) + auth.isLoggedIn.value = false + await nextTick() + expect(canEdit.value).toBe(false) + }) + + it('clears editing during navigation and ignores stale writable metadata', async () => { + auth.isLoggedIn.value = true + const stale = Promise.withResolvers() + const current = Promise.withResolvers() + vi.mocked(fetchMeta) + .mockResolvedValueOnce(metaWith('Owner', ['W'])) + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(current.promise) + vi.mocked(isOperationOffered).mockReturnValue(true) + const resource = ref({ resourceType: 'catalog', id: 'first' }) + const { canEdit, loading } = scope.run(() => useMeta(resource))! + await flushPromises() + expect(canEdit.value).toBe(true) + + resource.value = { resourceType: 'catalog', id: 'second' } + await nextTick() + expect(loading.value).toBe(true) + expect(canEdit.value).toBe(false) + resource.value = { resourceType: 'catalog', id: 'third' } + await nextTick() + stale.resolve(metaWith('Owner', ['W'])) + await flushPromises() + expect(loading.value).toBe(true) + expect(canEdit.value).toBe(false) + current.resolve(metaWith('Reader', ['R'])) + await flushPromises() + expect(loading.value).toBe(false) + expect(canEdit.value).toBe(false) + }) + it('shows the membership but cannot write without W', async () => { auth.isLoggedIn.value = true vi.mocked(fetchMeta).mockResolvedValue(metaWith('Data Provider', ['C'])) diff --git a/tests/views/resourceEdit.test.ts b/tests/views/resourceEdit.test.ts new file mode 100644 index 0000000..5f93822 --- /dev/null +++ b/tests/views/resourceEdit.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSSRApp, h } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { RouterView } from 'vue-router' +import router from '../../src/router' + +// Exercise the production routes and RouterLinks in Node without browser history. +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createWebHistory: actual.createMemoryHistory } +}) +vi.mock('../../src/composables/useAuth', () => ({ + useAuth: () => ({ isLoggedIn: { value: true }, isAdmin: { value: false } }), +})) +vi.mock('../../src/composables/apiDocs', () => ({ + apiDocsReady: Promise.resolve(), + isOperationOffered: () => true, +})) +// Unrelated routes are not part of this render test. +vi.mock('../../src/views/LoginView.vue', () => ({ default: {} })) +vi.mock('../../src/views/SearchView.vue', () => ({ default: {} })) +vi.mock('../../src/views/UsersView.vue', () => ({ default: {} })) +vi.mock('../../src/views/UserFormView.vue', () => ({ default: {} })) +vi.mock('../../src/components/RdfGraph.vue', () => ({ default: {} })) + +const state = vi.hoisted(() => ({ canEdit: false })) +vi.mock('../../src/composables/useMeta', () => ({ + useMeta: () => ({ membershipName: null, canEdit: state.canEdit }), +})) +vi.mock('../../src/composables/useMembers', () => ({ + useMembers: () => ({ otherOwnerNames: [], loaded: true }), +})) +vi.mock('../../src/composables/useResourceView', async () => { + const { computed, ref } = await import('vue') + const { useRoute } = await import('vue-router') + return { + useResourceView: () => { + const route = useRoute() + return { + resource: computed(() => + route.params.resourceType + ? { resourceType: route.params.resourceType, id: route.params.id } + : null, + ), + resourceUri: ref('http://localhost' + route.path), + currentNodeUri: ref('http://localhost' + route.path), + loading: false, + error: null, + title: 'Test resource', + description: null, + rawTurtle: ref(null), + breadcrumbs: [], + metadataRows: [], + unknownMetadataRows: [], + childSections: [], + childSummaries: {}, + resourceLabel: () => 'Test resource', + } + }, + } +}) + +async function renderRoute() { + return renderToString(createSSRApp({ render: () => h(RouterView) }).use(router)) +} + +function linkHref(html: string, label: string): string { + const link = [...html.matchAll(/]*>[\s\S]*?<\/a>/g)].find( + ([anchor]) => anchor.replace(/<[^>]*>/g, '').trim() === label, + )?.[0] + expect(link, `Expected a rendered ${label} link`).toBeDefined() + const href = link?.match(/href="([^"]*)"/)?.[1] + expect(href).toBeDefined() + return href! +} + +beforeEach(() => { + state.canEdit = false +}) + +describe.each([ + { resourcePath: '/', editPath: '/edit', heading: 'Edit FAIR Data Point' }, + { resourcePath: '/catalog/abc', editPath: '/catalog/abc/edit', heading: 'Edit catalog' }, +])('resource editing from $resourcePath', ({ resourcePath, editPath, heading }) => { + it('hides the Edit link when editing is unavailable', async () => { + await router.push(resourcePath) + const html = await renderRoute() + expect(html).toContain('Test resource') + expect(html).not.toContain('resource-edit-link') + expect(html).not.toContain(`href="${editPath}"`) + }) + + it('links to the placeholder and returns to the resource through Cancel', async () => { + state.canEdit = true + await router.push(resourcePath) + const editHref = linkHref(await renderRoute(), 'Edit') + expect(editHref).toBe(editPath) + + await router.push(editHref) + const placeholder = await renderRoute() + expect(placeholder).toContain(`

${heading}

`) + expect(placeholder).toContain('Editing is not implemented yet.') + const cancelHref = linkHref(placeholder, 'Cancel') + expect(cancelHref).toBe(resourcePath) + + await router.push(cancelHref) + expect(router.currentRoute.value.path).toBe(resourcePath) + expect(await renderRoute()).toContain('Test resource') + }) +}) From e9478d5ebcfb0af235b5ad05936432f9d75ac4c4 Mon Sep 17 00:00:00 2001 From: Mihai Lefter Date: Thu, 17 Sep 2026 11:27:47 +0200 Subject: [PATCH 03/38] Cancel unread response bodies on failed requests --- src/composables/fetchUtils.ts | 9 +++++- tests/composables/fetchUtils.test.ts | 43 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/composables/fetchUtils.test.ts diff --git a/src/composables/fetchUtils.ts b/src/composables/fetchUtils.ts index 86ea982..d05cd17 100644 --- a/src/composables/fetchUtils.ts +++ b/src/composables/fetchUtils.ts @@ -17,7 +17,14 @@ export async function request( ): Promise { const { headers, ...rest } = init const response = await fetch(url, { ...rest, headers: authHeaders(headers) }) - if (!response.ok) throw new Error(`HTTP ${response.status}`) + if (!response.ok) { + try { + await response.body?.cancel() + } catch { + // Releasing the unread body can fail; preserve the original HTTP status. + } + throw new Error(`HTTP ${response.status}`) + } return response } diff --git a/tests/composables/fetchUtils.test.ts b/tests/composables/fetchUtils.test.ts new file mode 100644 index 0000000..d2b9064 --- /dev/null +++ b/tests/composables/fetchUtils.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { request } from '../../src/composables/fetchUtils' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +/** A response whose body reports whether it was cancelled. */ +function responseWithBody(status: number) { + const cancel = vi.fn() + const body = new ReadableStream({ cancel }) + return { response: new Response(body, { status }), cancel } +} + +describe('request', () => { + it('cancels the unread body of an error response before throwing', async () => { + const { response, cancel } = responseWithBody(405) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)) + + await expect(request('http://localhost/members')).rejects.toThrow('HTTP 405') + expect(cancel).toHaveBeenCalled() + }) + + it('preserves the HTTP status when the response body has already errored', async () => { + const body = new ReadableStream({ + start(controller) { + controller.error(new Error('Response body stream failed')) + }, + }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status: 403 }))) + + await expect(request('http://localhost/members')).rejects.toThrow('HTTP 403') + }) + + it('leaves the body of a successful response readable', async () => { + const { response, cancel } = responseWithBody(200) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)) + + const result = await request('http://localhost/meta') + expect(result.bodyUsed).toBe(false) + expect(cancel).not.toHaveBeenCalled() + }) +}) From 8a0dab80b3103f6abc53d132fd39b0f5044ceb80 Mon Sep 17 00:00:00 2001 From: Mihai Lefter Date: Thu, 17 Sep 2026 16:04:01 +0200 Subject: [PATCH 04/38] Show the shape declared editable fields and breadcrumbs on the edit page, and rework shape loading --- src/assets/main.css | 10 ++ src/composables/rdfUtils.ts | 126 ++++++++++++--- src/composables/shaclFallback.ts | 5 + src/composables/useRdfLoader.ts | 46 +++--- src/composables/useResourceView.ts | 71 +++++++-- src/composables/vocabularies.ts | 2 + src/views/ResourceEditView.vue | 81 ++++++++-- tests/composables/rdfUtils.test.ts | 185 ++++++++++++++++++++++ tests/composables/useResourceView.test.ts | 153 ++++++++++++++++++ tests/views/resourceEdit.test.ts | 110 ++++++++++++- 10 files changed, 712 insertions(+), 77 deletions(-) diff --git a/src/assets/main.css b/src/assets/main.css index cad2dc3..4aae505 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -279,6 +279,16 @@ a:hover { padding-left: 1.1rem; } +.editable-field__required { + color: var(--fdp-color-danger); +} + +.editable-field__meta { + margin-left: var(--fdp-space-3); + color: var(--fdp-color-muted); + font-weight: 400; +} + .text-link { border: none; background: none; diff --git a/src/composables/rdfUtils.ts b/src/composables/rdfUtils.ts index c38af5a..6470515 100644 --- a/src/composables/rdfUtils.ts +++ b/src/composables/rdfUtils.ts @@ -26,6 +26,7 @@ import { SIO_IS_RELATED_TO, DASH_URI_VIEWER, DASH_VIEWER, + DASH_EDITOR, SHACL_IRI, SHACL_NODE_SHAPE, SHACL_TARGET_CLASS, @@ -33,7 +34,12 @@ import { SHACL_NODE_KIND, SHACL_PATH, SHACL_NAME, + SHACL_DESCRIPTION, SHACL_ORDER, + SHACL_DATATYPE, + SHACL_NODE, + SHACL_MIN_COUNT, + SHACL_MAX_COUNT, prefixes, XSD_DATE, XSD_DATETIME, @@ -62,14 +68,27 @@ export type MetadataRow = { blankNodes?: BlankNodeProperty[][] } -type ShapeProperty = { +/** + * One SHACL property shape. `viewer` drives display, `editor` drives editing: a property is + * offered in a given UI only when the shape declares the matching DASH hint. + */ +export type ShapeProperty = { path: string label: string | null + description: string | null order: number viewer: string | null + editor: string | null nodeKind: string | null + datatype: string | null + /** IRI of the nested shape referenced by sh:node. */ + node: string | null + minCount: number | null + maxCount: number | null } +export type EditableField = ShapeProperty & { editor: string } + // --- Low-level helpers --- function formatDate(isoString: string): string { @@ -357,6 +376,14 @@ function getObjectNamedNode(store: Store, subject: Term, predicate: string): str ) } +/** Parses a SHACL cardinality literal; absent or unparseable means no constraint. */ +function readCount(shapeGraph: Store, propTerm: Term, predicate: string): number | null { + const raw = getObjectLiteral(shapeGraph, propTerm, predicate) + if (raw === null) return null + const count = parseInt(raw, 10) + return Number.isNaN(count) ? null : count +} + /** * Reads a SHACL property shape node into a ShapeProperty struct. * Returns null if the node has no shacl:path. @@ -366,31 +393,75 @@ function readShapeProperty(shapeGraph: Store, propTerm: Term): ShapeProperty | n const path = getObjectNamedNode(shapeGraph, propTerm, SHACL_PATH) if (!path) return null - const label = getObjectLiteral(shapeGraph, propTerm, SHACL_NAME) const orderStr = getObjectLiteral(shapeGraph, propTerm, SHACL_ORDER) - const order = orderStr ? parseInt(orderStr, 10) : Number.MAX_SAFE_INTEGER - const viewer = getObjectNamedNode(shapeGraph, propTerm, DASH_VIEWER) - const nodeKind = getObjectNamedNode(shapeGraph, propTerm, SHACL_NODE_KIND) - return { path, label, order, viewer, nodeKind } + return { + path, + label: getObjectLiteral(shapeGraph, propTerm, SHACL_NAME), + description: getObjectLiteral(shapeGraph, propTerm, SHACL_DESCRIPTION), + order: orderStr ? parseInt(orderStr, 10) : Number.MAX_SAFE_INTEGER, + viewer: getObjectNamedNode(shapeGraph, propTerm, DASH_VIEWER), + editor: getObjectNamedNode(shapeGraph, propTerm, DASH_EDITOR), + nodeKind: getObjectNamedNode(shapeGraph, propTerm, SHACL_NODE_KIND), + datatype: getObjectNamedNode(shapeGraph, propTerm, SHACL_DATATYPE), + node: getObjectNamedNode(shapeGraph, propTerm, SHACL_NODE), + minCount: readCount(shapeGraph, propTerm, SHACL_MIN_COUNT), + maxCount: readCount(shapeGraph, propTerm, SHACL_MAX_COUNT), + } +} + +/** Returns the stricter of two cardinality bounds; null means the shape set no bound. */ +function mergeCardinalityBound( + a: number | null, + b: number | null, + pick: (x: number, y: number) => number, +) { + if (a === null) return b + if (b === null) return a + return pick(a, b) +} + +/** + * Merges shapes for one path using the strictest cardinality bounds. + * Each hint comes from the lowest-order shape that declares it; + * ties use encounter order. + */ +function mergeShapeProperties(properties: ShapeProperty[]): ShapeProperty { + return [...properties] + .sort((a, b) => a.order - b.order) + .reduce((merged, prop) => ({ + path: merged.path, + label: merged.label ?? prop.label, + description: merged.description ?? prop.description, + order: merged.order, + viewer: merged.viewer ?? prop.viewer, + editor: merged.editor ?? prop.editor, + nodeKind: merged.nodeKind ?? prop.nodeKind, + datatype: merged.datatype ?? prop.datatype, + node: merged.node ?? prop.node, + minCount: mergeCardinalityBound(merged.minCount, prop.minCount, Math.max), + maxCount: mergeCardinalityBound(merged.maxCount, prop.maxCount, Math.min), + })) } /** * Builds a map of shacl:path -> ShapeProperty for the current resource's RDF types. * Searches all provided shape graphs for NodeShapes whose shacl:targetClass matches one of - * the resource's types. When multiple shapes define the same path, the lower shacl:order wins; - * missing fields are filled from the superseded entry. + * the resource's types. Shapes that constrain the same path are merged, see + * mergeShapeProperties. */ -function getShapePropertyMap( +export function getShapePropertyMap( resourceStore: Store, subjectUri: string | null, shapeGraphs: Store[], ): Map { - const map = new Map() - if (!subjectUri) return map + if (!subjectUri) return new Map() const currentTypes = new Set(getNodeRefs(resourceStore, subjectUri, RDF_TYPE)) - if (currentTypes.size === 0) return map + if (currentTypes.size === 0) return new Map() + + // Collect per path first so hints can be merged in shacl:order. + const byPath = new Map() for (const shapeGraph of shapeGraphs) { for (const subject of shapeGraph.getSubjects( @@ -410,21 +481,28 @@ function getShapePropertyMap( const prop = readShapeProperty(shapeGraph, propTerm) if (!prop) continue - // Lower order wins; if the new entry lacks a field, preserve it from the superseded one. - const existing = map.get(prop.path) - if (!existing || prop.order < existing.order) { - map.set(prop.path, { - path: prop.path, - label: prop.label ?? existing?.label ?? null, - order: prop.order, - viewer: prop.viewer ?? existing?.viewer ?? null, - nodeKind: prop.nodeKind ?? existing?.nodeKind ?? null, - }) - } + const collected = byPath.get(prop.path) + if (collected) collected.push(prop) + else byPath.set(prop.path, [prop]) } } } - return map + + return new Map([...byPath].map(([path, properties]) => [path, mergeShapeProperties(properties)])) +} + +/** + * Shape properties an edit form offers, in shacl:order. A property is editable only when the + * shape declares a dash:editor, as dash:viewer decides what the metadata table shows. + */ +export function getEditableFields( + resourceStore: Store, + subjectUri: string | null, + shapeGraphs: Store[], +): EditableField[] { + return [...getShapePropertyMap(resourceStore, subjectUri, shapeGraphs).values()] + .filter((property): property is EditableField => property.editor !== null) + .sort((a, b) => a.order - b.order) } // --- Metadata rows --- diff --git a/src/composables/shaclFallback.ts b/src/composables/shaclFallback.ts index 8c23279..902fe91 100644 --- a/src/composables/shaclFallback.ts +++ b/src/composables/shaclFallback.ts @@ -1,4 +1,6 @@ import { + DCT_TITLE, + DCT_DESCRIPTION, DCT_CONFORMS_TO, DCT_LANGUAGE, DCT_LICENSE, @@ -59,6 +61,9 @@ import { compactUri } from './rdfUtils' // Human-readable labels for known predicates. // Unknown predicates fall back to their compacted URI. const labelMap: Record = { + // The metadata table renders title and description outside the rows, but a form labels them. + [DCT_TITLE]: 'Title', + [DCT_DESCRIPTION]: 'Description', [DCT_CONFORMS_TO]: 'Conforms to', [DCT_LANGUAGE]: 'Language', [DCT_LICENSE]: 'License', diff --git a/src/composables/useRdfLoader.ts b/src/composables/useRdfLoader.ts index 84249d0..972f1b2 100644 --- a/src/composables/useRdfLoader.ts +++ b/src/composables/useRdfLoader.ts @@ -35,7 +35,6 @@ export function useRdfLoader() { const rawTurtle = ref(null) const childSummaries = ref>({}) const parentSummaries = ref>({}) - const shapeGraphs = ref>({}) /** Fetches and parses the primary resource, populating quads and rawTurtle. */ async function loadResource(uri: string) { @@ -82,30 +81,30 @@ export function useRdfLoader() { } } - /** Fetches a profile and calls loadShapeDocument for each of its SHACL artifacts. */ - async function loadProfile(uri: string): Promise { - if (shapeGraphs.value[uri]) return - - try { - const store = parseTurtle(await fetchRdfTurtle(uri)) - for (const artifactUri of getArtifactUris(store)) { - void loadShapeDocument(artifactUri) + /** Fetches a profile and its SHACL artifacts, retaining successful results and reporting failures. */ + async function loadProfile(uri: string): Promise<{ + graphs: Record + error: string | null + }> { + const store = parseTurtle(await fetchRdfTurtle(uri)) + const results = await Promise.allSettled( + getArtifactUris(store).map(async (artifactUri) => { + const graph = parseTurtle(await fetchRdfTurtle(artifactUri)) + return [artifactUri, graph] as const + }), + ) + const graphs: Record = {} + let error: string | null = null + for (const result of results) { + if (result.status === 'fulfilled') { + const [artifactUri, graph] = result.value + graphs[artifactUri] = graph + } else { + error ??= + result.reason instanceof Error ? result.reason.message : 'Unable to load resource shapes.' } - } catch (err) { - console.warn(`Failed to load profile ${uri}`, err) - } - } - - /** Fetches and parses a SHACL shape document into shapeGraphs, used for property ordering and rendering hints. */ - async function loadShapeDocument(uri: string): Promise { - if (shapeGraphs.value[uri]) return - - try { - const store = parseTurtle(await fetchRdfTurtle(uri)) - shapeGraphs.value[uri] = store - } catch (err) { - console.warn(`Failed to load shape document ${uri}`, err) } + return { graphs, error } } /** Fetches a child resource and stores a display summary (title, description, dates, theme) in childSummaries. */ @@ -136,7 +135,6 @@ export function useRdfLoader() { rawTurtle, childSummaries, parentSummaries, - shapeGraphs, loadResource, loadChildSummary, loadParentChain, diff --git a/src/composables/useResourceView.ts b/src/composables/useResourceView.ts index 4568e22..25de2da 100644 --- a/src/composables/useResourceView.ts +++ b/src/composables/useResourceView.ts @@ -1,5 +1,6 @@ -import { computed, watch } from 'vue' +import { computed, ref, watch } from 'vue' import { useRoute } from 'vue-router' +import type { Store } from 'n3' import { getTitle, getBreadcrumbs, @@ -11,6 +12,7 @@ import { getParentUri, getChildSections, getMetadataRows, + getEditableFields, uriLabel, } from './rdfUtils' import { useRdfLoader, type ChildSummary } from './useRdfLoader' @@ -23,7 +25,9 @@ export type { ChildSummary } * Derives all display data for ResourceView from the current route: resolves the resource URI, * delegates fetching to useRdfLoader, and exposes computed title, breadcrumbs, metadata rows, and child sections. */ -export function useResourceView() { +export function useResourceView({ + loadChildSummaries = true, +}: { loadChildSummaries?: boolean } = {}) { const route = useRoute() const fdpBaseUri = getBaseUrl() @@ -53,13 +57,16 @@ export function useResourceView() { rawTurtle, childSummaries, parentSummaries, - shapeGraphs, loadResource, loadChildSummary, loadParentChain, loadProfile, } = useRdfLoader() + const shapeGraphs = ref>({}) + const shapesLoading = ref(false) + const shapesError = ref(null) + function resourceLabel(uri: string): string { return uriLabel(quads.value, uri) } @@ -93,6 +100,11 @@ export function useResourceView() { const metadataRows = computed(() => allMetadataRows.value.rows) const unknownMetadataRows = computed(() => allMetadataRows.value.unknownRows) + // Same shapes as the metadata table, filtered by dash:editor instead of dash:viewer. + const editableFields = computed(() => + getEditableFields(quads.value, currentNodeUri.value, Object.values(shapeGraphs.value)), + ) + watch( resourceUri, async (uri) => { @@ -101,17 +113,20 @@ export function useResourceView() { { immediate: true }, ) - watch( - childSections, - (sections) => { - sections - .flatMap((section) => section.items) - .forEach((uri) => { - void loadChildSummary(uri) - }) - }, - { immediate: true }, - ) + // The edit page needs breadcrumbs, but not child summaries. + if (loadChildSummaries) { + watch( + childSections, + (sections) => { + sections + .flatMap((section) => section.items) + .forEach((uri) => { + void loadChildSummary(uri) + }) + }, + { immediate: true }, + ) + } watch( currentNodeUri, @@ -128,15 +143,38 @@ export function useResourceView() { watch( currentNodeUri, - (uri) => { + async (uri, _previous, onCleanup) => { + let cancelled = false + onCleanup(() => { + cancelled = true + }) + shapeGraphs.value = {} + shapesError.value = null + shapesLoading.value = false if (!uri) return const profileUri = getConformsTo(quads.value, uri) - if (profileUri) void loadProfile(profileUri) + if (!profileUri) return + shapesLoading.value = true + try { + const result = await loadProfile(profileUri) + if (!cancelled) { + shapeGraphs.value = result.graphs + shapesError.value = result.error + } + } catch (err) { + if (!cancelled) { + shapesError.value = err instanceof Error ? err.message : 'Unable to load resource shapes.' + } + } finally { + if (!cancelled) shapesLoading.value = false + } }, { immediate: true }, ) return { + shapesLoading, + shapesError, loading, error, rawTurtle, @@ -151,6 +189,7 @@ export function useResourceView() { quads, metadataRows, unknownMetadataRows, + editableFields, childSections, childSummaries, resourceLabel, diff --git a/src/composables/vocabularies.ts b/src/composables/vocabularies.ts index d576dbe..aed26d1 100644 --- a/src/composables/vocabularies.ts +++ b/src/composables/vocabularies.ts @@ -109,6 +109,7 @@ export const SHACL_NODE_KIND = `${SH}nodeKind` export const SHACL_MIN_COUNT = `${SH}minCount` export const SHACL_MAX_COUNT = `${SH}maxCount` export const SHACL_NAME = `${SH}name` +export const SHACL_DESCRIPTION = `${SH}description` export const SHACL_ORDER = `${SH}order` export const SHACL_NODE = `${SH}node` @@ -131,6 +132,7 @@ export const SIO_IS_RELATED_TO = `${SIO}SIO_000332` export const DASH_VIEWER = `${DASH}viewer` export const DASH_LABEL_VIEWER = `${DASH}LabelViewer` export const DASH_URI_VIEWER = `${DASH}URIViewer` +export const DASH_EDITOR = `${DASH}editor` export const prefixes: Record = { [DCT]: 'dct', diff --git a/src/views/ResourceEditView.vue b/src/views/ResourceEditView.vue index 20f68de..c64d946 100644 --- a/src/views/ResourceEditView.vue +++ b/src/views/ResourceEditView.vue @@ -1,20 +1,79 @@ diff --git a/tests/composables/rdfUtils.test.ts b/tests/composables/rdfUtils.test.ts index 45f5259..2df9ee2 100644 --- a/tests/composables/rdfUtils.test.ts +++ b/tests/composables/rdfUtils.test.ts @@ -13,12 +13,15 @@ import { getNodeRefs, uriLabel, getMetadataRows, + getShapePropertyMap, + getEditableFields, } from '../../src/composables/rdfUtils' import { DCT_TITLE, DCT_DESCRIPTION, DCT_PUBLISHER, DCT_LICENSE, + DCT_ISSUED, DCT_CONFORMS_TO, DCAT_ENDPOINT_URL, DCAT_ENDPOINT_DESCRIPTION, @@ -34,6 +37,20 @@ import { LDP_MEMBERSHIP_RESOURCE, XSD_DATE, XSD_DATETIME, + SHACL_NODE_SHAPE, + SHACL_TARGET_CLASS, + SHACL_PROPERTY, + SHACL_PATH, + SHACL_NAME, + SHACL_DESCRIPTION, + SHACL_ORDER, + SHACL_DATATYPE, + SHACL_NODE, + SHACL_MIN_COUNT, + SHACL_MAX_COUNT, + DASH_EDITOR, + DASH_VIEWER, + DASH_LABEL_VIEWER, } from '../../src/composables/vocabularies' const XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string' @@ -369,3 +386,171 @@ describe('getMetadataRows', () => { }) }) }) + +describe('getShapePropertyMap', () => { + const RESOURCE = 'http://localhost/catalog/c1' + const CATALOG = 'http://www.w3.org/ns/dcat#Catalog' + const TEXT_FIELD_EDITOR = 'http://datashapes.org/dash#TextFieldEditor' + const URI_EDITOR = 'http://datashapes.org/dash#URIEditor' + const AGENT_SHAPE = 'http://fairdatapoint.org/AgentShape' + + const resourceStore = () => parseTurtle(`<${RESOURCE}> a <${CATALOG}> .`) + + /** A NodeShape targeting dcat:Catalog, with the given property-shape bodies. */ + const shapeGraph = (...properties: string[]) => + parseTurtle(` + a <${SHACL_NODE_SHAPE}> ; + <${SHACL_TARGET_CLASS}> <${CATALOG}> ; + ${properties.map((body) => `<${SHACL_PROPERTY}> [ ${body} ]`).join(' ;\n')} . + `) + + it('reads the SHACL and DASH attributes an edit form needs', () => { + const shape = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_NAME}> "Title" ; + <${SHACL_DESCRIPTION}> "The resource title" ; + <${SHACL_ORDER}> 1 ; + <${SHACL_DATATYPE}> <${XSD_STRING}> ; + <${SHACL_MIN_COUNT}> 1 ; + <${SHACL_MAX_COUNT}> 1 ; + <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> ; + <${DASH_VIEWER}> <${DASH_LABEL_VIEWER}> + `) + expect(getShapePropertyMap(resourceStore(), RESOURCE, [shape]).get(DCT_TITLE)).toEqual({ + path: DCT_TITLE, + label: 'Title', + description: 'The resource title', + order: 1, + viewer: DASH_LABEL_VIEWER, + editor: TEXT_FIELD_EDITOR, + nodeKind: null, + datatype: XSD_STRING, + node: null, + minCount: 1, + maxCount: 1, + }) + }) + + it('reads sh:node for a property whose value is its own record', () => { + const shape = shapeGraph(` + <${SHACL_PATH}> <${DCT_PUBLISHER}> ; + <${SHACL_NODE}> <${AGENT_SHAPE}> + `) + expect( + getShapePropertyMap(resourceStore(), RESOURCE, [shape]).get(DCT_PUBLISHER), + ).toMatchObject({ + node: AGENT_SHAPE, + order: Number.MAX_SAFE_INTEGER, + minCount: null, + maxCount: null, + }) + }) + + it('merges shapes constraining the same path, keeping the strictest cardinality', () => { + const loose = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 1 ; + <${SHACL_MIN_COUNT}> 0 ; + <${SHACL_MAX_COUNT}> 5 ; + <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> + `) + const strict = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 9 ; + <${SHACL_DESCRIPTION}> "Only the stricter shape describes it" ; + <${SHACL_MIN_COUNT}> 2 ; + <${SHACL_MAX_COUNT}> 3 + `) + expect(getShapePropertyMap(resourceStore(), RESOURCE, [loose, strict]).get(DCT_TITLE)).toEqual({ + path: DCT_TITLE, + label: null, + description: 'Only the stricter shape describes it', + order: 1, + viewer: null, + editor: TEXT_FIELD_EDITOR, + nodeKind: null, + datatype: null, + node: null, + minCount: 2, + maxCount: 3, + }) + }) + + it('picks hints by shacl:order, whichever order the shapes are read in', () => { + const unhinted = shapeGraph(`<${SHACL_PATH}> <${DCT_TITLE}> ; <${SHACL_ORDER}> 1`) + const later = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 9 ; + <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> + `) + const earlier = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 5 ; + <${DASH_EDITOR}> <${URI_EDITOR}> + `) + + for (const shapes of [ + [unhinted, later, earlier], + [unhinted, earlier, later], + ]) { + expect(getShapePropertyMap(resourceStore(), RESOURCE, shapes).get(DCT_TITLE)).toMatchObject({ + order: 1, + editor: URI_EDITOR, + }) + } + }) + + it('treats an absent bound as no constraint when merging', () => { + const unbounded = shapeGraph(`<${SHACL_PATH}> <${DCT_TITLE}> ; <${SHACL_ORDER}> 1`) + const bounded = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 2 ; + <${SHACL_MAX_COUNT}> 1 + `) + expect( + getShapePropertyMap(resourceStore(), RESOURCE, [unbounded, bounded]).get(DCT_TITLE), + ).toMatchObject({ minCount: null, maxCount: 1 }) + }) + + it('ignores shapes that target a class the resource does not have', () => { + const shape = parseTurtle(` + a <${SHACL_NODE_SHAPE}> ; + <${SHACL_TARGET_CLASS}> ; + <${SHACL_PROPERTY}> [ <${SHACL_PATH}> <${DCT_TITLE}> ; <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> ] . + `) + expect(getShapePropertyMap(resourceStore(), RESOURCE, [shape]).size).toBe(0) + }) + + describe('getEditableFields', () => { + it('offers only properties with a dash:editor, in shacl:order', () => { + // Mirrors the live catalog shapes: dct:issued is displayed but the backend owns it. + const shape = shapeGraph( + `<${SHACL_PATH}> <${DCT_ISSUED}> ; + <${SHACL_ORDER}> 20 ; + <${DASH_VIEWER}> <${DASH_LABEL_VIEWER}>`, + `<${SHACL_PATH}> <${DCT_LICENSE}> ; + <${SHACL_ORDER}> 6 ; + <${DASH_EDITOR}> <${URI_EDITOR}> ; + <${DASH_VIEWER}> <${DASH_LABEL_VIEWER}>`, + `<${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 1 ; + <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}>`, + ) + expect(getEditableFields(resourceStore(), RESOURCE, [shape]).map((f) => f.path)).toEqual([ + DCT_TITLE, + DCT_LICENSE, + ]) + }) + + it('sorts properties without shacl:order last', () => { + const shape = shapeGraph( + `<${SHACL_PATH}> <${DCT_PUBLISHER}> ; <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}>`, + `<${SHACL_PATH}> <${DCT_TITLE}> ; <${SHACL_ORDER}> 1 ; <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}>`, + ) + expect(getEditableFields(resourceStore(), RESOURCE, [shape]).map((f) => f.path)).toEqual([ + DCT_TITLE, + DCT_PUBLISHER, + ]) + }) + }) +}) diff --git a/tests/composables/useResourceView.test.ts b/tests/composables/useResourceView.test.ts index 30fd4c3..b010834 100644 --- a/tests/composables/useResourceView.test.ts +++ b/tests/composables/useResourceView.test.ts @@ -811,3 +811,156 @@ describe('useResourceView', () => { }) }) }) + +describe('editable shape loading', () => { + let scope: EffectScope + const profileUri = 'http://localhost/profile/edit' + const shapeUri = 'http://localhost/shapes/edit' + const resource = ` a ; + <${profileUri}> .` + const profile = `<${profileUri}> <${shapeUri}> .` + const shape = `@prefix sh: . + a sh:NodeShape; sh:targetClass ; + sh:property [ sh:path ; + ] .` + + beforeEach(() => { + scope = effectScope() + mockRoute({}) + }) + afterEach(() => { + scope.stop() + vi.clearAllMocks() + }) + + it('waits for both the profile and its shapes before exposing the fields', async () => { + const pendingProfile = Promise.withResolvers() + const pendingShape = Promise.withResolvers() + vi.mocked(fetchRdfTurtle).mockImplementation(async (uri) => { + if (uri === ROOT_URI) return resource + if (uri === profileUri) return pendingProfile.promise + if (uri === shapeUri) return pendingShape.promise + throw new Error(`Unexpected URI: ${uri}`) + }) + const view = scope.run(() => useResourceView())! + await flushPromises() + expect(view.loading.value).toBe(false) + expect(view.shapesLoading.value).toBe(true) + pendingProfile.resolve(profile) + await flushPromises() + expect(view.shapesLoading.value).toBe(true) + expect(view.editableFields.value).toEqual([]) + pendingShape.resolve(shape) + await flushPromises() + expect(view.shapesLoading.value).toBe(false) + expect(view.shapesError.value).toBeNull() + expect(view.editableFields.value.map((field) => field.path)).toEqual(['http://ex/title']) + }) + + it.each([profileUri, shapeUri])('reports a failed request for %s', async (failedUri) => { + vi.mocked(fetchRdfTurtle).mockImplementation(async (uri) => { + if (uri === failedUri) throw new Error('HTTP 503') + if (uri === ROOT_URI) return resource + return profile + }) + const view = scope.run(() => useResourceView())! + await flushPromises() + expect(view.shapesLoading.value).toBe(false) + expect(view.shapesError.value).toBe('HTTP 503') + expect(view.editableFields.value).toEqual([]) + }) + + it('preserves successful shapes for metadata display when another artifact fails', async () => { + const brokenUri = 'http://localhost/shapes/broken' + const pending = Promise.withResolvers() + vi.mocked(fetchRdfTurtle).mockImplementation(async (uri) => { + if (uri === ROOT_URI) + return ( + resource + + ` + "Title"; "Other" .` + ) + if (uri === profileUri) + return ( + profile + + ` + <${profileUri}> <${brokenUri}> .` + ) + if (uri === brokenUri) throw new Error('HTTP 503') + if (uri === shapeUri) return pending.promise + throw new Error(`Unexpected URI: ${uri}`) + }) + const view = scope.run(() => useResourceView())! + await flushPromises() + expect(view.shapesLoading.value).toBe(true) + pending.resolve( + shape.replace( + 'sh:path ;', + 'sh:path ; ;', + ), + ) + await flushPromises() + expect(view.shapesLoading.value).toBe(false) + expect(view.shapesError.value).toBe('HTTP 503') + expect(view.metadataRows.value.map((row) => row.predicate)).toEqual(['http://ex/title']) + expect(view.unknownMetadataRows.value.map((row) => row.predicate)).toContain('http://ex/other') + }) + + it.each([true, false])( + 'always loads parents and optionally loads child summaries (%s)', + async (loadChildSummaries) => { + mockRoute({ resourceType: 'catalog', id: CATALOG_URI.split('/').pop()! }) + vi.mocked(fetchRdfTurtle).mockImplementation(async (uri) => + uri === CATALOG_URI ? readFixture('catalog.ttl') : EMPTY_TTL, + ) + scope.run(() => useResourceView({ loadChildSummaries })) + await flushPromises() + const requestedUris = vi.mocked(fetchRdfTurtle).mock.calls.map(([uri]) => uri) + expect(requestedUris).toContain(CATALOG_URI) + expect(requestedUris).toContain(CATALOG_PROFILE_URI) + expect(requestedUris).toContain(ROOT_URI_NO_TRAILING_SLASH) + expect(requestedUris.includes(DATASET_URI)).toBe(loadChildSummaries) + if (!loadChildSummaries) expect(requestedUris).toHaveLength(3) + }, + ) + + it('builds ancestor breadcrumbs when child summaries are disabled', async () => { + mockRoute({ resourceType: 'dataset', id: DATASET_URI.split('/').pop()! }) + setupFetchFixtures({ + [DATASET_URI]: readFixture('dataset.ttl'), + [CATALOG_URI]: readFixture('catalog.ttl'), + [ROOT_URI_NO_TRAILING_SLASH]: EMPTY_TTL, + [DATASET_PROFILE_URI]: EMPTY_TTL, + }) + const view = scope.run(() => useResourceView({ loadChildSummaries: false }))! + await flushPromises() + expect(view.breadcrumbs.value).toEqual([ + { text: 'FAIR Data Point', uri: ROOT_URI }, + { text: 'A catalog', uri: CATALOG_URI }, + { text: 'A dataset', uri: DATASET_URI }, + ]) + expect(vi.mocked(fetchRdfTurtle).mock.calls.map(([uri]) => uri)).not.toContain(DISTRIBUTION_URI) + }) + + it('ignores shapes that finish after navigation to a resource without a profile', async () => { + const pending = Promise.withResolvers() + const route = reactive({ params: {} as Record }) + vi.mocked(useRoute).mockReturnValue(route as unknown as ReturnType) + vi.mocked(fetchRdfTurtle).mockImplementation(async (uri) => { + if (uri === ROOT_URI) return resource + if (uri === profileUri) return profile + if (uri === shapeUri) return pending.promise + return ' a .' + }) + const view = scope.run(() => useResourceView())! + await flushPromises() + expect(view.shapesLoading.value).toBe(true) + route.params = { resourceType: 'catalog', id: 'next' } + await flushPromises() + pending.resolve(shape) + await flushPromises() + expect(view.shapesLoading.value).toBe(false) + expect(view.shapesError.value).toBeNull() + expect(view.editableFields.value).toEqual([]) + }) +}) diff --git a/tests/views/resourceEdit.test.ts b/tests/views/resourceEdit.test.ts index 5f93822..b2ca163 100644 --- a/tests/views/resourceEdit.test.ts +++ b/tests/views/resourceEdit.test.ts @@ -23,7 +23,19 @@ vi.mock('../../src/views/UsersView.vue', () => ({ default: {} })) vi.mock('../../src/views/UserFormView.vue', () => ({ default: {} })) vi.mock('../../src/components/RdfGraph.vue', () => ({ default: {} })) -const state = vi.hoisted(() => ({ canEdit: false })) +const state = vi.hoisted(() => ({ + canEdit: false, + breadcrumbs: [] as { text: string; uri: string }[], + shapesLoading: false, + shapesError: null as string | null, + editableFields: [] as { + path: string + label: string | null + editor: string | null + minCount: number | null + maxCount: number | null + }[], +})) vi.mock('../../src/composables/useMeta', () => ({ useMeta: () => ({ membershipName: null, canEdit: state.canEdit }), })) @@ -44,16 +56,19 @@ vi.mock('../../src/composables/useResourceView', async () => { ), resourceUri: ref('http://localhost' + route.path), currentNodeUri: ref('http://localhost' + route.path), + shapesLoading: state.shapesLoading, + shapesError: state.shapesError, loading: false, error: null, title: 'Test resource', description: null, rawTurtle: ref(null), - breadcrumbs: [], + breadcrumbs: computed(() => state.breadcrumbs), metadataRows: [], unknownMetadataRows: [], childSections: [], childSummaries: {}, + editableFields: computed(() => state.editableFields), resourceLabel: () => 'Test resource', } }, @@ -76,6 +91,10 @@ function linkHref(html: string, label: string): string { beforeEach(() => { state.canEdit = false + state.breadcrumbs = [] + state.shapesLoading = false + state.shapesError = null + state.editableFields = [] }) describe.each([ @@ -107,4 +126,91 @@ describe.each([ expect(router.currentRoute.value.path).toBe(resourcePath) expect(await renderRoute()).toContain('Test resource') }) + + it('links the breadcrumb trail back to the resource and ends with Edit', async () => { + state.breadcrumbs = [{ text: 'My FAIR Data Point', uri: 'http://localhost/' }] + if (resourcePath !== '/') { + state.breadcrumbs.push({ text: 'My catalog', uri: 'http://localhost' + resourcePath }) + } + await router.push(editPath) + const html = await renderRoute() + const nav = html.match(/]*>[\s\S]*?<\/nav>/)?.[0] + expect(nav).toBeDefined() + expect(linkHref(nav!, 'My FAIR Data Point')).toBe('/') + if (resourcePath !== '/') expect(linkHref(nav!, 'My catalog')).toBe(resourcePath) + expect(nav).toContain('Edit') + expect(nav!.indexOf('breadcrumb-current')).toBeGreaterThan(nav!.lastIndexOf('')) + + const currentResourceLabel = resourcePath === '/' ? 'My FAIR Data Point' : 'My catalog' + await router.push(linkHref(nav!, currentResourceLabel)) + expect(router.currentRoute.value.path).toBe(resourcePath) + }) + + it('lists the shape-declared editable fields, falling back to a known predicate label', async () => { + state.canEdit = true + state.editableFields = [ + { + path: 'http://purl.org/dc/terms/title', + label: 'Title', + editor: 'http://datashapes.org/dash#TextFieldEditor', + minCount: 1, + maxCount: 1, + }, + { + // No sh:name, so the label comes from shaclFallback's curated map. + path: 'http://www.w3.org/ns/dcat#keyword', + label: null, + editor: 'http://datashapes.org/dash#TextFieldEditor', + minCount: null, + maxCount: null, + }, + ] + await router.push(editPath) + const html = await renderRoute() + + expect(html).toContain('Title') + expect(html).toContain('Keyword') + expect(html).toContain('dash:TextFieldEditor') + expect(html).toContain('1..1') + expect(html).toContain('0..*') + }) + + it('shows loading instead of an empty field list while shapes are pending', async () => { + state.shapesLoading = true + await router.push(editPath) + const html = await renderRoute() + expect(html).toContain('Loading…') + expect(html).not.toContain('No editable fields') + }) + + it('shows shape failures instead of an empty field list', async () => { + state.shapesError = 'HTTP 503' + await router.push(editPath) + const html = await renderRoute() + expect(html).toContain('Error: HTTP 503') + expect(html).not.toContain('No editable fields') + }) + + it('shows the error instead of fields from an incomplete set of shapes', async () => { + state.shapesError = 'HTTP 503' + state.editableFields = [ + { + path: 'http://purl.org/dc/terms/title', + label: 'Partial field', + editor: 'http://datashapes.org/dash#TextFieldEditor', + minCount: null, + maxCount: null, + }, + ] + await router.push(editPath) + const html = await renderRoute() + expect(html).toContain('Error: HTTP 503') + expect(html).not.toContain('Partial field') + }) + + it('says so when the shape declares no editable fields', async () => { + state.canEdit = true + await router.push(editPath) + expect(await renderRoute()).toContain('No editable fields are declared') + }) }) From 22e21032105ee40b7cecbf0403fd5b96c9375431 Mon Sep 17 00:00:00 2001 From: Mihai Lefter Date: Thu, 17 Sep 2026 16:19:32 +0200 Subject: [PATCH 05/38] Extract SHACL utilities and share the computed shape map --- src/composables/rdfUtils.ts | 192 +------------------------- src/composables/shaclUtils.ts | 185 +++++++++++++++++++++++++ src/composables/useResourceView.ts | 15 ++- tests/composables/rdfUtils.test.ts | 191 +------------------------- tests/composables/shaclUtils.test.ts | 195 +++++++++++++++++++++++++++ 5 files changed, 397 insertions(+), 381 deletions(-) create mode 100644 src/composables/shaclUtils.ts create mode 100644 tests/composables/shaclUtils.test.ts diff --git a/src/composables/rdfUtils.ts b/src/composables/rdfUtils.ts index 6470515..35ce65a 100644 --- a/src/composables/rdfUtils.ts +++ b/src/composables/rdfUtils.ts @@ -1,5 +1,5 @@ import { Store, Parser, DataFactory } from 'n3' -import type { Literal, Term } from 'n3' +import type { Literal } from 'n3' import { DCT_TITLE, DCT_DESCRIPTION, @@ -25,26 +25,14 @@ import { SIO_IS_ABOUT, SIO_IS_RELATED_TO, DASH_URI_VIEWER, - DASH_VIEWER, - DASH_EDITOR, SHACL_IRI, - SHACL_NODE_SHAPE, - SHACL_TARGET_CLASS, - SHACL_PROPERTY, - SHACL_NODE_KIND, - SHACL_PATH, - SHACL_NAME, - SHACL_DESCRIPTION, - SHACL_ORDER, - SHACL_DATATYPE, - SHACL_NODE, - SHACL_MIN_COUNT, - SHACL_MAX_COUNT, prefixes, XSD_DATE, XSD_DATETIME, } from './vocabularies' import { predicateLabel, metadataPredicatePriority } from './shaclFallback' +// Keep this type-only: shaclUtils imports runtime helpers from this module. +import type { ShapeProperty } from './shaclUtils' import { isInternalUri } from './urlUtils' // --- Types --- @@ -68,27 +56,6 @@ export type MetadataRow = { blankNodes?: BlankNodeProperty[][] } -/** - * One SHACL property shape. `viewer` drives display, `editor` drives editing: a property is - * offered in a given UI only when the shape declares the matching DASH hint. - */ -export type ShapeProperty = { - path: string - label: string | null - description: string | null - order: number - viewer: string | null - editor: string | null - nodeKind: string | null - datatype: string | null - /** IRI of the nested shape referenced by sh:node. */ - node: string | null - minCount: number | null - maxCount: number | null -} - -export type EditableField = ShapeProperty & { editor: string } - // --- Low-level helpers --- function formatDate(isoString: string): string { @@ -357,154 +324,6 @@ export function getBreadcrumbs( return items } -// --- SHACL/DASH shape properties --- - -/** Like getFirstLiteral but takes an N3 Term as subject; used when iterating SHACL shape nodes. */ -function getObjectLiteral(store: Store, subject: Term, predicate: string): string | null { - const obj = store - .getObjects(subject, DataFactory.namedNode(predicate), null) - .find((o) => o.termType === 'Literal') - return obj ? formatLiteralValue(obj as Literal) : null -} - -/** Like getNodeRefs but takes an N3 Term as subject and returns only the first named node value. */ -function getObjectNamedNode(store: Store, subject: Term, predicate: string): string | null { - return ( - store - .getObjects(subject, DataFactory.namedNode(predicate), null) - .find((o) => o.termType === 'NamedNode')?.value ?? null - ) -} - -/** Parses a SHACL cardinality literal; absent or unparseable means no constraint. */ -function readCount(shapeGraph: Store, propTerm: Term, predicate: string): number | null { - const raw = getObjectLiteral(shapeGraph, propTerm, predicate) - if (raw === null) return null - const count = parseInt(raw, 10) - return Number.isNaN(count) ? null : count -} - -/** - * Reads a SHACL property shape node into a ShapeProperty struct. - * Returns null if the node has no shacl:path. - * shacl:order defaults to MAX_SAFE_INTEGER when absent, so unordered properties sort last. - */ -function readShapeProperty(shapeGraph: Store, propTerm: Term): ShapeProperty | null { - const path = getObjectNamedNode(shapeGraph, propTerm, SHACL_PATH) - if (!path) return null - - const orderStr = getObjectLiteral(shapeGraph, propTerm, SHACL_ORDER) - - return { - path, - label: getObjectLiteral(shapeGraph, propTerm, SHACL_NAME), - description: getObjectLiteral(shapeGraph, propTerm, SHACL_DESCRIPTION), - order: orderStr ? parseInt(orderStr, 10) : Number.MAX_SAFE_INTEGER, - viewer: getObjectNamedNode(shapeGraph, propTerm, DASH_VIEWER), - editor: getObjectNamedNode(shapeGraph, propTerm, DASH_EDITOR), - nodeKind: getObjectNamedNode(shapeGraph, propTerm, SHACL_NODE_KIND), - datatype: getObjectNamedNode(shapeGraph, propTerm, SHACL_DATATYPE), - node: getObjectNamedNode(shapeGraph, propTerm, SHACL_NODE), - minCount: readCount(shapeGraph, propTerm, SHACL_MIN_COUNT), - maxCount: readCount(shapeGraph, propTerm, SHACL_MAX_COUNT), - } -} - -/** Returns the stricter of two cardinality bounds; null means the shape set no bound. */ -function mergeCardinalityBound( - a: number | null, - b: number | null, - pick: (x: number, y: number) => number, -) { - if (a === null) return b - if (b === null) return a - return pick(a, b) -} - -/** - * Merges shapes for one path using the strictest cardinality bounds. - * Each hint comes from the lowest-order shape that declares it; - * ties use encounter order. - */ -function mergeShapeProperties(properties: ShapeProperty[]): ShapeProperty { - return [...properties] - .sort((a, b) => a.order - b.order) - .reduce((merged, prop) => ({ - path: merged.path, - label: merged.label ?? prop.label, - description: merged.description ?? prop.description, - order: merged.order, - viewer: merged.viewer ?? prop.viewer, - editor: merged.editor ?? prop.editor, - nodeKind: merged.nodeKind ?? prop.nodeKind, - datatype: merged.datatype ?? prop.datatype, - node: merged.node ?? prop.node, - minCount: mergeCardinalityBound(merged.minCount, prop.minCount, Math.max), - maxCount: mergeCardinalityBound(merged.maxCount, prop.maxCount, Math.min), - })) -} - -/** - * Builds a map of shacl:path -> ShapeProperty for the current resource's RDF types. - * Searches all provided shape graphs for NodeShapes whose shacl:targetClass matches one of - * the resource's types. Shapes that constrain the same path are merged, see - * mergeShapeProperties. - */ -export function getShapePropertyMap( - resourceStore: Store, - subjectUri: string | null, - shapeGraphs: Store[], -): Map { - if (!subjectUri) return new Map() - - const currentTypes = new Set(getNodeRefs(resourceStore, subjectUri, RDF_TYPE)) - if (currentTypes.size === 0) return new Map() - - // Collect per path first so hints can be merged in shacl:order. - const byPath = new Map() - - for (const shapeGraph of shapeGraphs) { - for (const subject of shapeGraph.getSubjects( - DataFactory.namedNode(RDF_TYPE), - DataFactory.namedNode(SHACL_NODE_SHAPE), - null, - )) { - if (subject.termType !== 'NamedNode') continue - const targetClasses = getNodeRefs(shapeGraph, subject.value, SHACL_TARGET_CLASS) - if (!targetClasses.some((tc) => currentTypes.has(tc))) continue - - for (const propTerm of shapeGraph.getObjects( - subject, - DataFactory.namedNode(SHACL_PROPERTY), - null, - )) { - const prop = readShapeProperty(shapeGraph, propTerm) - if (!prop) continue - - const collected = byPath.get(prop.path) - if (collected) collected.push(prop) - else byPath.set(prop.path, [prop]) - } - } - } - - return new Map([...byPath].map(([path, properties]) => [path, mergeShapeProperties(properties)])) -} - -/** - * Shape properties an edit form offers, in shacl:order. A property is editable only when the - * shape declares a dash:editor, as dash:viewer decides what the metadata table shows. - */ -export function getEditableFields( - resourceStore: Store, - subjectUri: string | null, - shapeGraphs: Store[], -): EditableField[] { - return [...getShapePropertyMap(resourceStore, subjectUri, shapeGraphs).values()] - .filter((property): property is EditableField => property.editor !== null) - .sort((a, b) => a.order - b.order) -} - // --- Metadata rows --- const embeddedNodeSkipList = new Set([RDF_TYPE, SIO_IS_ABOUT, SIO_IS_RELATED_TO]) @@ -676,7 +495,7 @@ function buildRows( store: Store, uri: string, predicates: string[], - shapePropertyMap: Map, + shapePropertyMap: ReadonlyMap, ): MetadataRow[] { return predicates .map((p) => { @@ -694,12 +513,11 @@ function buildRows( export function getMetadataRows( store: Store, subjectUri: string | null, - shapeGraphs: Store[], + shapePropertyMap: ReadonlyMap, ): { rows: MetadataRow[]; unknownRows: MetadataRow[] } { if (!subjectUri) return { rows: [], unknownRows: [] } const allPredicates = getFilteredPredicates(store, subjectUri) - const shapePropertyMap = getShapePropertyMap(store, subjectUri, shapeGraphs) if (shapePropertyMap.size === 0) { const prioritySet = new Set(metadataPredicatePriority) diff --git a/src/composables/shaclUtils.ts b/src/composables/shaclUtils.ts new file mode 100644 index 0000000..cb2c65e --- /dev/null +++ b/src/composables/shaclUtils.ts @@ -0,0 +1,185 @@ +import { DataFactory } from 'n3' +import type { Literal, Store, Term } from 'n3' +import { + DASH_EDITOR, + DASH_VIEWER, + RDF_TYPE, + SHACL_DATATYPE, + SHACL_DESCRIPTION, + SHACL_MAX_COUNT, + SHACL_MIN_COUNT, + SHACL_NAME, + SHACL_NODE, + SHACL_NODE_KIND, + SHACL_NODE_SHAPE, + SHACL_ORDER, + SHACL_PATH, + SHACL_PROPERTY, + SHACL_TARGET_CLASS, +} from './vocabularies' +import { formatLiteralValue, getNodeRefs } from './rdfUtils' + +/** + * One SHACL property shape. `viewer` drives display, `editor` drives editing: a property is + * offered in a given UI only when the shape declares the matching DASH hint. + */ +export type ShapeProperty = { + path: string + label: string | null + description: string | null + order: number + viewer: string | null + editor: string | null + nodeKind: string | null + datatype: string | null + /** IRI of the nested shape referenced by sh:node. */ + node: string | null + minCount: number | null + maxCount: number | null +} + +export type EditableField = ShapeProperty & { editor: string } + +/** Like getFirstLiteral but takes an N3 Term as subject; used when iterating SHACL shape nodes. */ +function getObjectLiteral(store: Store, subject: Term, predicate: string): string | null { + const obj = store + .getObjects(subject, DataFactory.namedNode(predicate), null) + .find((o) => o.termType === 'Literal') + return obj ? formatLiteralValue(obj as Literal) : null +} + +/** Like getNodeRefs but takes an N3 Term as subject and returns only the first named node value. */ +function getObjectNamedNode(store: Store, subject: Term, predicate: string): string | null { + return ( + store + .getObjects(subject, DataFactory.namedNode(predicate), null) + .find((o) => o.termType === 'NamedNode')?.value ?? null + ) +} + +/** Parses a SHACL cardinality literal; absent or unparseable means no constraint. */ +function readCount(shapeGraph: Store, propTerm: Term, predicate: string): number | null { + const raw = getObjectLiteral(shapeGraph, propTerm, predicate) + if (raw === null) return null + const count = parseInt(raw, 10) + return Number.isNaN(count) ? null : count +} + +/** + * Reads a SHACL property shape node into a ShapeProperty struct. + * Returns null if the node has no shacl:path. + * shacl:order defaults to MAX_SAFE_INTEGER when absent, so unordered properties sort last. + */ +function readShapeProperty(shapeGraph: Store, propTerm: Term): ShapeProperty | null { + const path = getObjectNamedNode(shapeGraph, propTerm, SHACL_PATH) + if (!path) return null + + const orderStr = getObjectLiteral(shapeGraph, propTerm, SHACL_ORDER) + + return { + path, + label: getObjectLiteral(shapeGraph, propTerm, SHACL_NAME), + description: getObjectLiteral(shapeGraph, propTerm, SHACL_DESCRIPTION), + order: orderStr ? parseInt(orderStr, 10) : Number.MAX_SAFE_INTEGER, + viewer: getObjectNamedNode(shapeGraph, propTerm, DASH_VIEWER), + editor: getObjectNamedNode(shapeGraph, propTerm, DASH_EDITOR), + nodeKind: getObjectNamedNode(shapeGraph, propTerm, SHACL_NODE_KIND), + datatype: getObjectNamedNode(shapeGraph, propTerm, SHACL_DATATYPE), + node: getObjectNamedNode(shapeGraph, propTerm, SHACL_NODE), + minCount: readCount(shapeGraph, propTerm, SHACL_MIN_COUNT), + maxCount: readCount(shapeGraph, propTerm, SHACL_MAX_COUNT), + } +} + +/** Returns the stricter of two cardinality bounds; null means the shape set no bound. */ +function mergeCardinalityBound( + a: number | null, + b: number | null, + pick: (x: number, y: number) => number, +) { + if (a === null) return b + if (b === null) return a + return pick(a, b) +} + +/** + * Merges shapes for one path using the strictest cardinality bounds. + * Each hint comes from the lowest-order shape that declares it; + * ties use encounter order. + */ +function mergeShapeProperties(properties: ShapeProperty[]): ShapeProperty { + return [...properties] + .sort((a, b) => a.order - b.order) + .reduce((merged, prop) => ({ + path: merged.path, + label: merged.label ?? prop.label, + description: merged.description ?? prop.description, + order: merged.order, + viewer: merged.viewer ?? prop.viewer, + editor: merged.editor ?? prop.editor, + nodeKind: merged.nodeKind ?? prop.nodeKind, + datatype: merged.datatype ?? prop.datatype, + node: merged.node ?? prop.node, + minCount: mergeCardinalityBound(merged.minCount, prop.minCount, Math.max), + maxCount: mergeCardinalityBound(merged.maxCount, prop.maxCount, Math.min), + })) +} + +/** + * Builds a map of shacl:path -> ShapeProperty for the current resource's RDF types. + * Searches all provided shape graphs for NodeShapes whose shacl:targetClass matches one of + * the resource's types. Shapes that constrain the same path are merged, see + * mergeShapeProperties. + */ +export function getShapePropertyMap( + resourceStore: Store, + subjectUri: string | null, + shapeGraphs: Store[], +): Map { + if (!subjectUri) return new Map() + + const currentTypes = new Set(getNodeRefs(resourceStore, subjectUri, RDF_TYPE)) + if (currentTypes.size === 0) return new Map() + + // Collect per path first so hints can be merged in shacl:order. + const byPath = new Map() + + for (const shapeGraph of shapeGraphs) { + for (const subject of shapeGraph.getSubjects( + DataFactory.namedNode(RDF_TYPE), + DataFactory.namedNode(SHACL_NODE_SHAPE), + null, + )) { + if (subject.termType !== 'NamedNode') continue + const targetClasses = getNodeRefs(shapeGraph, subject.value, SHACL_TARGET_CLASS) + if (!targetClasses.some((tc) => currentTypes.has(tc))) continue + + for (const propTerm of shapeGraph.getObjects( + subject, + DataFactory.namedNode(SHACL_PROPERTY), + null, + )) { + const prop = readShapeProperty(shapeGraph, propTerm) + if (!prop) continue + + const collected = byPath.get(prop.path) + if (collected) collected.push(prop) + else byPath.set(prop.path, [prop]) + } + } + } + + return new Map([...byPath].map(([path, properties]) => [path, mergeShapeProperties(properties)])) +} + +/** + * Shape properties an edit form offers, in shacl:order. A property is editable only when the + * shape declares a dash:editor, as dash:viewer decides what the metadata table shows. + */ +export function getEditableFields( + shapePropertyMap: ReadonlyMap, +): EditableField[] { + return [...shapePropertyMap.values()] + .filter((property): property is EditableField => property.editor !== null) + .sort((a, b) => a.order - b.order) +} diff --git a/src/composables/useResourceView.ts b/src/composables/useResourceView.ts index 25de2da..7d2486f 100644 --- a/src/composables/useResourceView.ts +++ b/src/composables/useResourceView.ts @@ -12,9 +12,9 @@ import { getParentUri, getChildSections, getMetadataRows, - getEditableFields, uriLabel, } from './rdfUtils' +import { getShapePropertyMap, getEditableFields } from './shaclUtils' import { useRdfLoader, type ChildSummary } from './useRdfLoader' import { getBaseUrl } from './urlUtils' import type { ResourceIdentifier } from './fdpApi' @@ -93,17 +93,20 @@ export function useResourceView({ const childSections = computed(() => getChildSections(quads.value, currentNodeUri.value)) + // Share the computed shape map between metadata display and editable-field selection. + const shapeProperties = computed(() => + getShapePropertyMap(quads.value, currentNodeUri.value, Object.values(shapeGraphs.value)), + ) + const allMetadataRows = computed(() => - getMetadataRows(quads.value, currentNodeUri.value, Object.values(shapeGraphs.value)), + getMetadataRows(quads.value, currentNodeUri.value, shapeProperties.value), ) const metadataRows = computed(() => allMetadataRows.value.rows) const unknownMetadataRows = computed(() => allMetadataRows.value.unknownRows) - // Same shapes as the metadata table, filtered by dash:editor instead of dash:viewer. - const editableFields = computed(() => - getEditableFields(quads.value, currentNodeUri.value, Object.values(shapeGraphs.value)), - ) + // The same properties as the metadata table, filtered by dash:editor instead of dash:viewer. + const editableFields = computed(() => getEditableFields(shapeProperties.value)) watch( resourceUri, diff --git a/tests/composables/rdfUtils.test.ts b/tests/composables/rdfUtils.test.ts index 2df9ee2..a358277 100644 --- a/tests/composables/rdfUtils.test.ts +++ b/tests/composables/rdfUtils.test.ts @@ -13,15 +13,12 @@ import { getNodeRefs, uriLabel, getMetadataRows, - getShapePropertyMap, - getEditableFields, } from '../../src/composables/rdfUtils' import { DCT_TITLE, DCT_DESCRIPTION, DCT_PUBLISHER, DCT_LICENSE, - DCT_ISSUED, DCT_CONFORMS_TO, DCAT_ENDPOINT_URL, DCAT_ENDPOINT_DESCRIPTION, @@ -37,20 +34,6 @@ import { LDP_MEMBERSHIP_RESOURCE, XSD_DATE, XSD_DATETIME, - SHACL_NODE_SHAPE, - SHACL_TARGET_CLASS, - SHACL_PROPERTY, - SHACL_PATH, - SHACL_NAME, - SHACL_DESCRIPTION, - SHACL_ORDER, - SHACL_DATATYPE, - SHACL_NODE, - SHACL_MIN_COUNT, - SHACL_MAX_COUNT, - DASH_EDITOR, - DASH_VIEWER, - DASH_LABEL_VIEWER, } from '../../src/composables/vocabularies' const XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string' @@ -362,7 +345,7 @@ describe('getMetadataRows', () => { it('marks a same-origin dcat:endpointURL as external', () => { const store = parseTurtle(`<${BASE_URI}> <${DCAT_ENDPOINT_URL}> <${BASE_URI}> .`) - const { rows } = getMetadataRows(store, BASE_URI, []) + const { rows } = getMetadataRows(store, BASE_URI, new Map()) expect(find(rows, DCAT_ENDPOINT_URL)).toMatchObject({ values: [{ href: BASE_URI, internal: false }], }) @@ -372,7 +355,7 @@ describe('getMetadataRows', () => { const store = parseTurtle( `<${BASE_URI}> <${DCAT_ENDPOINT_DESCRIPTION}> <${BASE_URI}/v3/api-docs> .`, ) - const { rows } = getMetadataRows(store, BASE_URI, []) + const { rows } = getMetadataRows(store, BASE_URI, new Map()) expect(find(rows, DCAT_ENDPOINT_DESCRIPTION)).toMatchObject({ values: [{ href: `${BASE_URI}/v3/api-docs`, internal: false }], }) @@ -380,177 +363,9 @@ describe('getMetadataRows', () => { it('still marks an ordinary same-origin link as internal', () => { const store = parseTurtle(`<${BASE_URI}> <${DCT_CONFORMS_TO}> <${BASE_URI}/profile/1> .`) - const { rows } = getMetadataRows(store, BASE_URI, []) + const { rows } = getMetadataRows(store, BASE_URI, new Map()) expect(find(rows, DCT_CONFORMS_TO)).toMatchObject({ values: [{ href: `${BASE_URI}/profile/1`, internal: true }], }) }) }) - -describe('getShapePropertyMap', () => { - const RESOURCE = 'http://localhost/catalog/c1' - const CATALOG = 'http://www.w3.org/ns/dcat#Catalog' - const TEXT_FIELD_EDITOR = 'http://datashapes.org/dash#TextFieldEditor' - const URI_EDITOR = 'http://datashapes.org/dash#URIEditor' - const AGENT_SHAPE = 'http://fairdatapoint.org/AgentShape' - - const resourceStore = () => parseTurtle(`<${RESOURCE}> a <${CATALOG}> .`) - - /** A NodeShape targeting dcat:Catalog, with the given property-shape bodies. */ - const shapeGraph = (...properties: string[]) => - parseTurtle(` - a <${SHACL_NODE_SHAPE}> ; - <${SHACL_TARGET_CLASS}> <${CATALOG}> ; - ${properties.map((body) => `<${SHACL_PROPERTY}> [ ${body} ]`).join(' ;\n')} . - `) - - it('reads the SHACL and DASH attributes an edit form needs', () => { - const shape = shapeGraph(` - <${SHACL_PATH}> <${DCT_TITLE}> ; - <${SHACL_NAME}> "Title" ; - <${SHACL_DESCRIPTION}> "The resource title" ; - <${SHACL_ORDER}> 1 ; - <${SHACL_DATATYPE}> <${XSD_STRING}> ; - <${SHACL_MIN_COUNT}> 1 ; - <${SHACL_MAX_COUNT}> 1 ; - <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> ; - <${DASH_VIEWER}> <${DASH_LABEL_VIEWER}> - `) - expect(getShapePropertyMap(resourceStore(), RESOURCE, [shape]).get(DCT_TITLE)).toEqual({ - path: DCT_TITLE, - label: 'Title', - description: 'The resource title', - order: 1, - viewer: DASH_LABEL_VIEWER, - editor: TEXT_FIELD_EDITOR, - nodeKind: null, - datatype: XSD_STRING, - node: null, - minCount: 1, - maxCount: 1, - }) - }) - - it('reads sh:node for a property whose value is its own record', () => { - const shape = shapeGraph(` - <${SHACL_PATH}> <${DCT_PUBLISHER}> ; - <${SHACL_NODE}> <${AGENT_SHAPE}> - `) - expect( - getShapePropertyMap(resourceStore(), RESOURCE, [shape]).get(DCT_PUBLISHER), - ).toMatchObject({ - node: AGENT_SHAPE, - order: Number.MAX_SAFE_INTEGER, - minCount: null, - maxCount: null, - }) - }) - - it('merges shapes constraining the same path, keeping the strictest cardinality', () => { - const loose = shapeGraph(` - <${SHACL_PATH}> <${DCT_TITLE}> ; - <${SHACL_ORDER}> 1 ; - <${SHACL_MIN_COUNT}> 0 ; - <${SHACL_MAX_COUNT}> 5 ; - <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> - `) - const strict = shapeGraph(` - <${SHACL_PATH}> <${DCT_TITLE}> ; - <${SHACL_ORDER}> 9 ; - <${SHACL_DESCRIPTION}> "Only the stricter shape describes it" ; - <${SHACL_MIN_COUNT}> 2 ; - <${SHACL_MAX_COUNT}> 3 - `) - expect(getShapePropertyMap(resourceStore(), RESOURCE, [loose, strict]).get(DCT_TITLE)).toEqual({ - path: DCT_TITLE, - label: null, - description: 'Only the stricter shape describes it', - order: 1, - viewer: null, - editor: TEXT_FIELD_EDITOR, - nodeKind: null, - datatype: null, - node: null, - minCount: 2, - maxCount: 3, - }) - }) - - it('picks hints by shacl:order, whichever order the shapes are read in', () => { - const unhinted = shapeGraph(`<${SHACL_PATH}> <${DCT_TITLE}> ; <${SHACL_ORDER}> 1`) - const later = shapeGraph(` - <${SHACL_PATH}> <${DCT_TITLE}> ; - <${SHACL_ORDER}> 9 ; - <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> - `) - const earlier = shapeGraph(` - <${SHACL_PATH}> <${DCT_TITLE}> ; - <${SHACL_ORDER}> 5 ; - <${DASH_EDITOR}> <${URI_EDITOR}> - `) - - for (const shapes of [ - [unhinted, later, earlier], - [unhinted, earlier, later], - ]) { - expect(getShapePropertyMap(resourceStore(), RESOURCE, shapes).get(DCT_TITLE)).toMatchObject({ - order: 1, - editor: URI_EDITOR, - }) - } - }) - - it('treats an absent bound as no constraint when merging', () => { - const unbounded = shapeGraph(`<${SHACL_PATH}> <${DCT_TITLE}> ; <${SHACL_ORDER}> 1`) - const bounded = shapeGraph(` - <${SHACL_PATH}> <${DCT_TITLE}> ; - <${SHACL_ORDER}> 2 ; - <${SHACL_MAX_COUNT}> 1 - `) - expect( - getShapePropertyMap(resourceStore(), RESOURCE, [unbounded, bounded]).get(DCT_TITLE), - ).toMatchObject({ minCount: null, maxCount: 1 }) - }) - - it('ignores shapes that target a class the resource does not have', () => { - const shape = parseTurtle(` - a <${SHACL_NODE_SHAPE}> ; - <${SHACL_TARGET_CLASS}> ; - <${SHACL_PROPERTY}> [ <${SHACL_PATH}> <${DCT_TITLE}> ; <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> ] . - `) - expect(getShapePropertyMap(resourceStore(), RESOURCE, [shape]).size).toBe(0) - }) - - describe('getEditableFields', () => { - it('offers only properties with a dash:editor, in shacl:order', () => { - // Mirrors the live catalog shapes: dct:issued is displayed but the backend owns it. - const shape = shapeGraph( - `<${SHACL_PATH}> <${DCT_ISSUED}> ; - <${SHACL_ORDER}> 20 ; - <${DASH_VIEWER}> <${DASH_LABEL_VIEWER}>`, - `<${SHACL_PATH}> <${DCT_LICENSE}> ; - <${SHACL_ORDER}> 6 ; - <${DASH_EDITOR}> <${URI_EDITOR}> ; - <${DASH_VIEWER}> <${DASH_LABEL_VIEWER}>`, - `<${SHACL_PATH}> <${DCT_TITLE}> ; - <${SHACL_ORDER}> 1 ; - <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}>`, - ) - expect(getEditableFields(resourceStore(), RESOURCE, [shape]).map((f) => f.path)).toEqual([ - DCT_TITLE, - DCT_LICENSE, - ]) - }) - - it('sorts properties without shacl:order last', () => { - const shape = shapeGraph( - `<${SHACL_PATH}> <${DCT_PUBLISHER}> ; <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}>`, - `<${SHACL_PATH}> <${DCT_TITLE}> ; <${SHACL_ORDER}> 1 ; <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}>`, - ) - expect(getEditableFields(resourceStore(), RESOURCE, [shape]).map((f) => f.path)).toEqual([ - DCT_TITLE, - DCT_PUBLISHER, - ]) - }) - }) -}) diff --git a/tests/composables/shaclUtils.test.ts b/tests/composables/shaclUtils.test.ts new file mode 100644 index 0000000..c1a063b --- /dev/null +++ b/tests/composables/shaclUtils.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from 'vitest' +import { parseTurtle } from '../../src/composables/rdfUtils' +import { getShapePropertyMap, getEditableFields } from '../../src/composables/shaclUtils' +import { + DCT_TITLE, + DCT_PUBLISHER, + DCT_LICENSE, + DCT_ISSUED, + SHACL_NODE_SHAPE, + SHACL_TARGET_CLASS, + SHACL_PROPERTY, + SHACL_PATH, + SHACL_NAME, + SHACL_DESCRIPTION, + SHACL_ORDER, + SHACL_DATATYPE, + SHACL_NODE, + SHACL_MIN_COUNT, + SHACL_MAX_COUNT, + DASH_EDITOR, + DASH_VIEWER, + DASH_LABEL_VIEWER, +} from '../../src/composables/vocabularies' + +const XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string' + +describe('getShapePropertyMap', () => { + const RESOURCE = 'http://localhost/catalog/c1' + const CATALOG = 'http://www.w3.org/ns/dcat#Catalog' + const TEXT_FIELD_EDITOR = 'http://datashapes.org/dash#TextFieldEditor' + const URI_EDITOR = 'http://datashapes.org/dash#URIEditor' + const AGENT_SHAPE = 'http://fairdatapoint.org/AgentShape' + + const resourceStore = () => parseTurtle(`<${RESOURCE}> a <${CATALOG}> .`) + + /** A NodeShape targeting dcat:Catalog, with the given property-shape bodies. */ + const shapeGraph = (...properties: string[]) => + parseTurtle(` + a <${SHACL_NODE_SHAPE}> ; + <${SHACL_TARGET_CLASS}> <${CATALOG}> ; + ${properties.map((body) => `<${SHACL_PROPERTY}> [ ${body} ]`).join(' ;\n')} . + `) + + it('reads the SHACL and DASH attributes an edit form needs', () => { + const shape = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_NAME}> "Title" ; + <${SHACL_DESCRIPTION}> "The resource title" ; + <${SHACL_ORDER}> 1 ; + <${SHACL_DATATYPE}> <${XSD_STRING}> ; + <${SHACL_MIN_COUNT}> 1 ; + <${SHACL_MAX_COUNT}> 1 ; + <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> ; + <${DASH_VIEWER}> <${DASH_LABEL_VIEWER}> + `) + expect(getShapePropertyMap(resourceStore(), RESOURCE, [shape]).get(DCT_TITLE)).toEqual({ + path: DCT_TITLE, + label: 'Title', + description: 'The resource title', + order: 1, + viewer: DASH_LABEL_VIEWER, + editor: TEXT_FIELD_EDITOR, + nodeKind: null, + datatype: XSD_STRING, + node: null, + minCount: 1, + maxCount: 1, + }) + }) + + it('reads sh:node for a property whose value is its own record', () => { + const shape = shapeGraph(` + <${SHACL_PATH}> <${DCT_PUBLISHER}> ; + <${SHACL_NODE}> <${AGENT_SHAPE}> + `) + expect( + getShapePropertyMap(resourceStore(), RESOURCE, [shape]).get(DCT_PUBLISHER), + ).toMatchObject({ + node: AGENT_SHAPE, + order: Number.MAX_SAFE_INTEGER, + minCount: null, + maxCount: null, + }) + }) + + it('merges shapes constraining the same path, keeping the strictest cardinality', () => { + const loose = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 1 ; + <${SHACL_MIN_COUNT}> 0 ; + <${SHACL_MAX_COUNT}> 5 ; + <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> + `) + const strict = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 9 ; + <${SHACL_DESCRIPTION}> "Only the stricter shape describes it" ; + <${SHACL_MIN_COUNT}> 2 ; + <${SHACL_MAX_COUNT}> 3 + `) + expect(getShapePropertyMap(resourceStore(), RESOURCE, [loose, strict]).get(DCT_TITLE)).toEqual({ + path: DCT_TITLE, + label: null, + description: 'Only the stricter shape describes it', + order: 1, + viewer: null, + editor: TEXT_FIELD_EDITOR, + nodeKind: null, + datatype: null, + node: null, + minCount: 2, + maxCount: 3, + }) + }) + + it('picks hints by shacl:order, whichever order the shapes are read in', () => { + const unhinted = shapeGraph(`<${SHACL_PATH}> <${DCT_TITLE}> ; <${SHACL_ORDER}> 1`) + const later = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 9 ; + <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> + `) + const earlier = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 5 ; + <${DASH_EDITOR}> <${URI_EDITOR}> + `) + + for (const shapes of [ + [unhinted, later, earlier], + [unhinted, earlier, later], + ]) { + expect(getShapePropertyMap(resourceStore(), RESOURCE, shapes).get(DCT_TITLE)).toMatchObject({ + order: 1, + editor: URI_EDITOR, + }) + } + }) + + it('treats an absent bound as no constraint when merging', () => { + const unbounded = shapeGraph(`<${SHACL_PATH}> <${DCT_TITLE}> ; <${SHACL_ORDER}> 1`) + const bounded = shapeGraph(` + <${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 2 ; + <${SHACL_MAX_COUNT}> 1 + `) + expect( + getShapePropertyMap(resourceStore(), RESOURCE, [unbounded, bounded]).get(DCT_TITLE), + ).toMatchObject({ minCount: null, maxCount: 1 }) + }) + + it('ignores shapes that target a class the resource does not have', () => { + const shape = parseTurtle(` + a <${SHACL_NODE_SHAPE}> ; + <${SHACL_TARGET_CLASS}> ; + <${SHACL_PROPERTY}> [ <${SHACL_PATH}> <${DCT_TITLE}> ; <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}> ] . + `) + expect(getShapePropertyMap(resourceStore(), RESOURCE, [shape]).size).toBe(0) + }) + + describe('getEditableFields', () => { + it('offers only properties with a dash:editor, in shacl:order', () => { + // Mirrors the live catalog shapes: dct:issued is displayed but the backend owns it. + const shape = shapeGraph( + `<${SHACL_PATH}> <${DCT_ISSUED}> ; + <${SHACL_ORDER}> 20 ; + <${DASH_VIEWER}> <${DASH_LABEL_VIEWER}>`, + `<${SHACL_PATH}> <${DCT_LICENSE}> ; + <${SHACL_ORDER}> 6 ; + <${DASH_EDITOR}> <${URI_EDITOR}> ; + <${DASH_VIEWER}> <${DASH_LABEL_VIEWER}>`, + `<${SHACL_PATH}> <${DCT_TITLE}> ; + <${SHACL_ORDER}> 1 ; + <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}>`, + ) + expect( + getEditableFields(getShapePropertyMap(resourceStore(), RESOURCE, [shape])).map( + (f) => f.path, + ), + ).toEqual([DCT_TITLE, DCT_LICENSE]) + }) + + it('sorts properties without shacl:order last', () => { + const shape = shapeGraph( + `<${SHACL_PATH}> <${DCT_PUBLISHER}> ; <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}>`, + `<${SHACL_PATH}> <${DCT_TITLE}> ; <${SHACL_ORDER}> 1 ; <${DASH_EDITOR}> <${TEXT_FIELD_EDITOR}>`, + ) + expect( + getEditableFields(getShapePropertyMap(resourceStore(), RESOURCE, [shape])).map( + (f) => f.path, + ), + ).toEqual([DCT_TITLE, DCT_PUBLISHER]) + }) + }) +}) From 2c18580d3ad188b94d7e50d6ee6001f98fb20a51 Mon Sep 17 00:00:00 2001 From: Mihai Lefter Date: Fri, 18 Sep 2026 11:52:16 +0200 Subject: [PATCH 06/38] Add a shape driven form to the edit page --- src/assets/main.css | 88 ++++++- src/components/ShapeFormFields.vue | 202 ++++++++++++++++ src/composables/rdfUtils.ts | 12 +- src/composables/shaclUtils.ts | 107 ++++++--- src/composables/shapeForm.ts | 125 ++++++++++ src/composables/vocabularies.ts | 5 +- src/views/ResourceEditView.vue | 38 +-- tests/components/ShapeFormFields.test.ts | 69 ++++++ tests/composables/shaclUtils.test.ts | 57 ++++- tests/composables/shapeForm.test.ts | 291 +++++++++++++++++++++++ tests/composables/useShapeForm.test.ts | 95 ++++++++ tests/views/resourceEdit.test.ts | 182 ++++++++++++-- 12 files changed, 1179 insertions(+), 92 deletions(-) create mode 100644 src/components/ShapeFormFields.vue create mode 100644 src/composables/shapeForm.ts create mode 100644 tests/components/ShapeFormFields.test.ts create mode 100644 tests/composables/shapeForm.test.ts create mode 100644 tests/composables/useShapeForm.test.ts diff --git a/src/assets/main.css b/src/assets/main.css index 4aae505..e4c41c8 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -279,14 +279,28 @@ a:hover { padding-left: 1.1rem; } -.editable-field__required { +.user-form__required { color: var(--fdp-color-danger); } -.editable-field__meta { - margin-left: var(--fdp-space-3); +/* Placeholder for editors the form cannot render yet; disappears as widgets land. */ +.user-form__pending { + margin: 0; color: var(--fdp-color-muted); - font-weight: 400; + font-style: italic; +} + +/* A sh:node sub-record, set off from the field it belongs to. */ +.user-form__nested { + margin-left: var(--fdp-space-4); + padding-left: var(--fdp-space-4); + border-left: 2px solid var(--fdp-color-border); +} + +.user-form__note { + margin: 0 0 var(--fdp-space-6); + color: var(--fdp-color-muted); + font-style: italic; } .text-link { @@ -1262,6 +1276,31 @@ pre.raw-content code { margin-bottom: 1.5rem; } +fieldset.user-form__group { + border: 0; + padding: 0; + margin-inline: 0; + min-width: 0; +} + +.user-form__group legend, +.user-form__label { + padding: 0; + font-size: var(--fdp-font-size-sm); + font-weight: 600; + margin-bottom: var(--fdp-space-1); +} + +.user-form__visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + .user-form__group--error input, .user-form__group--error select { border-bottom-color: var(--fdp-color-danger); @@ -1280,8 +1319,44 @@ pre.raw-content code { margin-bottom: var(--fdp-space-1); } +/* One value of a field, with its remove button when the field is a list. */ +.user-form__value { + display: flex; + align-items: flex-start; + gap: var(--fdp-space-3); +} + +.user-form__value + .user-form__value { + margin-top: var(--fdp-space-3); +} + +.user-form__value > .user-form__nested { + flex: 1; +} + +.user-form__remove { + flex-shrink: 0; + border: none; + background: none; + padding: 0 var(--fdp-space-1); + color: var(--fdp-color-danger); + font-size: var(--fdp-font-size-lg); + line-height: 1.4; + cursor: pointer; +} + +.user-form__add { + margin-top: var(--fdp-space-2); + font-size: var(--fdp-font-size-sm); +} + +.user-form__group textarea { + resize: vertical; +} + .user-form__group input, -.user-form__group select { +.user-form__group select, +.user-form__group textarea { width: 100%; box-sizing: border-box; border: none; @@ -1294,7 +1369,8 @@ pre.raw-content code { } .user-form__group input:focus, -.user-form__group select:focus { +.user-form__group select:focus, +.user-form__group textarea:focus { border-bottom-color: var(--fdp-color-link); } diff --git a/src/components/ShapeFormFields.vue b/src/components/ShapeFormFields.vue new file mode 100644 index 0000000..6f96ae8 --- /dev/null +++ b/src/components/ShapeFormFields.vue @@ -0,0 +1,202 @@ + + +