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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions l10n/en.js

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

1 change: 1 addition & 0 deletions l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2977,6 +2977,7 @@
"Landing page": "Landing page",
"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.": "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.",
"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.": "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.",
"Choose a list": "Choose a list",
"nl": "nl",
"https://www.example.nl": "https://www.example.nl",
"Administration settings": "Administration settings"
Expand Down
1 change: 1 addition & 0 deletions l10n/nl.js

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

1 change: 1 addition & 0 deletions l10n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -3133,6 +3133,7 @@
"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.",
"Choose a list": "Kies een overzicht",
"nl": "nl",
"https://www.example.nl": "https://www.voorbeeld.nl",
"Administration settings": "Beheerinstellingen"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Screens

- Inloggen https://identity.conduction.nl/screens/board?id=portaliq/Inloggen
- PtStemgedrag https://identity.conduction.nl/screens/board?id=portaliq/PtStemgedrag
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ decidiq's half is `decidiq/portal-voting-record-and-confidential-papers`. Tasks
- Files: `src/site/components/PublicRecordsBlock.vue`, `src/site/components/WidgetGrid.vue`, the widget palette entry, the page editor's config form (list picker from the anonymous aggregate), the server-side block render for `site-honest-without-javascript`, `l10n/*.json`
- Done when: the list, the search, the record with `KpiCards` and `CollectionTable`, the back link and the stale-link text render per design.md D2, with and without JavaScript
- Test: `tests/public-records.spec.mjs` (node), then `tests/e2e/site-member-voting-record.spec.ts` against decidiq's seeded council
- Built: `PublicRecordsBlock.vue` (list, search, record with figure cards, table, note, back link, stale-link text), its widget registration and palette label, `tests/public-records.spec.mjs`. — not run: the block draws its own cards and table instead of `KpiCards` and `CollectionTable`, there is no config picker from the anonymous aggregate and no server-side render for readers without JavaScript; `tests/e2e/site-member-voting-record.spec.ts` needs a live instance with decidiq.
- Built: `PublicRecordsBlock.vue` (list, search, record, note, back link, stale-link text), its widget registration and palette label, `tests/public-records.spec.mjs`. The summary renders through `KpiCards.vue` and the rows through `CollectionTable.vue` (`recordFigures`, `recordTableCollection` in `src/site/lib/publicRecordsApi.js`; the table's link column takes its address from `hrefField: subjectUrl` and keeps only a site path or http(s)). The page editor picks the list from the anonymous aggregate (`src/editor/recordLists.js`, field kind `recordList` in `pageWidgetCatalogue.js`, `loadRecordLists` in `PageLayoutDesigner.vue`). (not run: the server-side render for readers without JavaScript waits on `site-honest-without-javascript`, which has none of its 16 tasks built and no `/site/plain` route; `tests/e2e/site-member-voting-record.spec.ts` is the live pass, decision 139.)

- [ ] **T4**: documents on any listable collection (REQ-SCR-002, REQ-SCR-007)
- [x] **T4**: documents on any listable collection (REQ-SCR-002, REQ-SCR-007)
- Files: `lib/Contribution/DocumentsProviderMethod.php`, `lib/Service/CollectionDocuments.php` (extracted), `lib/Service/CitizenCaseDocuments.php`, `lib/Controller/ContributionController.php`, `appinfo/routes.php` (`GET /portal/api/collections/{app}/{collection}/{id}/documents` and `/{documentId}`), `src/site/components/mijn/DocumentsBlock.vue` on the collection detail card
- Done when: the order in design.md D3 holds; a collection below the session's trust, an object outside the scope and an unlisted document are each 404; case documents keep working unchanged
- Test: PHPUnit `CollectionDocumentsTest`, the existing `CitizenCaseDocuments` tests still green, `tests/case-documents-screen` check unchanged
- Built: `CollectionDocuments`, `PortalCollectionDocumentsController` and the routes `GET /portal/api/collections/{register}/{schema}/{id}/documents` and `.../{documentId}` (register and schema as the other collection routes, not app and collection), `CollectionDocumentsTest` and `PortalCollectionDocumentsControllerTest`. `CitizenCaseDocuments` is untouched. — not run: the Documents section on the collection detail card (`DocumentsBlock.vue` still reads the case screen route).
- Built: `CollectionDocuments`, `PortalCollectionDocumentsController` and the routes `GET /portal/api/collections/{register}/{schema}/{id}/documents` and `.../{documentId}` (register and schema as the other collection routes, not app and collection), `CollectionDocumentsTest` and `PortalCollectionDocumentsControllerTest`. `CitizenCaseDocuments` is untouched. The Documents section: `DocumentsBlock.vue` reads a non-case collection through `fetchCollectionDocuments` and opens a paper through `downloadCollectionDocument` (`src/shared/portalApi.js`); a `kind: cases` collection keeps the case screen's route and its upload (`tests/mijn-documents.spec.mjs`, `tests/case-documents-screen.spec.mjs`).

- [x] **T5**: the `opened` hook, fail-closed (REQ-SCR-003)
- Files: `lib/Contribution/DocumentsProviderMethod.php`, `lib/Service/CollectionDocuments.php`, `l10n/*.json`
Expand Down
75 changes: 74 additions & 1 deletion src/editor/PageGridEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,26 @@
{{ block.title }}
</option>
</select>
<select
v-else-if="
field.kind === 'recordList'
&& recordLists.length > 0
"
:id="`field-${field.name}`"
class="page-grid-editor__input"
:data-testid="`designer-field-${field.name}`"
:value="recordListValue()"
@change="onRecordListInput($event.target.value)">
<option value="">
{{ t('portaliq', 'Choose a list') }}
</option>
<option
v-for="recordList in recordLists"
:key="`${recordList.app}/${recordList.id}`"
:value="`${recordList.app}/${recordList.id}`">
{{ recordList.label }} ({{ recordList.app }})
</option>
</select>
<input
v-else-if="field.kind === 'boolean'"
:id="`field-${field.name}`"
Expand Down Expand Up @@ -287,6 +307,7 @@ import { widgetLabel } from '../lib/widgetLabels.js'
import { historyIntent } from './editHistory.js'
import { cellFromDrop } from './geometry.js'
import { PALETTE_DRAG_TYPE } from './paletteDrag.js'
import { recordListChoice } from './recordLists.js'
import {
formWidgetFor,
inspectorModeFor,
Expand Down Expand Up @@ -339,12 +360,24 @@ export default {
* `() => Promise<Array<{id, title}>>`. Without it the shared block
* field is a text input for the block's id.
*
* @type {Function|null}
* @type {(() => Promise<Array<{id: string, title: string}>>)|null}
*/
loadBlocks: {
type: Function,
default: null,
},

/**
* Lists the public record lists the installed apps contribute:
* `() => Promise<Array<{app, id, label}>>`. Without it the list field
* is a text input for the list's id.
*
* @type {(() => Promise<Array<{app: string, id: string, label: string}>>)|null}
*/
loadRecordLists: {
type: Function,
default: null,
},
},

emits: ['update:paletteOpen'],
Expand All @@ -370,6 +403,14 @@ export default {
* @type {Array<{id: string, title: string}>}
*/
blocks: [],

/**
* The public record lists an author can place, as `{app, id, label}`;
* empty until read, and when the host cannot list them.
*
* @type {Array<{app: string, id: string, label: string}>}
*/
recordLists: [],
}
},

Expand Down Expand Up @@ -471,6 +512,13 @@ export default {
this.blocks = []
}
}
if (this.loadRecordLists) {
try {
this.recordLists = await this.loadRecordLists()
} catch {
this.recordLists = []
}
}
},

