diff --git a/l10n/nl.js b/l10n/nl.js index 80c1402fc..99de245fa 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -3110,7 +3110,13 @@ OC.L10N.register( "Served at the root": "Op het hoofdadres", "Landing page": "Landingspagina", "Whether a proxy in front of Nextcloud rewrites the short addresses of this host (/, /afval) to the site route. When true, the site writes its links and its canonical address as short addresses on this host. Leave false without such a proxy rule: the short addresses would not resolve. Read only from a verified domain.": "Of een proxy voor Nextcloud de korte adressen van deze host (/, /afval) omschrijft naar de route van de site. Staat dit aan, dan schrijft de site haar links en haar vaste adres als korte adressen op deze host. Laat het uit zonder zo'n proxyregel: de korte adressen werken dan niet. Wordt alleen gelezen van een geverifieerd domein.", - "The site page the bare address of this host opens, as a route with a leading slash. Empty means the home page. A host for the signed-in area names that area here. Read only from a verified domain.": "De pagina van de site die het kale adres van deze host opent, als route die met een schuine streep begint. Leeg betekent de startpagina. Een host voor het ingelogde deel noemt dat deel hier. Wordt alleen gelezen van een geverifieerd domein." + "The site page the bare address of this host opens, as a route with a leading slash. Empty means the home page. A host for the signed-in area names that area here. Read only from a verified domain.": "De pagina van de site die het kale adres van deze host opent, als route die met een schuine streep begint. Leeg betekent de startpagina. Een host voor het ingelogde deel noemt dat deel hier. Wordt alleen gelezen van een geverifieerd domein.", + "No logo": "Geen logo", + "A language is a code such as nl, en, de, fr or fy.": "Een taal is een code zoals nl, en, de, fr of fy.", + "The list to choose from could not be loaded.": "De lijst om uit te kiezen kon niet worden geladen.", + "Or a web address": "Of een webadres", + "A logo address starts with /, http:// or https://.": "Een logo-adres begint met /, http:// of https://.", + "A published image from Media of this portal, or the address of an image.": "Een gepubliceerde afbeelding uit Media van deze portal, of het adres van een afbeelding." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 1d98a6c8d..ed67dcbf9 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -3124,7 +3124,13 @@ "Served at the root": "Op het hoofdadres", "Landing page": "Landingspagina", "Whether a proxy in front of Nextcloud rewrites the short addresses of this host (/, /afval) to the site route. When true, the site writes its links and its canonical address as short addresses on this host. Leave false without such a proxy rule: the short addresses would not resolve. Read only from a verified domain.": "Of een proxy voor Nextcloud de korte adressen van deze host (/, /afval) omschrijft naar de route van de site. Staat dit aan, dan schrijft de site haar links en haar vaste adres als korte adressen op deze host. Laat het uit zonder zo'n proxyregel: de korte adressen werken dan niet. Wordt alleen gelezen van een geverifieerd domein.", - "The site page the bare address of this host opens, as a route with a leading slash. Empty means the home page. A host for the signed-in area names that area here. Read only from a verified domain.": "De pagina van de site die het kale adres van deze host opent, als route die met een schuine streep begint. Leeg betekent de startpagina. Een host voor het ingelogde deel noemt dat deel hier. Wordt alleen gelezen van een geverifieerd domein." + "The site page the bare address of this host opens, as a route with a leading slash. Empty means the home page. A host for the signed-in area names that area here. Read only from a verified domain.": "De pagina van de site die het kale adres van deze host opent, als route die met een schuine streep begint. Leeg betekent de startpagina. Een host voor het ingelogde deel noemt dat deel hier. Wordt alleen gelezen van een geverifieerd domein.", + "No logo": "Geen logo", + "A language is a code such as nl, en, de, fr or fy.": "Een taal is een code zoals nl, en, de, fr of fy.", + "The list to choose from could not be loaded.": "De lijst om uit te kiezen kon niet worden geladen.", + "Or a web address": "Of een webadres", + "A logo address starts with /, http:// or https://.": "Een logo-adres begint met /, http:// of https://.", + "A published image from Media of this portal, or the address of an image.": "Een gepubliceerde afbeelding uit Media van deze portal, of het adres van een afbeelding." }, "plurals": "", "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/package-lock.json b/package-lock.json index 1a9cd29b4..3e8b2b809 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.5", "@babel/runtime": "^7.23.6", - "@conduction/nextcloud-vue": "^2.82.0", + "@conduction/nextcloud-vue": "^2.83.0", "@gemeente-denhaag/action": "4.4.2", "@gemeente-denhaag/card": "5.1.4", "@gemeente-denhaag/contact-timeline": "4.1.3", @@ -1771,9 +1771,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.82.0", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.82.0.tgz", - "integrity": "sha512-GyvYAQq7cRQvqr/SoUwC6uhkkyb87MAr+7bjh+WO0BaGmWVn8yJJcx75YCtgLzAQziNuhkU8vv5eM4u3nXqXng==", + "version": "2.83.0", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.83.0.tgz", + "integrity": "sha512-KDVTMiBSrkNXxpfvF5ML6dsmKp5/2au6eaq0zWLYLNDaRu48WSyKIWdBX0TFf7TbXWyFw04t325czqzrLluFww==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", diff --git a/package.json b/package.json index b8c6f1a23..3d9b3ec14 100644 --- a/package.json +++ b/package.json @@ -219,7 +219,7 @@ "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.5", "@babel/runtime": "^7.23.6", - "@conduction/nextcloud-vue": "^2.82.0", + "@conduction/nextcloud-vue": "^2.83.0", "@gemeente-denhaag/action": "4.4.2", "@gemeente-denhaag/card": "5.1.4", "@gemeente-denhaag/contact-timeline": "4.1.3", diff --git a/src/lib/portalDetails.js b/src/lib/portalDetails.js deleted file mode 100644 index 6ad15c8a8..000000000 --- a/src/lib/portalDetails.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * The "Gegevens van de portal" form: which fields it edits, how a value is - * checked before it is saved, and what is sent. - * - * @spec openspec/specs/portal-settings/spec.md - */ - -export const KIND_OPTIONS = [ - { id: 'site', label: 'Website on portaliq' }, - { id: 'external', label: 'Points to another website' }, -] - -export const HEADER_OPTIONS = [ - { id: 'double', label: 'Two lines, menu below' }, - { id: 'single', label: 'One line' }, -] - -export const LOCALE_OPTIONS = ['nl', 'en', 'de', 'fr', 'fy'] - -const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ -const ORIGIN = /^https?:\/\/[a-z0-9.-]+(?::\d+)?$/i - -/** - * Check the form before it is saved. - * - * @param {object} form The form values. - * @return {Record} Field to message key; empty when valid. - * @spec openspec/specs/portal-settings/spec.md - */ -export function validateDetails(form) { - const errors = {} - if (!String(form.title || '').trim()) { - errors.title = 'Give the portal a name.' - } - if (!SLUG.test(String(form.slug || ''))) { - errors.slug = 'Use lowercase letters, digits and dashes.' - } - if ((form.locales || []).length === 0) { - errors.locales = 'Choose at least one language.' - } - const bad = (form.frameAncestors || []).find((o) => !ORIGIN.test(String(o))) - if (bad !== undefined) { - errors.frameAncestors = 'An address looks like https://www.example.nl.' - } - return errors -} - -/** - * What to send: only the fields the beheerder changed. OpenRegister merges a - * PATCH into the stored portal, so every field the form does not edit (domains, - * theme, tokens, traffic, bookkeeping) is never part of the request and cannot - * be lost by it. - * - * @param {object} portal The portal as the page loaded it. - * @param {object} form The form values. - * @return {object} The body to PATCH; empty when nothing changed. - * @spec openspec/specs/portal-settings/spec.md - */ -export function portalPatch(portal, form) { - const before = formFrom(portal) - const after = { - title: String(form.title).trim(), - tagline: String(form.tagline || '').trim(), - slug: form.slug, - organisation: String(form.organisation || '').trim(), - kind: form.kind, - headerVariant: form.headerVariant, - locales: [...form.locales], - accountLabel: String(form.accountLabel || '').trim(), - logo: String(form.logo || '').trim(), - frameAncestors: [...(form.frameAncestors || [])], - } - const patch = {} - for (const [key, value] of Object.entries(after)) { - if (JSON.stringify(value) !== JSON.stringify(before[key])) { - patch[key] = value - } - } - return patch -} - -/** - * The portal as the server holds it after a PATCH: the baseline the next save - * compares against. - * - * @param {object} baseline The portal before the save. - * @param {object} patch The body that was sent. - * @return {object} The portal with the patch applied. - * @spec openspec/specs/portal-settings/spec.md - */ -export function afterSave(baseline, patch) { - return { ...baseline, ...patch } -} - -/** - * The form values for a portal. - * - * @param {object} portal The portal as the page loaded it. - * @return {object} The form values. - * @spec openspec/specs/portal-settings/spec.md - */ -export function formFrom(portal) { - const p = portal || {} - return { - title: p.title || '', - tagline: p.tagline || '', - slug: p.slug || '', - organisation: p.organisation || '', - kind: p.kind || 'site', - headerVariant: p.headerVariant || 'double', - locales: - Array.isArray(p.locales) && p.locales.length > 0 - ? [...p.locales] - : ['nl'], - accountLabel: p.accountLabel || '', - logo: p.logo || '', - frameAncestors: Array.isArray(p.frameAncestors) ? [...p.frameAncestors] : [], - } -} diff --git a/src/manifest.json b/src/manifest.json index faac982a7..4c2bde702 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2527,9 +2527,120 @@ }, { "id": "portal-settings-details", - "type": "PortalDetailsForm", + "type": "settings-form", "title": "Details of the portal", - "content": {} + "content": { + "columns": 2, + "saveLabel": "Save", + "savedText": "The portal is saved.", + "fields": [ + { + "key": "title", + "label": "Name", + "required": true + }, + { + "key": "tagline", + "label": "Tagline" + }, + { + "key": "slug", + "label": "Address in portaliq", + "hint": "Pages and menus belong to the portal through this address.", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "patternMessage": "Use lowercase letters, digits and dashes." + }, + { + "key": "organisation", + "label": "Organisation" + }, + { + "key": "kind", + "label": "Kind", + "type": "select", + "options": [ + { + "value": "site", + "label": "Website on portaliq" + }, + { + "value": "external", + "label": "Points to another website" + } + ], + "default": "site" + }, + { + "key": "headerVariant", + "label": "Header", + "type": "select", + "options": [ + { + "value": "double", + "label": "Two lines, menu below" + }, + { + "value": "single", + "label": "One line" + } + ], + "default": "double" + }, + { + "key": "locales", + "label": "Languages", + "type": "chips", + "addLabel": "Add", + "confirmLabel": "Add", + "default": [ + "nl" + ], + "minItems": 1, + "minItemsMessage": "Choose at least one language.", + "hint": "With more than one language the header shows a language choice.", + "lowercase": true, + "placeholder": "nl", + "pattern": "^[a-z]{2,3}(-[a-z0-9]{2,8})*$", + "patternMessage": "A language is a code such as nl, en, de, fr or fy." + }, + { + "key": "accountLabel", + "label": "Name of the login button" + }, + { + "key": "logo", + "label": "Logo", + "type": "reference", + "register": "portaliq", + "schema": "media", + "labelField": "title", + "filter": { + "kind": "image", + "status": "published", + "portal": "@object.slug" + }, + "valueTemplate": "/apps/portaliq/api/content/media/{id}?portal={portal}", + "emptyLabel": "No logo", + "hint": "A published image from Media of this portal, or the address of an image.", + "free": true, + "freeLabel": "Or a web address", + "freePlaceholder": "https://www.example.nl/logo.svg", + "freePattern": "^(/(?!/)\\S*|https?://\\S+)$", + "freePatternMessage": "A logo address starts with /, http:// or https://." + }, + { + "key": "frameAncestors", + "label": "May be embedded on", + "type": "chips", + "addLabel": "Add", + "confirmLabel": "Add", + "placeholder": "https://www.example.nl", + "pattern": "^https?://[A-Za-z0-9.-]+(:[0-9]+)?$", + "patternMessage": "An address looks like https://www.example.nl.", + "wide": true + } + ] + } }, { "id": "portal-settings-theme", diff --git a/src/registry.js b/src/registry.js index b45f07fc0..d347e4950 100644 --- a/src/registry.js +++ b/src/registry.js @@ -47,7 +47,6 @@ import PageTrafficFlow from './widgets/PageTrafficFlow.vue' import PortalAccessibility from './widgets/PortalAccessibility.vue' import PortalAccountWithdraw from './widgets/PortalAccountWithdraw.vue' import PortalCaseTypes from './widgets/PortalCaseTypes.vue' -import PortalDetailsForm from './widgets/PortalDetailsForm.vue' import PortalHomePage from './widgets/PortalHomePage.vue' import PortalRegistration from './widgets/PortalRegistration.vue' import PortalSignin from './widgets/PortalSignin.vue' @@ -252,17 +251,6 @@ export default { propsSchema: null, _note: "Who may make an account on the portal and the registrations waiting for approval (identity-staff-account-screens T07). Custom because the policy is saved inside the portal's authentication block and Approve and Refuse post the guarded account routes.", }, - // @custom-widget-ratchet exclude the portal's own fields, where the slug that pages and menus join on, the languages and the embedding origins are checked together and the save patches only the fields that changed, which no built-in form widget does in nc-vue 2.80 - PortalDetailsForm: { - kind: 'widget', - component: PortalDetailsForm, - defaultSize: { w: 12, h: 8 }, - minSize: { w: 6, h: 6 }, - maxSize: { w: 12, h: 14 }, - allowedSlots: ['body'], - propsSchema: null, - _note: "Gegevens van de portal, the first section of a portal's Instellingen tab (screens PtPortalInstellingen). The tab itself is a library sections widget; this is one of its sections. Custom because the slug, the languages and the embedding origins are checked together and the save patches only the fields that changed.", - }, // @custom-widget-ratchet exclude a read-only summary of the portal record in the side column (domains, house style, status) with the portal's own computed values, which no built-in widget draws without a data source PortalSummaryCard: { kind: 'widget', diff --git a/src/widgets/PortalDetailsForm.vue b/src/widgets/PortalDetailsForm.vue deleted file mode 100644 index b53c02ac2..000000000 --- a/src/widgets/PortalDetailsForm.vue +++ /dev/null @@ -1,264 +0,0 @@ - - - - - - - - diff --git a/tests/portal-details.spec.mjs b/tests/portal-details.spec.mjs index a9c6e7657..bf70f2d86 100644 --- a/tests/portal-details.spec.mjs +++ b/tests/portal-details.spec.mjs @@ -2,9 +2,10 @@ // SPDX-License-Identifier: EUPL-1.2 // Copyright (C) 2026 Conduction B.V. // -// portal-details.spec.mjs: the "Gegevens van de portal" form refuses what the -// portal cannot use and saves only the fields that changed -// (portal-domains-and-editors REQ-PDE-004). +// portal-details.spec.mjs: the "Gegevens van de portal" card is manifest config +// over nextcloud-vue's settings-form and chips-field widgets +// (portal-domains-and-editors REQ-PDE-004). The values below are the portal +// schema's, not the widgets': a wrong enum value is refused by OpenRegister. // // Usage: // node --test tests/portal-details.spec.mjs @@ -12,65 +13,95 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' -import { afterSave, formFrom, portalPatch, validateDetails } from '../src/lib/portalDetails.js' -const PORTAL = { - title: 'Gemeente Zuiddrecht', - slug: 'zuiddrecht', - status: 'published', - domains: [{ hostname: 'zuiddrecht.nl', verified: true }], - '@self': { id: 'p1' }, -} +const read = (name) => readFileSync(new URL(`../${name}`, import.meta.url), 'utf8') +const MANIFEST = JSON.parse(read('src/manifest.json')) +const REGISTER = JSON.parse(read('lib/Settings/portaliq_register.json')) +const PORTAL = REGISTER.components.schemas.portal.properties +const DETAIL = MANIFEST.pages.find((page) => page.id === 'PortalDetail').config -test('a complete form is valid', () => { - assert.deepEqual(validateDetails(formFrom(PORTAL)), {}) -}) +const widget = (id) => DETAIL.widgets.find((w) => w.id === id) +const form = widget('portal-settings-details') +const field = (key) => form.content.fields.find((f) => f.key === key) -test('an empty name, a bad address, no language and a bad origin are each refused', () => { - const errors = validateDetails({ - ...formFrom(PORTAL), - title: ' ', - slug: 'Bad Slug', - locales: [], - frameAncestors: ['www.example.nl'], - }) - assert.deepEqual(Object.keys(errors).sort(), ['frameAncestors', 'locales', 'slug', 'title']) +test('the details card is a settings-form over fields the portal schema has', () => { + assert.equal(form.type, 'settings-form') + for (const f of form.content.fields) { + assert.ok(PORTAL[f.key], `the portal schema has no "${f.key}"`) + } + assert.equal(field('title').required, true) }) -test('an origin with a scheme and a port is accepted', () => { - const errors = validateDetails({ ...formFrom(PORTAL), frameAncestors: ['https://www.example.nl', 'http://localhost:8080'] }) - assert.equal(errors.frameAncestors, undefined) +test('the select options are the schema enums, not a placeholder', () => { + assert.deepEqual(field('kind').options.map((o) => o.value).sort(), [...PORTAL.kind.enum].sort()) + assert.deepEqual(field('headerVariant').options.map((o) => o.value).sort(), [...PORTAL.headerVariant.enum].sort()) }) -test('the patch holds only the fields that changed, so nothing else is sent', () => { - const patch = portalPatch(PORTAL, { ...formFrom(PORTAL), tagline: ' Samen ' }) - assert.deepEqual(patch, { tagline: 'Samen' }) +test('the address accepts lowercase words joined by dashes and nothing else', () => { + const re = new RegExp(field('slug').pattern) + for (const ok of ['zuiddrecht', 'open-tilburg', 'a1-b2']) assert.ok(re.test(ok), ok) + for (const bad of ['Bad Slug', 'UPPER', '-lead', 'trail-', 'a--b', '']) assert.equal(re.test(bad), false, bad) + assert.ok(field('slug').patternMessage) }) -test('a form left as loaded sends nothing', () => { - assert.deepEqual(portalPatch(PORTAL, formFrom(PORTAL)), {}) +test('languages and embedding origins are chips in the same form, saved with it', () => { + const locales = field('locales') + assert.equal(locales.type, 'chips') + assert.equal(PORTAL.locales.type, 'array') + assert.equal(locales.minItems, 1) + assert.ok(locales.minItemsMessage) + assert.deepEqual(locales.default, ['nl']) + // A free code, as the old taggable select allowed, not a fixed list. + assert.equal(locales.options, undefined) + const code = new RegExp(locales.pattern) + for (const ok of ['nl', 'en', 'fy', 'pap', 'nl-be']) assert.ok(code.test(ok), ok) + for (const bad of ['', 'Nederlands', 'n', 'nl_BE']) assert.equal(code.test(bad), false, bad) + const frame = field('frameAncestors') + assert.equal(frame.type, 'chips') + assert.equal(PORTAL.frameAncestors.type, 'array') + const origin = new RegExp(frame.pattern) + for (const ok of ['https://www.example.nl', 'http://localhost:8080']) assert.ok(origin.test(ok), ok) + for (const bad of ['www.example.nl', 'https://', 'ftp://x.nl', 'https://x.nl/path']) assert.equal(origin.test(bad), false, bad) + assert.ok(frame.patternMessage) }) -test('a list that changed is sent whole', () => { - const patch = portalPatch(PORTAL, { ...formFrom(PORTAL), locales: ['nl', 'en'] }) - assert.deepEqual(patch, { locales: ['nl', 'en'] }) +test('an absent kind or header shows the value the site already assumes', () => { + assert.equal(field('kind').default, 'site') + assert.ok(PORTAL.kind.enum.includes(field('kind').default)) + assert.equal(field('headerVariant').default, 'double') + assert.ok(PORTAL.headerVariant.enum.includes(field('headerVariant').default)) }) -test('a second save compares against the first save, not against the page as it loaded', () => { - // Save "B" over "A", then edit back to "A": the server holds "B", so "A" must be sent. - let baseline = PORTAL - const first = portalPatch(baseline, { ...formFrom(baseline), title: 'B' }) - assert.deepEqual(first, { title: 'B' }) - baseline = afterSave(baseline, first) - const back = portalPatch(baseline, { ...formFrom(baseline), title: PORTAL.title }) - assert.deepEqual(back, { title: PORTAL.title }) - // Against the stale starting values the revert looked like "nothing changed". - assert.deepEqual(portalPatch(PORTAL, { ...formFrom(PORTAL), title: PORTAL.title }), {}) +test('the logo is picked from the portal media library and stored as an address the site serves', () => { + const logo = field('logo') + assert.equal(logo.type, 'reference') + assert.equal(logo.register, 'portaliq') + assert.ok(REGISTER.components.schemas[logo.schema], `the register has no "${logo.schema}" schema`) + const media = REGISTER.components.schemas[logo.schema].properties + assert.ok(media.kind.enum.includes(logo.filter.kind)) + assert.ok(media.status.enum.includes(logo.filter.status)) + // Only this portal's images: media.portal holds the portal slug. + assert.equal(logo.filter.portal, '@object.slug') + assert.ok(media.portal) + // A typed address, accepted where PortalRuntimeConfigResolver::logoUrl() accepts it. + assert.equal(logo.free, true) + const typed = new RegExp(logo.freePattern) + for (const ok of ['/apps/portaliq/logo/open-tilburg.svg', 'https://www.example.nl/logo.svg', 'http://localhost/logo.png']) assert.ok(typed.test(ok), ok) + for (const bad of ['//evil.example/logo.svg', 'javascript:alert(1)', 'logo.svg', 'ftp://x.nl/l.svg', '']) assert.equal(typed.test(bad), false, bad) + assert.ok(logo.freePatternMessage) + assert.ok(media[logo.labelField], logo.labelField) + // Root-relative, so PortalRuntimeConfigResolver::logoUrl() lets it through, + // and the route the public media items are served on. + assert.match(logo.valueTemplate, /^\/apps\/portaliq\/api\/content\/media\/\{id\}\?portal=\{portal\}$/) + assert.match(read('appinfo/routes.php'), /'url' => '\/api\/content\/media\/\{id\}'/) }) -test('the form saves against its baseline and moves it after a save', () => { - const src = readFileSync(new URL('../src/widgets/PortalDetailsForm.vue', import.meta.url), 'utf8') - assert.match(src, /portalPatch\(this\.baseline, this\.form\)/) - assert.match(src, /this\.baseline = afterSave\(this\.baseline, patch\)/) - assert.doesNotMatch(src, /portalPatch\(this\.objectData/) +test('the details are one card, and the old component is gone', () => { + const sections = widget('portal-settings').content.sections + assert.ok(sections.some((s) => s.widgetId === 'portal-settings-details')) + for (const id of ['portal-settings-locales', 'portal-settings-frame']) { + assert.equal(widget(id), undefined, `${id} is a card of its own again`) + } + assert.deepEqual(form.content.fields.map((f) => f.key), ['title', 'tagline', 'slug', 'organisation', 'kind', 'headerVariant', 'locales', 'accountLabel', 'logo', 'frameAncestors']) + assert.equal(read('src/registry.js').includes('PortalDetailsForm'), false) })