beforeUnmount() {
Expand Down Expand Up @@ -674,6 +722,31 @@ export default {
return value === undefined ? '' : String(value)
},

/**
* The chosen record list as `app/list`, '' when none is chosen.
*
* @return {string} The select's value.
* @spec openspec/changes/site-member-voting-record-and-confidential-papers/tasks.md#t3
*/
recordListValue() {
const props = this.selected?.props || {}
return props.app && props.list ? `${props.app}/${props.list}` : ''
},

/**
* Choose a record list: it sets both the app and the list, so the two
* can never point at different contributions.
*
* @param {string} value The option's `app/list`, or ''.
* @return {void}
* @spec openspec/changes/site-member-voting-record-and-confidential-papers/tasks.md#t3
*/
onRecordListInput(value) {
const { app, list } = recordListChoice(this.recordLists, value)
this.editor.setProp('app', app)
this.editor.setProp('list', list)
},

/**
* Take a typed value. A JSON field that does not parse is not written and
* says so: a broken string in `cards` renders as nothing on the page.
Expand Down
1 change: 1 addition & 0 deletions src/editor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export {
renamePagePayload,
} from './pageTree.js'
export { createPortalObjects } from './portalObjects.js'
export { recordListChoice, recordListsFrom } from './recordLists.js'
export {
formWidgetFor,
inspectorModeFor,
Expand Down
53 changes: 53 additions & 0 deletions src/editor/recordLists.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
* SPDX-License-Identifier: EUPL-1.2
*
* The page editor's picker for a public records block
* (site-member-voting-record-and-confidential-papers, design.md D2). Free of
* Vue so the node tests drive it directly.
*/

/**
* The public record lists an author can place, from the anonymous
* contributions answer (`GET /api/content/contributions`).
*
* @param {object} answer The answer's body.
* @return {Array<{app: string, id: string, label: string}>} The lists.
* @spec openspec/changes/site-member-voting-record-and-confidential-papers/tasks.md#t3
*/
export function recordListsFrom(answer) {
const lists = []
for (const contribution of Array.isArray(answer?.contributions)
? answer.contributions
: []) {
for (const entry of Array.isArray(contribution?.publicRecords)
? contribution.publicRecords
: []) {
const app = String(entry?.app || contribution?.app || '')
const id = String(entry?.id || '')
if (app !== '' && id !== '') {
lists.push({ app, id, label: String(entry?.label || id) })
}
}
}

return lists
}

/**
* The props a chosen list sets: both the app and the list, so the two never
* point at different contributions. An unknown choice clears both.
*
* @param {Array<{app: string, id: string}>} lists The lists offered.
* @param {string} value The option's `app/list`, or ''.
* @return {{app: (string|undefined), list: (string|undefined)}} The props.
* @spec openspec/changes/site-member-voting-record-and-confidential-papers/tasks.md#t3
*/
export function recordListChoice(lists, value) {
const chosen = (Array.isArray(lists) ? lists : []).find(
(recordList) => `${recordList.app}/${recordList.id}` === value,
)
return chosen
? { app: chosen.app, list: chosen.id }
: { app: undefined, list: undefined }
}
15 changes: 14 additions & 1 deletion src/lib/pageWidgetCatalogue.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ const FIELD_OVERRIDES = {
],
}

/**
* Fields that keep their place among a widget's own props but get a picker
* instead of a text input. `recordList` lists the public record lists the
* installed apps contribute; choosing one sets both `list` and `app`
* (site-member-voting-record-and-confidential-papers, design.md D2).
*
* @type {Record<string, Record<string, string>>}
*/
const KIND_OVERRIDES = {
publicRecords: { list: 'recordList' },
}

/**
* Components the SITE loads lazily and this designer does not.
*
Expand Down Expand Up @@ -306,7 +318,8 @@ export function fieldsFor(key) {
.filter((name) => !hidden.includes(name) && !NEVER_EDITABLE.includes(name))
.map((name) => ({
name,
kind: kindFor(name, component.props[name]),
kind:
KIND_OVERRIDES[key]?.[name] || kindFor(name, component.props[name]),
label: humanise(name),
}))
}
Expand Down
96 changes: 75 additions & 21 deletions src/shared/portalApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,38 @@ export function createPortalApi(config, store = {}) {
const citizenCase = (register, schema) =>
`/citizen/cases/${encodeURIComponent(register)}/${encodeURIComponent(schema)}`

/**
* Fetch one listed document and save it client-side through a Blob URL,
* as downloadFile().
*
* @param {string} url The document route.
* @param {object} entry The listed entry: `{ id, title }`.
* @return {Promise<object>} `{ ok }`, or `{ ok: false, status }`.
* @spec openspec/specs/citizen-case-documents/spec.md#requirement-every-listed-document-opens-from-the-case-screen-req-cdc-002
*/
async function saveDocument(url, entry) {
try {
const res = await fetch(url, { headers: { ...authHeaders() } })
if (!res.ok) {
return { ok: false, status: res.status }
}
const blob = await res.blob()
const objectUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = entry.title || 'download'
document.body.appendChild(link)
link.click()
setTimeout(() => {
link.remove()
window.URL.revokeObjectURL(objectUrl)
}, 10000)
return { ok: true }
} catch {
return { ok: false, status: 0 }
}
}

return {
/**
* Resolve the current session (fail-closed). Returns the session object
Expand Down Expand Up @@ -1271,27 +1303,49 @@ export function createPortalApi(config, store = {}) {
* @spec openspec/specs/citizen-case-documents/spec.md#requirement-every-listed-document-opens-from-the-case-screen-req-cdc-002
*/
async downloadCitizenDocument(collection, caseId, entry) {
const url = `${base}${citizenCase(collection.register, collection.schema)}/${encodeURIComponent(caseId)}/documents/${encodeURIComponent(entry.id)}`
try {
const res = await fetch(url, { headers: { ...authHeaders() } })
if (!res.ok) {
return { ok: false, status: res.status }
}
const blob = await res.blob()
const objectUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = entry.title || 'download'
document.body.appendChild(link)
link.click()
setTimeout(() => {
link.remove()
window.URL.revokeObjectURL(objectUrl)
}, 10000)
return { ok: true }
} catch {
return { ok: false, status: 0 }
}
return saveDocument(
`${base}${citizenCase(collection.register, collection.schema)}/${encodeURIComponent(caseId)}/documents/${encodeURIComponent(entry.id)}`,
entry,
)
},

/**
* The documents one object of any collection lists, for a collection
* that declares `documents` and is not a case collection. The server
* finds the collection in the resident's own aggregate and reads the
* object within its scope, so a collection above the session's trust,
* an object outside the scope and an unknown id are all null here.
*
* @param {object} collection Manifest collection: `{ register, schema }`.
* @param {string} id The object id.
* @return {Promise<object|null>} `{ documents, documentsLabel }` or null.
* @spec openspec/changes/site-member-voting-record-and-confidential-papers/tasks.md#t4
*/
async fetchCollectionDocuments(collection, id) {
const answer = await get(
`${col(collection.register, collection.schema)}/${encodeURIComponent(id)}/documents`,
)
return answer && Array.isArray(answer.documents)
? { documents: answer.documents, documentsLabel: answer.label || '' }
: null
},

/**
* Open one document `fetchCollectionDocuments` listed. Only the entry
* id travels; the server looks it up again, runs the collection's
* `opened` hook when it has one, and answers 503 when that says no.
*
* @param {object} collection Manifest collection: `{ register, schema }`.
* @param {string} id The object id.
* @param {object} entry The listed entry: `{ id, title }`.
* @return {Promise<object>} `{ ok }`, or `{ ok: false, status }`.
* @spec openspec/changes/site-member-voting-record-and-confidential-papers/tasks.md#t4
*/
async downloadCollectionDocument(collection, id, entry) {
return saveDocument(
`${base}${col(collection.register, collection.schema)}/${encodeURIComponent(id)}/documents/${encodeURIComponent(entry.id)}`,
entry,
)
},

/**
Expand Down
Loading
Loading