@conduction/nextcloud-vue 2.67.0 → 2.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js +37 -26
  2. package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js.map +1 -1
  3. package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js +3 -3
  4. package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js.map +1 -1
  5. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +21 -35
  6. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  7. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +17 -0
  8. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  9. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +40 -3
  10. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +58 -16
  12. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +87 -18
  14. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  15. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +6 -1
  16. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  17. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +58 -48
  18. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  19. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  20. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnIndexPage/defaultActions.js +6 -7
  22. package/dist/esm/components/CnIndexPage/defaultActions.js.map +1 -1
  23. package/dist/esm/components/CnRowActions/CnRowActions.vue2.js +33 -34
  24. package/dist/esm/components/CnRowActions/CnRowActions.vue2.js.map +1 -1
  25. package/dist/esm/components/CnRowActions/CnRowActions.vue3.js +3 -3
  26. package/dist/esm/components/CnRowActions/CnRowActions.vue3.js.map +1 -1
  27. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +8 -2
  28. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  29. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  30. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +2 -2
  31. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  32. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  33. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +70 -1
  34. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  35. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +23 -3
  36. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  37. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +3 -2
  38. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -1
  39. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -1
  40. package/dist/esm/composables/useContextMenu.js +4 -2
  41. package/dist/esm/composables/useContextMenu.js.map +1 -1
  42. package/dist/esm/l10n/en.json.js +3 -0
  43. package/dist/esm/l10n/en.json.js.map +1 -1
  44. package/dist/esm/l10n/nl.json.js +3 -0
  45. package/dist/esm/l10n/nl.json.js.map +1 -1
  46. package/dist/esm/mixins/pageViews.js +278 -11
  47. package/dist/esm/mixins/pageViews.js.map +1 -1
  48. package/dist/esm/store/plugins/dashboardLayouts.js +35 -1
  49. package/dist/esm/store/plugins/dashboardLayouts.js.map +1 -1
  50. package/dist/esm/utils/formatMetric.js +137 -16
  51. package/dist/esm/utils/formatMetric.js.map +1 -1
  52. package/dist/esm/utils/resolveRowActions.js +130 -0
  53. package/dist/esm/utils/resolveRowActions.js.map +1 -0
  54. package/dist/esm/utils/rowActionItem.js +104 -0
  55. package/dist/esm/utils/rowActionItem.js.map +1 -0
  56. package/dist/esm/utils/timelineEvents.js +44 -1
  57. package/dist/esm/utils/timelineEvents.js.map +1 -1
  58. package/dist/esm/utils/validateManifest.js +59 -3
  59. package/dist/esm/utils/validateManifest.js.map +1 -1
  60. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  61. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  62. package/dist/nextcloud-vue.cjs.js +1251 -232
  63. package/dist/nextcloud-vue.cjs.js.map +2 -2
  64. package/l10n/en.json +3 -0
  65. package/l10n/nl.json +3 -0
  66. package/package.json +1 -1
  67. package/scripts/.jsdoc-baselines.json +2 -2
  68. package/src/components/CnContextMenu/CnContextMenu.vue +40 -29
  69. package/src/components/CnDashboardPage/CnDashboardPage.md +2 -0
  70. package/src/components/CnDashboardPage/CnDashboardPage.vue +34 -35
  71. package/src/components/CnDetailPage/CnDetailPage.md +6 -0
  72. package/src/components/CnDetailPage/CnDetailPage.vue +67 -5
  73. package/src/components/CnFormDialog/CnFormDialog.vue +92 -18
  74. package/src/components/CnIndexPage/CnIndexPage.md +21 -0
  75. package/src/components/CnIndexPage/CnIndexPage.vue +61 -51
  76. package/src/components/CnIndexPage/defaultActions.js +6 -7
  77. package/src/components/CnRowActions/CnRowActions.vue +36 -37
  78. package/src/components/CnSetupWizard/CnSetupWizard.vue +8 -2
  79. package/src/components/CnStatWidget/CnStatWidget.vue +2 -2
  80. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +82 -3
  81. package/src/components/CnTimelineWidget/CnTimelineWidget.vue +3 -2
  82. package/src/composables/useContextMenu.js +4 -2
  83. package/src/mixins/pageViews.js +277 -10
  84. package/src/schemas/app-manifest-v2.schema.json +73 -9
  85. package/src/store/plugins/dashboardLayouts.js +34 -0
  86. package/src/utils/formatMetric.js +143 -15
  87. package/src/utils/resolveRowActions.js +127 -0
  88. package/src/utils/rowActionItem.js +100 -0
  89. package/src/utils/timelineEvents.js +43 -0
  90. package/src/utils/validateManifest.js +60 -3
  91. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"CnTimelineWidget.vue3.js","sources":["../../../../src/components/CnTimelineWidget/CnTimelineWidget.vue?vue&type=template&id=621fcccf&lang.js"],"sourcesContent":["<!-- SPDX-License-Identifier: EUPL-1.2 -->\n<!-- SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl> -->\n<template>\n\t<section class=\"cn-timeline-widget\" data-testid=\"cn-timeline-widget\" :aria-busy=\"loading ? 'true' : 'false'\">\n\t\t<h3 v-if=\"resolvedTitle\" class=\"cn-timeline-widget__title\">\n\t\t\t{{ resolvedTitle }}\n\t\t</h3>\n\n\t\t<div v-if=\"loading && events.length === 0\" class=\"cn-timeline-widget__loading\">\n\t\t\t<NcLoadingIcon :size=\"24\" aria-hidden=\"true\" />\n\t\t\t<span>{{ loadingLabel }}</span>\n\t\t</div>\n\n\t\t<template v-else>\n\t\t\t<!-- A failed read is not an empty history: say which part is missing. -->\n\t\t\t<NcNoteCard v-if=\"failedSources.length > 0\" type=\"warning\" data-testid=\"cn-timeline-widget-error\">\n\t\t\t\t{{ failedText }}\n\t\t\t</NcNoteCard>\n\n\t\t\t<p v-if=\"events.length === 0\" class=\"cn-timeline-widget__empty\" data-testid=\"cn-timeline-widget-empty\">\n\t\t\t\t{{ emptyLabel }}\n\t\t\t</p>\n\n\t\t\t<ol v-else class=\"cn-timeline-widget__list\">\n\t\t\t\t<li\n\t\t\t\t\tv-for=\"event in events\"\n\t\t\t\t\t:key=\"event.id\"\n\t\t\t\t\tclass=\"cn-timeline-widget__event\"\n\t\t\t\t\t:class=\"{ 'cn-timeline-widget__event--upcoming': event.upcoming }\"\n\t\t\t\t\t:data-source=\"event.source\"\n\t\t\t\t\tdata-testid=\"cn-timeline-widget-event\">\n\t\t\t\t\t<span class=\"cn-timeline-widget__dot\" aria-hidden=\"true\" />\n\t\t\t\t\t<div class=\"cn-timeline-widget__body\">\n\t\t\t\t\t\t<time class=\"cn-timeline-widget__when\" :datetime=\"event.at.toISOString()\">\n\t\t\t\t\t\t\t{{ formatMoment(event) }}\n\t\t\t\t\t\t</time>\n\t\t\t\t\t\t<span v-if=\"event.upcoming\" class=\"cn-timeline-widget__badge\">\n\t\t\t\t\t\t\t{{ upcomingLabel }}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<p class=\"cn-timeline-widget__label\">\n\t\t\t\t\t\t\t{{ translate(event.label) }}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p v-if=\"event.detail\" class=\"cn-timeline-widget__detail\">\n\t\t\t\t\t\t\t{{ event.detail }}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t</li>\n\t\t\t</ol>\n\t\t</template>\n\t</section>\n</template>\n\n<script>\nimport { getLanguage, translate as t } from '@nextcloud/l10n'\nimport { NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'\nimport { auditEvents, fieldEvents, relatedEvents, sortEvents, timelineEntryEvents } from '../../utils/timelineEvents.js'\n\n/**\n * CnTimelineWidget — an object's dated events in time order (`timeline`\n * widget type for detail pages).\n *\n * Generalises dossiq's case timeline. It merges up to four sources, all\n * optional, into one list:\n *\n * - `fields`: the object's own date properties, `[{ field, label }]`. A\n * dotted path reaches metadata, such as `@self.created`.\n * - `related`: related objects, `[{ register?, schema, field, dateField?,\n * label, titleField? }]`. Rows of `schema` whose `field` holds this object's\n * id become events at their `dateField` (default `@self.created`).\n * - `auditTrail: true`: the object's audit trail, one event per change.\n * - `timeline: true`: OpenRegister's timeline of notes, calls and messages.\n *\n * Oldest first by default (`order: 'desc'` flips it). A moment after now is\n * marked upcoming, in words as well as style.\n *\n * ```json\n * { \"type\": \"timeline\", \"content\": {\n * \"title\": \"Timeline\",\n * \"fields\": [\n * { \"field\": \"@self.created\", \"label\": \"Booking created\" },\n * { \"field\": \"depositClearedAt\", \"label\": \"Deposit cleared\" },\n * { \"field\": \"confirmationSentAt\", \"label\": \"Confirmation mail sent\" },\n * { \"field\": \"startsAt\", \"label\": \"Starts\" },\n * { \"field\": \"endsAt\", \"label\": \"Ends\" }\n * ] } }\n * ```\n *\n * The object comes from the detail page's context, like `audit-trail`:\n * explicit props win, then the injects, then `content`.\n *\n * @spec openspec/changes/timeline-widget/specs/timeline-widget/spec.md#requirement-a-timeline-widget-shows-an-objects-dated-events-in-order\n */\nexport default {\n\tname: 'CnTimelineWidget',\n\n\tcomponents: {\n\t\tNcLoadingIcon,\n\t\tNcNoteCard,\n\t},\n\n\tinject: {\n\t\t/** Detail-page object context from CnDetailPage (a ref or a raw bag). */\n\t\tcnObjectContext: { default: null },\n\t\t/** Detail object context holder from CnPageRenderer (`{ value: { objectData, ... } }`). */\n\t\tcnDetailObjectContext: { default: null },\n\t\t/** Consumer translation function; labels are authored in English. */\n\t\tcnTranslate: { default: () => (key) => key },\n\t},\n\n\tprops: {\n\t\t/** Register slug; object context spread as props by CnWidgetGrid. */\n\t\tregister: { type: String, default: '' },\n\t\t/**\n\t\t * Schema slug, or the schema object the detail context supplies.\n\t\t *\n\t\t * @type {string|object}\n\t\t */\n\t\tschema: { type: [String, Object], default: '' },\n\t\t/** The object's id. */\n\t\tobjectId: { type: String, default: '' },\n\t\t/** The object itself, when the host already has it. */\n\t\tobject: { type: Object, default: null },\n\t\t/** Card title override. */\n\t\ttitle: { type: String, default: '' },\n\t\t/** Stored widget content: `{ title, fields, related, auditTrail, timeline, order }`. */\n\t\tcontent: { type: Object, default: () => ({}) },\n\t\t/** OpenRegister API base. */\n\t\tapiBase: { type: String, default: '/apps/openregister/api' },\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\tloading: false,\n\t\t\trelatedRows: {},\n\t\t\tauditEntries: [],\n\t\t\ttimelineEntries: [],\n\t\t\tfailedSources: [],\n\t\t\tfetchedObject: null,\n\t\t\tloadSeq: 0,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\tctx() {\n\t\t\tconst raw = this.cnObjectContext\n\t\t\tconst inj = raw && (raw.value !== undefined ? raw.value : raw)\n\t\t\tconst holder = this.cnDetailObjectContext && this.cnDetailObjectContext.value\n\t\t\treturn inj || holder || {}\n\t\t},\n\n\t\tresolvedObjectId() {\n\t\t\treturn this.objectId || this.ctx.objectId || this.content.objectId || ''\n\t\t},\n\n\t\tresolvedRegister() {\n\t\t\treturn this.register || this.ctx.register || this.content.register || ''\n\t\t},\n\n\t\tresolvedSchema() {\n\t\t\tconst s = this.schema || this.ctx.schema || this.content.schema || ''\n\t\t\treturn typeof s === 'string' ? s : (s && (s.slug || s.name || s.id)) || ''\n\t\t},\n\n\t\t/**\n\t\t * The object: explicit prop, then the page context, then a fetch of\n\t\t * our own when only the id is known.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tresolvedObject() {\n\t\t\treturn this.object || this.ctx.object || this.ctx.objectData || this.fetchedObject || null\n\t\t},\n\n\t\tresolvedTitle() {\n\t\t\tconst raw = this.title || this.content.title || ''\n\t\t\treturn raw ? this.translate(raw) : ''\n\t\t},\n\n\t\tfieldsConfig() {\n\t\t\treturn Array.isArray(this.content.fields) ? this.content.fields : []\n\t\t},\n\n\t\trelatedConfig() {\n\t\t\treturn Array.isArray(this.content.related) ? this.content.related.filter((r) => r && r.schema && r.field) : []\n\t\t},\n\n\t\t/**\n\t\t * Every event from every source, in time order.\n\t\t *\n\t\t * @return {Array<object>}\n\t\t */\n\t\tevents() {\n\t\t\tconst now = new Date()\n\t\t\tconst all = [\n\t\t\t\t...fieldEvents(this.resolvedObject || {}, this.fieldsConfig, now),\n\t\t\t\t...this.relatedConfig.flatMap((cfg, i) => relatedEvents(this.relatedRows[i] || [], cfg, now)),\n\t\t\t\t...auditEvents(this.auditEntries, this.describeAudit, now),\n\t\t\t\t...timelineEntryEvents(this.timelineEntries, t('nextcloud-vue', 'Note'), now),\n\t\t\t]\n\t\t\treturn sortEvents(all, this.content.order === 'desc' ? 'desc' : 'asc')\n\t\t},\n\n\t\t/**\n\t\t * A signature of what to fetch, so a change refetches once.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tloadKey() {\n\t\t\treturn JSON.stringify({\n\t\t\t\tid: this.resolvedObjectId,\n\t\t\t\tregister: this.resolvedRegister,\n\t\t\t\tschema: this.resolvedSchema,\n\t\t\t\trelated: this.relatedConfig,\n\t\t\t\taudit: this.content.auditTrail === true,\n\t\t\t\ttimeline: this.content.timeline === true,\n\t\t\t\tneedObject: this.fieldsConfig.length > 0 && !(this.object || this.ctx.object || this.ctx.objectData),\n\t\t\t})\n\t\t},\n\n\t\tfailedText() {\n\t\t\treturn t('nextcloud-vue', 'Part of the timeline could not load: {parts}.', {\n\t\t\t\tparts: this.failedSources.map((s) => this.sourceName(s)).join(', '),\n\t\t\t})\n\t\t},\n\n\t\tloadingLabel() {\n\t\t\treturn t('nextcloud-vue', 'Loading…')\n\t\t},\n\n\t\temptyLabel() {\n\t\t\treturn t('nextcloud-vue', 'Nothing has happened yet.')\n\t\t},\n\n\t\tupcomingLabel() {\n\t\t\treturn t('nextcloud-vue', 'Upcoming')\n\t\t},\n\t},\n\n\twatch: {\n\t\tloadKey: {\n\t\t\timmediate: true,\n\t\t\thandler() {\n\t\t\t\tthis.load()\n\t\t\t},\n\t\t},\n\t},\n\n\tmethods: {\n\t\ttranslate(text) {\n\t\t\treturn text ? this.cnTranslate(text) : ''\n\t\t},\n\n\t\tsourceName(source) {\n\t\t\tif (source === 'audit') {\n\t\t\t\treturn t('nextcloud-vue', 'changes')\n\t\t\t}\n\t\t\tif (source === 'timeline') {\n\t\t\t\treturn t('nextcloud-vue', 'notes and messages')\n\t\t\t}\n\t\t\tif (source === 'object') {\n\t\t\t\treturn t('nextcloud-vue', 'dates')\n\t\t\t}\n\t\t\treturn t('nextcloud-vue', 'related items')\n\t\t},\n\n\t\t/**\n\t\t * The label of an audit event, from its action and who did it.\n\t\t *\n\t\t * @param {string} action The audit action (create, update, delete).\n\t\t * @param {string} actor Who did it.\n\t\t * @return {string}\n\t\t */\n\t\tdescribeAudit(action, actor) {\n\t\t\tconst known = {\n\t\t\t\tcreate: t('nextcloud-vue', 'Created'),\n\t\t\t\tupdate: t('nextcloud-vue', 'Updated'),\n\t\t\t\tdelete: t('nextcloud-vue', 'Deleted'),\n\t\t\t}\n\t\t\tconst what = known[action] || action || t('nextcloud-vue', 'Changed')\n\t\t\treturn actor ? t('nextcloud-vue', '{action} by {actor}', { action: what, actor }) : what\n\t\t},\n\n\t\t/**\n\t\t * Show a moment in the reader's language: a date for a calendar day,\n\t\t * date and time otherwise.\n\t\t *\n\t\t * @param {object} event The event.\n\t\t * @return {string}\n\t\t */\n\t\tformatMoment(event) {\n\t\t\tconst locale = (typeof getLanguage === 'function' && getLanguage()) || undefined\n\t\t\tconst options = event.dateOnly\n\t\t\t\t? { dateStyle: 'medium' }\n\t\t\t\t: { dateStyle: 'medium', timeStyle: 'short' }\n\t\t\ttry {\n\t\t\t\treturn new Intl.DateTimeFormat(locale, options).format(event.at)\n\t\t\t} catch {\n\t\t\t\treturn event.at.toLocaleString()\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Fetch every configured source. Each one fails on its own, so one\n\t\t * unreachable source never hides the others.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t * @spec openspec/changes/timeline-widget/specs/timeline-widget/spec.md#requirement-a-timeline-widget-shows-an-objects-dated-events-in-order\n\t\t */\n\t\tasync load() {\n\t\t\tconst id = this.resolvedObjectId\n\t\t\tconst register = this.resolvedRegister\n\t\t\tconst schema = this.resolvedSchema\n\t\t\tconst seq = ++this.loadSeq\n\t\t\tif (!id || !register || !schema) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([\n\t\t\t\timport('@nextcloud/axios'),\n\t\t\t\timport('@nextcloud/router'),\n\t\t\t])\n\t\t\tconst base = `${this.apiBase}/objects/${register}/${schema}/${id}`\n\t\t\tconst failed = []\n\t\t\tconst jobs = []\n\n\t\t\tif (this.fieldsConfig.length > 0 && !(this.object || this.ctx.object || this.ctx.objectData)) {\n\t\t\t\tjobs.push(axios.get(generateUrl(base))\n\t\t\t\t\t.then((r) => {\n\t\t\t\t\t\tthis.fetchedObject = (r && r.data) || null\n\t\t\t\t\t})\n\t\t\t\t\t.catch(() => failed.push('object')))\n\t\t\t}\n\n\t\t\tconst rows = {}\n\t\t\tthis.relatedConfig.forEach((cfg, i) => {\n\t\t\t\tconst url = generateUrl(`${this.apiBase}/objects/${cfg.register || register}/${cfg.schema}`)\n\t\t\t\tjobs.push(axios.get(url, { params: { [cfg.field]: id, _limit: cfg.limit || 50 } })\n\t\t\t\t\t.then((r) => {\n\t\t\t\t\t\tconst data = r && r.data\n\t\t\t\t\t\trows[i] = (data && (data.results || data)) || []\n\t\t\t\t\t})\n\t\t\t\t\t.catch(() => {\n\t\t\t\t\t\trows[i] = []\n\t\t\t\t\t\tif (!failed.includes('related')) {\n\t\t\t\t\t\t\tfailed.push('related')\n\t\t\t\t\t\t}\n\t\t\t\t\t}))\n\t\t\t})\n\n\t\t\tif (this.content.auditTrail === true) {\n\t\t\t\tjobs.push(axios.get(generateUrl(`${base}/audit-trail`), { params: { limit: this.content.auditLimit || 50 } })\n\t\t\t\t\t.then((r) => {\n\t\t\t\t\t\tconst data = r && r.data\n\t\t\t\t\t\tthis.auditEntries = (data && (data.results || data)) || []\n\t\t\t\t\t})\n\t\t\t\t\t.catch(() => {\n\t\t\t\t\t\tthis.auditEntries = []\n\t\t\t\t\t\tfailed.push('audit')\n\t\t\t\t\t}))\n\t\t\t}\n\n\t\t\tif (this.content.timeline === true) {\n\t\t\t\tjobs.push(axios.get(generateUrl(`${base}/timeline`))\n\t\t\t\t\t.then((r) => {\n\t\t\t\t\t\tconst data = r && r.data\n\t\t\t\t\t\tthis.timelineEntries = (data && (data.results || data)) || []\n\t\t\t\t\t})\n\t\t\t\t\t.catch(() => {\n\t\t\t\t\t\tthis.timelineEntries = []\n\t\t\t\t\t\tfailed.push('timeline')\n\t\t\t\t\t}))\n\t\t\t}\n\n\t\t\tif (jobs.length === 0) {\n\t\t\t\tthis.failedSources = []\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.loading = true\n\t\t\tawait Promise.all(jobs)\n\t\t\tif (seq !== this.loadSeq) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.relatedRows = rows\n\t\t\tthis.failedSources = failed\n\t\t\tthis.loading = false\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-timeline-widget {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 8px;\n}\n\n.cn-timeline-widget__title {\n\tmargin: 0;\n\tfont-size: var(--font-size-large, 1.1em);\n}\n\n.cn-timeline-widget__loading {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 8px;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-timeline-widget__empty {\n\tmargin: 0;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-timeline-widget__list {\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.cn-timeline-widget__event {\n\tposition: relative;\n\tdisplay: flex;\n\tgap: 12px;\n\tpadding-block-end: 12px;\n}\n\n/* The line between dots: drawn from each dot down to the next event. */\n.cn-timeline-widget__event:not(:last-child)::before {\n\tcontent: '';\n\tposition: absolute;\n\tinset-block: 14px 0;\n\tinset-inline-start: 5px;\n\tborder-inline-start: 2px solid var(--color-border-dark);\n}\n\n.cn-timeline-widget__dot {\n\tflex: 0 0 auto;\n\twidth: 12px;\n\theight: 12px;\n\tmargin-block-start: 4px;\n\tborder-radius: 50%;\n\tbackground-color: var(--color-primary-element);\n}\n\n/* Upcoming: a hollow dot, plus the word \"Upcoming\" beside the date. */\n.cn-timeline-widget__event--upcoming .cn-timeline-widget__dot {\n\tbox-sizing: border-box;\n\tborder: 2px solid var(--color-primary-element);\n\tbackground-color: var(--color-main-background);\n}\n\n.cn-timeline-widget__body {\n\tmin-width: 0;\n}\n\n.cn-timeline-widget__when {\n\tfont-size: var(--font-size-small, 13px);\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-timeline-widget__badge {\n\tmargin-inline-start: 8px;\n\tfont-size: var(--font-size-small, 13px);\n\tfont-weight: bold;\n\tcolor: var(--color-main-text);\n}\n\n.cn-timeline-widget__label {\n\tmargin: 0;\n\tfont-weight: bold;\n}\n\n.cn-timeline-widget__detail {\n\tmargin: 0;\n\toverflow-wrap: anywhere;\n}\n</style>\n"],"names":["_createElementBlock","_toDisplayString","_createVNode","_createBlock","_openBlock","_Fragment","_renderList","_normalizeClass","_createElementVNode"],"mappings":";;;;;AAMM,EAAA,KAAA,EAAA,2BAAA;;;;AAI2B,EAAA,KAAA,EAAA,6BAAA;;;;AAW3B,EAAA,KAAA,EAAA,2BAAA;AAAA,EAAA,aAAA,EAAA,0BAAA;;;;AAIQ,EAAA,KAAK,EAAE,0BAAM;;;AASjB,MAAA,UAAA,GAAA,EAAA,KAAK,EAAC,0BAAe,GAAA;;;;AAInB,EAAA,KAAA,EAAA,2BAAA;;AAGH,MAAA,WAAA,GAAA,EAAA,KAAA,EAAA,2BAAA,GAAA;;;AAGA,EAAA,KAAA,EAAA,4BAAA;;;;;;;uBAvCNA;AAAO,IAAA,KAAK,EAAC,oBAAK;AAAA,IAAA,aAAA,EAAA,oBAAA;AAAA,IAAA,WAAA,EAAA,KAAA,CAAA,OAAA,GAAA,MAAA,GAAA,OAAA;;KACf,QAAA,CAAA,aAAA;sBAAJA,kBAEK,CAAA,IAAA,EAFL,UAEK,EAAAC,eAAA;;KAEM,KAAO,CAAA,OAAA,IAAK,QAAK,CAAA,MAAA,CAAC,MAAE,KAAA,CAAA;yCAGzB,iBAAA,EAAA;UAFLC,WAAI,CAAA,wBAAA,EAAA;AAAA,YAAA,IAAA,EAAA,EAAA;AAAA,YAAA,aAAA,EAAA,MAAA;;;;sBAIJF;AAEY,UAAA,CAAA,KAAA,CAAA,aAAA,CAAA,MAAA,GAAA,CAAA;4BAAZG,WAEa,CAAA,qBAAA,EAAA;;AAFD,gBAAA,IAAA,EAAA,SAAA;AAAA,gBAAA,aAAA,EAAA,0BAAA;;;;;;;;AAIT,UAAA,CAAA,QAAA,CAAA,MAAA,CAAA,MAAA,KAAA,CAAA;4BAAHH,kBAEI,CAAA,GAAA,EAFJ,UAEI,EAAAC,eAAA;AAEF,eAAAG,SAAA,EAAA,EAAAJ,kBAAA,CAwBG,MAxBH,UAwBG,EAAA;kCAvBHA,kBAsBI,CAAAK,QAAA,EAAA,IAAA,EAAAC,UAAA,CArBY,QAAM,CAAA,MAAA,EAAA,CAAf,KAAK,KAAA;uCADZN,kBAsBI,CAAA,IAAA,EAAA;oBApBH,GAAG,EAAE,KAAK,CAAC,EAAE;AACd,oBAAA,KAAK,EAACO,cAAA,CAAA,CAAA,2BAAoB,EACY,EAAA,qCAAA,EAAA,KAAA,CAAA,QAAA,EAAA,CAAA,CAAA;oBACrC,aAAW,EAAE,KAAK,CAAC,MAAM;AAC1B,oBAAA,aAAW,EAAC,0BAAyB;;8CACpCC,kBAA0D,CAAA,MAAA,EAAA;AAAtD,sBAAA,KAAM,EAAC,yBAAyB;AAAC,sBAAA,aAAW,EAAC,MAAM;;uCActD,iBAAA,EAAA;sBAZDA,kBAEO,CAAA,MAAA,EAAA;AAFD,wBAAA,KAAA,EAAA,0BAAA;AAAA,wBAAA,QAAA,EAAA,KAAA,CAAA,EAAA,CAAA,WAAA,EAAA;AACF,uBAAA,EAAAP,eAAA,CAAA,QAAA,CAAA,YAAY,CAAC,KAAK,CAAA,CAAA,EAAA,CAAA,oBAAA,UAAA,CAAA;AAEhB,sBAAA,CAAA,KAAA,CAAA,QAAA;wCAAND,kBAEO,CAAA,MAAA,EAFP,UAEO,EAAAC,eAAA,CADH,QAAa,CAAA,aAAA,CAAA,EAAA,CAAA,YAAA;;AAEjB,sBAAAO,kBAAA,CAEI,KAFJ,WAEI,EAAAP,eAAA,CADA,kBAAS,CAAC,KAAK,CAAC,KAAK,CAAA,CAAA,EAAA,CAAA,YAAA;AAEtB,sBAAA,CAAA,KAAA,CAAA,MAAA;AAAH,2BAAAG,SAAA,EAAA,EAAAJ,kBAAA,CAEE,GAFF,EAAA,WAEE,EADEC,eAAA,CAAA,KAAA,CAAA,MAAA,CAAA,EAAA,CAAA,YAAA;;;;;;;;;;;;"}
1
+ {"version":3,"file":"CnTimelineWidget.vue3.js","sources":["../../../../src/components/CnTimelineWidget/CnTimelineWidget.vue?vue&type=template&id=621fcccf&lang.js"],"sourcesContent":["<!-- SPDX-License-Identifier: EUPL-1.2 -->\n<!-- SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl> -->\n<template>\n\t<section class=\"cn-timeline-widget\" data-testid=\"cn-timeline-widget\" :aria-busy=\"loading ? 'true' : 'false'\">\n\t\t<h3 v-if=\"resolvedTitle\" class=\"cn-timeline-widget__title\">\n\t\t\t{{ resolvedTitle }}\n\t\t</h3>\n\n\t\t<div v-if=\"loading && events.length === 0\" class=\"cn-timeline-widget__loading\">\n\t\t\t<NcLoadingIcon :size=\"24\" aria-hidden=\"true\" />\n\t\t\t<span>{{ loadingLabel }}</span>\n\t\t</div>\n\n\t\t<template v-else>\n\t\t\t<!-- A failed read is not an empty history: say which part is missing. -->\n\t\t\t<NcNoteCard v-if=\"failedSources.length > 0\" type=\"warning\" data-testid=\"cn-timeline-widget-error\">\n\t\t\t\t{{ failedText }}\n\t\t\t</NcNoteCard>\n\n\t\t\t<p v-if=\"events.length === 0\" class=\"cn-timeline-widget__empty\" data-testid=\"cn-timeline-widget-empty\">\n\t\t\t\t{{ emptyLabel }}\n\t\t\t</p>\n\n\t\t\t<ol v-else class=\"cn-timeline-widget__list\">\n\t\t\t\t<li\n\t\t\t\t\tv-for=\"event in events\"\n\t\t\t\t\t:key=\"event.id\"\n\t\t\t\t\tclass=\"cn-timeline-widget__event\"\n\t\t\t\t\t:class=\"{ 'cn-timeline-widget__event--upcoming': event.upcoming }\"\n\t\t\t\t\t:data-source=\"event.source\"\n\t\t\t\t\tdata-testid=\"cn-timeline-widget-event\">\n\t\t\t\t\t<span class=\"cn-timeline-widget__dot\" aria-hidden=\"true\" />\n\t\t\t\t\t<div class=\"cn-timeline-widget__body\">\n\t\t\t\t\t\t<time class=\"cn-timeline-widget__when\" :datetime=\"event.at.toISOString()\">\n\t\t\t\t\t\t\t{{ formatMoment(event) }}\n\t\t\t\t\t\t</time>\n\t\t\t\t\t\t<span v-if=\"event.upcoming\" class=\"cn-timeline-widget__badge\">\n\t\t\t\t\t\t\t{{ upcomingLabel }}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<p class=\"cn-timeline-widget__label\">\n\t\t\t\t\t\t\t{{ translate(event.label) }}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p v-if=\"event.detail\" class=\"cn-timeline-widget__detail\">\n\t\t\t\t\t\t\t{{ event.detail }}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t</li>\n\t\t\t</ol>\n\t\t</template>\n\t</section>\n</template>\n\n<script>\nimport { getLanguage, translate as t } from '@nextcloud/l10n'\nimport { NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'\nimport { auditEvents, fieldEvents, listEvents, relatedEvents, sortEvents, timelineEntryEvents } from '../../utils/timelineEvents.js'\n\n/**\n * CnTimelineWidget — an object's dated events in time order (`timeline`\n * widget type for detail pages).\n *\n * Generalises dossiq's case timeline. It merges up to four sources, all\n * optional, into one list:\n *\n * - `fields`: the object's own date properties, `[{ field, label }]`. A\n * dotted path reaches metadata, such as `@self.created`.\n * - `related`: related objects, `[{ register?, schema, field, dateField?,\n * label, titleField? }]`. Rows of `schema` whose `field` holds this object's\n * id become events at their `dateField` (default `@self.created`).\n * - `auditTrail: true`: the object's audit trail, one event per change.\n * - `timeline: true`: OpenRegister's timeline of notes, calls and messages.\n *\n * Oldest first by default (`order: 'desc'` flips it). A moment after now is\n * marked upcoming, in words as well as style.\n *\n * ```json\n * { \"type\": \"timeline\", \"content\": {\n * \"title\": \"Timeline\",\n * \"fields\": [\n * { \"field\": \"@self.created\", \"label\": \"Booking created\" },\n * { \"field\": \"depositClearedAt\", \"label\": \"Deposit cleared\" },\n * { \"field\": \"confirmationSentAt\", \"label\": \"Confirmation mail sent\" },\n * { \"field\": \"startsAt\", \"label\": \"Starts\" },\n * { \"field\": \"endsAt\", \"label\": \"Ends\" }\n * ] } }\n * ```\n *\n * The object comes from the detail page's context, like `audit-trail`:\n * explicit props win, then the injects, then `content`.\n *\n * @spec openspec/changes/timeline-widget/specs/timeline-widget/spec.md#requirement-a-timeline-widget-shows-an-objects-dated-events-in-order\n */\nexport default {\n\tname: 'CnTimelineWidget',\n\n\tcomponents: {\n\t\tNcLoadingIcon,\n\t\tNcNoteCard,\n\t},\n\n\tinject: {\n\t\t/** Detail-page object context from CnDetailPage (a ref or a raw bag). */\n\t\tcnObjectContext: { default: null },\n\t\t/** Detail object context holder from CnPageRenderer (`{ value: { objectData, ... } }`). */\n\t\tcnDetailObjectContext: { default: null },\n\t\t/** Consumer translation function; labels are authored in English. */\n\t\tcnTranslate: { default: () => (key) => key },\n\t},\n\n\tprops: {\n\t\t/** Register slug; object context spread as props by CnWidgetGrid. */\n\t\tregister: { type: String, default: '' },\n\t\t/**\n\t\t * Schema slug, or the schema object the detail context supplies.\n\t\t *\n\t\t * @type {string|object}\n\t\t */\n\t\tschema: { type: [String, Object], default: '' },\n\t\t/** The object's id. */\n\t\tobjectId: { type: String, default: '' },\n\t\t/** The object itself, when the host already has it. */\n\t\tobject: { type: Object, default: null },\n\t\t/** Card title override. */\n\t\ttitle: { type: String, default: '' },\n\t\t/** Stored widget content: `{ title, fields, related, auditTrail, timeline, order }`. */\n\t\tcontent: { type: Object, default: () => ({}) },\n\t\t/** OpenRegister API base. */\n\t\tapiBase: { type: String, default: '/apps/openregister/api' },\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\tloading: false,\n\t\t\trelatedRows: {},\n\t\t\tauditEntries: [],\n\t\t\ttimelineEntries: [],\n\t\t\tfailedSources: [],\n\t\t\tfetchedObject: null,\n\t\t\tloadSeq: 0,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\tctx() {\n\t\t\tconst raw = this.cnObjectContext\n\t\t\tconst inj = raw && (raw.value !== undefined ? raw.value : raw)\n\t\t\tconst holder = this.cnDetailObjectContext && this.cnDetailObjectContext.value\n\t\t\treturn inj || holder || {}\n\t\t},\n\n\t\tresolvedObjectId() {\n\t\t\treturn this.objectId || this.ctx.objectId || this.content.objectId || ''\n\t\t},\n\n\t\tresolvedRegister() {\n\t\t\treturn this.register || this.ctx.register || this.content.register || ''\n\t\t},\n\n\t\tresolvedSchema() {\n\t\t\tconst s = this.schema || this.ctx.schema || this.content.schema || ''\n\t\t\treturn typeof s === 'string' ? s : (s && (s.slug || s.name || s.id)) || ''\n\t\t},\n\n\t\t/**\n\t\t * The object: explicit prop, then the page context, then a fetch of\n\t\t * our own when only the id is known.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tresolvedObject() {\n\t\t\treturn this.object || this.ctx.object || this.ctx.objectData || this.fetchedObject || null\n\t\t},\n\n\t\tresolvedTitle() {\n\t\t\tconst raw = this.title || this.content.title || ''\n\t\t\treturn raw ? this.translate(raw) : ''\n\t\t},\n\n\t\tfieldsConfig() {\n\t\t\treturn Array.isArray(this.content.fields) ? this.content.fields : []\n\t\t},\n\n\t\trelatedConfig() {\n\t\t\treturn Array.isArray(this.content.related) ? this.content.related.filter((r) => r && r.schema && r.field) : []\n\t\t},\n\n\t\t/**\n\t\t * Every event from every source, in time order.\n\t\t *\n\t\t * @return {Array<object>}\n\t\t */\n\t\tevents() {\n\t\t\tconst now = new Date()\n\t\t\tconst all = [\n\t\t\t\t...fieldEvents(this.resolvedObject || {}, this.fieldsConfig, now),\n\t\t\t\t...listEvents(this.resolvedObject || {}, this.content.lists, now),\n\t\t\t\t...this.relatedConfig.flatMap((cfg, i) => relatedEvents(this.relatedRows[i] || [], cfg, now)),\n\t\t\t\t...auditEvents(this.auditEntries, this.describeAudit, now),\n\t\t\t\t...timelineEntryEvents(this.timelineEntries, t('nextcloud-vue', 'Note'), now),\n\t\t\t]\n\t\t\treturn sortEvents(all, this.content.order === 'desc' ? 'desc' : 'asc')\n\t\t},\n\n\t\t/**\n\t\t * A signature of what to fetch, so a change refetches once.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tloadKey() {\n\t\t\treturn JSON.stringify({\n\t\t\t\tid: this.resolvedObjectId,\n\t\t\t\tregister: this.resolvedRegister,\n\t\t\t\tschema: this.resolvedSchema,\n\t\t\t\trelated: this.relatedConfig,\n\t\t\t\taudit: this.content.auditTrail === true,\n\t\t\t\ttimeline: this.content.timeline === true,\n\t\t\t\tneedObject: this.fieldsConfig.length > 0 && !(this.object || this.ctx.object || this.ctx.objectData),\n\t\t\t})\n\t\t},\n\n\t\tfailedText() {\n\t\t\treturn t('nextcloud-vue', 'Part of the timeline could not load: {parts}.', {\n\t\t\t\tparts: this.failedSources.map((s) => this.sourceName(s)).join(', '),\n\t\t\t})\n\t\t},\n\n\t\tloadingLabel() {\n\t\t\treturn t('nextcloud-vue', 'Loading…')\n\t\t},\n\n\t\temptyLabel() {\n\t\t\treturn t('nextcloud-vue', 'Nothing has happened yet.')\n\t\t},\n\n\t\tupcomingLabel() {\n\t\t\treturn t('nextcloud-vue', 'Upcoming')\n\t\t},\n\t},\n\n\twatch: {\n\t\tloadKey: {\n\t\t\timmediate: true,\n\t\t\thandler() {\n\t\t\t\tthis.load()\n\t\t\t},\n\t\t},\n\t},\n\n\tmethods: {\n\t\ttranslate(text) {\n\t\t\treturn text ? this.cnTranslate(text) : ''\n\t\t},\n\n\t\tsourceName(source) {\n\t\t\tif (source === 'audit') {\n\t\t\t\treturn t('nextcloud-vue', 'changes')\n\t\t\t}\n\t\t\tif (source === 'timeline') {\n\t\t\t\treturn t('nextcloud-vue', 'notes and messages')\n\t\t\t}\n\t\t\tif (source === 'object') {\n\t\t\t\treturn t('nextcloud-vue', 'dates')\n\t\t\t}\n\t\t\treturn t('nextcloud-vue', 'related items')\n\t\t},\n\n\t\t/**\n\t\t * The label of an audit event, from its action and who did it.\n\t\t *\n\t\t * @param {string} action The audit action (create, update, delete).\n\t\t * @param {string} actor Who did it.\n\t\t * @return {string}\n\t\t */\n\t\tdescribeAudit(action, actor) {\n\t\t\tconst known = {\n\t\t\t\tcreate: t('nextcloud-vue', 'Created'),\n\t\t\t\tupdate: t('nextcloud-vue', 'Updated'),\n\t\t\t\tdelete: t('nextcloud-vue', 'Deleted'),\n\t\t\t}\n\t\t\tconst what = known[action] || action || t('nextcloud-vue', 'Changed')\n\t\t\treturn actor ? t('nextcloud-vue', '{action} by {actor}', { action: what, actor }) : what\n\t\t},\n\n\t\t/**\n\t\t * Show a moment in the reader's language: a date for a calendar day,\n\t\t * date and time otherwise.\n\t\t *\n\t\t * @param {object} event The event.\n\t\t * @return {string}\n\t\t */\n\t\tformatMoment(event) {\n\t\t\tconst locale = (typeof getLanguage === 'function' && getLanguage()) || undefined\n\t\t\tconst options = event.dateOnly\n\t\t\t\t? { dateStyle: 'medium' }\n\t\t\t\t: { dateStyle: 'medium', timeStyle: 'short' }\n\t\t\ttry {\n\t\t\t\treturn new Intl.DateTimeFormat(locale, options).format(event.at)\n\t\t\t} catch {\n\t\t\t\treturn event.at.toLocaleString()\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Fetch every configured source. Each one fails on its own, so one\n\t\t * unreachable source never hides the others.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t * @spec openspec/changes/timeline-widget/specs/timeline-widget/spec.md#requirement-a-timeline-widget-shows-an-objects-dated-events-in-order\n\t\t */\n\t\tasync load() {\n\t\t\tconst id = this.resolvedObjectId\n\t\t\tconst register = this.resolvedRegister\n\t\t\tconst schema = this.resolvedSchema\n\t\t\tconst seq = ++this.loadSeq\n\t\t\tif (!id || !register || !schema) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([\n\t\t\t\timport('@nextcloud/axios'),\n\t\t\t\timport('@nextcloud/router'),\n\t\t\t])\n\t\t\tconst base = `${this.apiBase}/objects/${register}/${schema}/${id}`\n\t\t\tconst failed = []\n\t\t\tconst jobs = []\n\n\t\t\tif (this.fieldsConfig.length > 0 && !(this.object || this.ctx.object || this.ctx.objectData)) {\n\t\t\t\tjobs.push(axios.get(generateUrl(base))\n\t\t\t\t\t.then((r) => {\n\t\t\t\t\t\tthis.fetchedObject = (r && r.data) || null\n\t\t\t\t\t})\n\t\t\t\t\t.catch(() => failed.push('object')))\n\t\t\t}\n\n\t\t\tconst rows = {}\n\t\t\tthis.relatedConfig.forEach((cfg, i) => {\n\t\t\t\tconst url = generateUrl(`${this.apiBase}/objects/${cfg.register || register}/${cfg.schema}`)\n\t\t\t\tjobs.push(axios.get(url, { params: { [cfg.field]: id, _limit: cfg.limit || 50 } })\n\t\t\t\t\t.then((r) => {\n\t\t\t\t\t\tconst data = r && r.data\n\t\t\t\t\t\trows[i] = (data && (data.results || data)) || []\n\t\t\t\t\t})\n\t\t\t\t\t.catch(() => {\n\t\t\t\t\t\trows[i] = []\n\t\t\t\t\t\tif (!failed.includes('related')) {\n\t\t\t\t\t\t\tfailed.push('related')\n\t\t\t\t\t\t}\n\t\t\t\t\t}))\n\t\t\t})\n\n\t\t\tif (this.content.auditTrail === true) {\n\t\t\t\tjobs.push(axios.get(generateUrl(`${base}/audit-trails`), { params: { limit: this.content.auditLimit || 50 } })\n\t\t\t\t\t.then((r) => {\n\t\t\t\t\t\tconst data = r && r.data\n\t\t\t\t\t\tthis.auditEntries = (data && (data.results || data)) || []\n\t\t\t\t\t})\n\t\t\t\t\t.catch(() => {\n\t\t\t\t\t\tthis.auditEntries = []\n\t\t\t\t\t\tfailed.push('audit')\n\t\t\t\t\t}))\n\t\t\t}\n\n\t\t\tif (this.content.timeline === true) {\n\t\t\t\tjobs.push(axios.get(generateUrl(`${base}/timeline`))\n\t\t\t\t\t.then((r) => {\n\t\t\t\t\t\tconst data = r && r.data\n\t\t\t\t\t\tthis.timelineEntries = (data && (data.results || data)) || []\n\t\t\t\t\t})\n\t\t\t\t\t.catch(() => {\n\t\t\t\t\t\tthis.timelineEntries = []\n\t\t\t\t\t\tfailed.push('timeline')\n\t\t\t\t\t}))\n\t\t\t}\n\n\t\t\tif (jobs.length === 0) {\n\t\t\t\tthis.failedSources = []\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.loading = true\n\t\t\tawait Promise.all(jobs)\n\t\t\tif (seq !== this.loadSeq) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.relatedRows = rows\n\t\t\tthis.failedSources = failed\n\t\t\tthis.loading = false\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-timeline-widget {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 8px;\n}\n\n.cn-timeline-widget__title {\n\tmargin: 0;\n\tfont-size: var(--font-size-large, 1.1em);\n}\n\n.cn-timeline-widget__loading {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 8px;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-timeline-widget__empty {\n\tmargin: 0;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-timeline-widget__list {\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.cn-timeline-widget__event {\n\tposition: relative;\n\tdisplay: flex;\n\tgap: 12px;\n\tpadding-block-end: 12px;\n}\n\n/* The line between dots: drawn from each dot down to the next event. */\n.cn-timeline-widget__event:not(:last-child)::before {\n\tcontent: '';\n\tposition: absolute;\n\tinset-block: 14px 0;\n\tinset-inline-start: 5px;\n\tborder-inline-start: 2px solid var(--color-border-dark);\n}\n\n.cn-timeline-widget__dot {\n\tflex: 0 0 auto;\n\twidth: 12px;\n\theight: 12px;\n\tmargin-block-start: 4px;\n\tborder-radius: 50%;\n\tbackground-color: var(--color-primary-element);\n}\n\n/* Upcoming: a hollow dot, plus the word \"Upcoming\" beside the date. */\n.cn-timeline-widget__event--upcoming .cn-timeline-widget__dot {\n\tbox-sizing: border-box;\n\tborder: 2px solid var(--color-primary-element);\n\tbackground-color: var(--color-main-background);\n}\n\n.cn-timeline-widget__body {\n\tmin-width: 0;\n}\n\n.cn-timeline-widget__when {\n\tfont-size: var(--font-size-small, 13px);\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-timeline-widget__badge {\n\tmargin-inline-start: 8px;\n\tfont-size: var(--font-size-small, 13px);\n\tfont-weight: bold;\n\tcolor: var(--color-main-text);\n}\n\n.cn-timeline-widget__label {\n\tmargin: 0;\n\tfont-weight: bold;\n}\n\n.cn-timeline-widget__detail {\n\tmargin: 0;\n\toverflow-wrap: anywhere;\n}\n</style>\n"],"names":["_createElementBlock","_toDisplayString","_createVNode","_createBlock","_openBlock","_Fragment","_renderList","_normalizeClass","_createElementVNode"],"mappings":";;;;;AAMM,EAAA,KAAA,EAAA,2BAAA;;;;AAI2B,EAAA,KAAA,EAAA,6BAAA;;;;AAW3B,EAAA,KAAA,EAAA,2BAAA;AAAA,EAAA,aAAA,EAAA,0BAAA;;;;AAIQ,EAAA,KAAK,EAAE,0BAAM;;;AASjB,MAAA,UAAA,GAAA,EAAA,KAAK,EAAC,0BAAe,GAAA;;;;AAInB,EAAA,KAAA,EAAA,2BAAA;;AAGH,MAAA,WAAA,GAAA,EAAA,KAAA,EAAA,2BAAA,GAAA;;;AAGA,EAAA,KAAA,EAAA,4BAAA;;;;;;;uBAvCNA;AAAO,IAAA,KAAK,EAAC,oBAAK;AAAA,IAAA,aAAA,EAAA,oBAAA;AAAA,IAAA,WAAA,EAAA,KAAA,CAAA,OAAA,GAAA,MAAA,GAAA,OAAA;;KACf,QAAA,CAAA,aAAA;sBAAJA,kBAEK,CAAA,IAAA,EAFL,UAEK,EAAAC,eAAA;;KAEM,KAAO,CAAA,OAAA,IAAK,QAAK,CAAA,MAAA,CAAC,MAAE,KAAA,CAAA;yCAGzB,iBAAA,EAAA;UAFLC,WAAI,CAAA,wBAAA,EAAA;AAAA,YAAA,IAAA,EAAA,EAAA;AAAA,YAAA,aAAA,EAAA,MAAA;;;;sBAIJF;AAEY,UAAA,CAAA,KAAA,CAAA,aAAA,CAAA,MAAA,GAAA,CAAA;4BAAZG,WAEa,CAAA,qBAAA,EAAA;;AAFD,gBAAA,IAAA,EAAA,SAAA;AAAA,gBAAA,aAAA,EAAA,0BAAA;;;;;;;;AAIT,UAAA,CAAA,QAAA,CAAA,MAAA,CAAA,MAAA,KAAA,CAAA;4BAAHH,kBAEI,CAAA,GAAA,EAFJ,UAEI,EAAAC,eAAA;AAEF,eAAAG,SAAA,EAAA,EAAAJ,kBAAA,CAwBG,MAxBH,UAwBG,EAAA;kCAvBHA,kBAsBI,CAAAK,QAAA,EAAA,IAAA,EAAAC,UAAA,CArBY,QAAM,CAAA,MAAA,EAAA,CAAf,KAAK,KAAA;uCADZN,kBAsBI,CAAA,IAAA,EAAA;oBApBH,GAAG,EAAE,KAAK,CAAC,EAAE;AACd,oBAAA,KAAK,EAACO,cAAA,CAAA,CAAA,2BAAoB,EACY,EAAA,qCAAA,EAAA,KAAA,CAAA,QAAA,EAAA,CAAA,CAAA;oBACrC,aAAW,EAAE,KAAK,CAAC,MAAM;AAC1B,oBAAA,aAAW,EAAC,0BAAyB;;8CACpCC,kBAA0D,CAAA,MAAA,EAAA;AAAtD,sBAAA,KAAM,EAAC,yBAAyB;AAAC,sBAAA,aAAW,EAAC,MAAM;;uCActD,iBAAA,EAAA;sBAZDA,kBAEO,CAAA,MAAA,EAAA;AAFD,wBAAA,KAAA,EAAA,0BAAA;AAAA,wBAAA,QAAA,EAAA,KAAA,CAAA,EAAA,CAAA,WAAA,EAAA;AACF,uBAAA,EAAAP,eAAA,CAAA,QAAA,CAAA,YAAY,CAAC,KAAK,CAAA,CAAA,EAAA,CAAA,oBAAA,UAAA,CAAA;AAEhB,sBAAA,CAAA,KAAA,CAAA,QAAA;wCAAND,kBAEO,CAAA,MAAA,EAFP,UAEO,EAAAC,eAAA,CADH,QAAa,CAAA,aAAA,CAAA,EAAA,CAAA,YAAA;;AAEjB,sBAAAO,kBAAA,CAEI,KAFJ,WAEI,EAAAP,eAAA,CADA,kBAAS,CAAC,KAAK,CAAC,KAAK,CAAA,CAAA,EAAA,CAAA,YAAA;AAEtB,sBAAA,CAAA,KAAA,CAAA,MAAA;AAAH,2BAAAG,SAAA,EAAA,EAAAJ,kBAAA,CAEE,GAFF,EAAA,WAEE,EADEC,eAAA,CAAA,KAAA,CAAA,MAAA,CAAA,EAAA,CAAA,YAAA;;;;;;;;;;;;"}
@@ -1,4 +1,5 @@
1
1
  import { ref, onBeforeUnmount } from 'vue';
2
+ import { rowActionPayload } from '../utils/rowActionItem.js';
2
3
 
3
4
  const CTX_MENU_CSS_VAR_X = '--cn-ctx-menu-x';
4
5
  const CTX_MENU_CSS_VAR_Y = '--cn-ctx-menu-y';
@@ -135,15 +136,16 @@ function useContextMenu() {
135
136
  *
136
137
  * Calls `action.handler(targetItem)` if a handler exists, then returns a
137
138
  * payload object the caller can pass to `$emit('action', payload)`.
139
+ * The payload is the one CnRowActions and CnContextMenu emit: the label as `action`, plus `id` and `builtin: true` when the action carries them.
138
140
  *
139
141
  * @param {object} action Action definition with `label` and optional `handler`
140
- * @return {{ action: string, row: object }}
142
+ * @return {{ action: string, row: object, id?: string, builtin?: boolean }}
141
143
  */
142
144
  function triggerAction(action) {
143
145
  if (action.handler && typeof action.handler === 'function') {
144
146
  action.handler(targetItem.value);
145
147
  }
146
- return { action: action.label, row: targetItem.value }
148
+ return rowActionPayload(action, targetItem.value)
147
149
  }
148
150
 
149
151
  // Clean up DOM if the consumer unmounts while the menu is open — nothing
@@ -1 +1 @@
1
- {"version":3,"file":"useContextMenu.js","sources":["../../../src/composables/useContextMenu.js"],"sourcesContent":["import { onBeforeUnmount, ref } from 'vue'\n\nexport const CTX_MENU_CSS_VAR_X = '--cn-ctx-menu-x'\nexport const CTX_MENU_CSS_VAR_Y = '--cn-ctx-menu-y'\nexport const CTX_MENU_DATA_ATTR = 'data-cn-ctx-menu'\n\n/**\n * Marker attribute `CnContextMenu` stamps on its own `.v-popper__popper`\n * element, and the only thing the cursor-positioning CSS keys on. See\n * `src/css/context-menu.css` and `CnContextMenu#tagPopper`.\n *\n * An attribute, not a class: floating-vue binds a dynamic `class` on that\n * element (`--shown` / `--hidden` / `--show-from` / …) and Vue's `patchClass`\n * assigns `el.className` wholesale, so a manually added class is wiped the\n * first time the popper opens. Vue only patches props that appear in the\n * vnode, so an attribute it never renders survives untouched.\n */\nexport const CTX_MENU_POPPER_ATTR = 'data-cn-ctx-menu-popper'\n\n// Internal aliases — keep the original short names for the existing code below.\nconst CSS_VAR_X = CTX_MENU_CSS_VAR_X\nconst CSS_VAR_Y = CTX_MENU_CSS_VAR_Y\nconst DATA_ATTR = CTX_MENU_DATA_ATTR\n\n/**\n * Strip the position CSS vars + data attribute from `<html>`.\n *\n * Full tear-down, used on unmount. Note that positioning no longer depends on\n * the data attribute (the CSS keys on `CTX_MENU_POPPER_CLASS` instead), so\n * calling this while a menu is mid-hide-animation is harmless.\n */\nexport function clearContextMenuPositionDom() {\n\tdocument.documentElement.style.removeProperty(CSS_VAR_X)\n\tdocument.documentElement.style.removeProperty(CSS_VAR_Y)\n\tdocument.documentElement.removeAttribute(DATA_ATTR)\n}\n\n/**\n * Composable for managing a right-click context menu positioned at the cursor.\n *\n * Handles cursor-based positioning through CSS custom properties and a data\n * attribute on `document.documentElement`. The `<NcActions>` template stays in\n * the consuming component — this composable only manages open/close state,\n * the target item reference, and action helpers.\n *\n * Pair with the shared CSS in `src/css/context-menu.css` (auto-imported via\n * `src/css/index.css`), which overrides the transform of the one popper\n * carrying `CTX_MENU_POPPER_ATTR`.\n *\n * @example In a Vue Options API component with setup()\n * ```js\n * import { useContextMenu } from '@conduction/nextcloud-vue'\n *\n * export default {\n * setup() {\n * const ctx = useContextMenu()\n * return {\n * contextMenuOpen: ctx.isOpen,\n * contextMenuRow: ctx.targetItem,\n * openContextMenu: ctx.open,\n * closeContextMenu: ctx.close,\n * isContextActionDisabled: ctx.isActionDisabled,\n * triggerContextAction: ctx.triggerAction,\n * }\n * },\n * }\n * ```\n *\n * @return {{\n * isOpen: import('vue').Ref<boolean>,\n * targetItem: import('vue').Ref<object>,\n * open: (params: { item: object, event: MouseEvent }) => void,\n * close: () => void,\n * isActionDisabled: (action: { disabled?: boolean | ((item: object) => boolean) }) => boolean,\n * triggerAction: (action: { label: string, handler?: (item: object) => void }) => { action: string, row: object },\n * }}\n */\nexport function useContextMenu() {\n\tconst isOpen = ref(false)\n\tconst targetItem = ref(null)\n\n\t/**\n\t * Open the context menu at the cursor position.\n\t *\n\t * Sets CSS custom properties for x/y coordinates on `document.documentElement`\n\t * so the shared CSS can override Popper positioning, plus a data attribute\n\t * marking \"a context menu is currently open\" for consumer CSS and tests.\n\t *\n\t * The vars are written *before* `isOpen` flips, so the popper is always\n\t * positioned with fresh coordinates on the very first frame it shows.\n\t *\n\t * @param {object} params - Context menu trigger parameters.\n\t * @param {object} params.item The item associated with the right-click (row, folder, etc.)\n\t * @param {MouseEvent} params.event The native contextmenu event\n\t */\n\tfunction open({ item, event }) {\n\t\tdocument.documentElement.style.setProperty(CSS_VAR_X, event.clientX + 'px')\n\t\tdocument.documentElement.style.setProperty(CSS_VAR_Y, event.clientY + 'px')\n\t\tdocument.documentElement.setAttribute(DATA_ATTR, '')\n\t\ttargetItem.value = item\n\t\tisOpen.value = true\n\t}\n\n\t/**\n\t * Close the context menu.\n\t *\n\t * Only flips reactive state. `CnContextMenu` drops the data attribute as\n\t * soon as it closes; the CSS vars are deliberately left in place so the\n\t * hide animation keeps its transform, and are overwritten by the next\n\t * `open()` (or cleared wholesale on unmount).\n\t *\n\t * Use as the `@close` handler on `<NcActions>` / `<CnContextMenu>`.\n\t */\n\tfunction close() {\n\t\tisOpen.value = false\n\t\ttargetItem.value = null\n\t}\n\n\t/**\n\t * Resolve whether an action is disabled for the current target item.\n\t * Supports both a static boolean and a function `(item) => boolean`.\n\t *\n\t * @param {object} action Action definition with optional `disabled` field\n\t * @return {boolean}\n\t */\n\tfunction isActionDisabled(action) {\n\t\tif (typeof action.disabled === 'function') {\n\t\t\treturn action.disabled(targetItem.value)\n\t\t}\n\t\treturn !!action.disabled\n\t}\n\n\t/**\n\t * Execute a context menu action.\n\t *\n\t * Calls `action.handler(targetItem)` if a handler exists, then returns a\n\t * payload object the caller can pass to `$emit('action', payload)`.\n\t *\n\t * @param {object} action Action definition with `label` and optional `handler`\n\t * @return {{ action: string, row: object }}\n\t */\n\tfunction triggerAction(action) {\n\t\tif (action.handler && typeof action.handler === 'function') {\n\t\t\taction.handler(targetItem.value)\n\t\t}\n\t\treturn { action: action.label, row: targetItem.value }\n\t}\n\n\t// Clean up DOM if the consumer unmounts while the menu is open — nothing\n\t// else would ever remove the leftover vars/attribute.\n\tonBeforeUnmount(() => {\n\t\tisOpen.value = false\n\t\ttargetItem.value = null\n\t\tclearContextMenuPositionDom()\n\t})\n\n\treturn {\n\t\tisOpen,\n\t\ttargetItem,\n\t\topen,\n\t\tclose,\n\t\tisActionDisabled,\n\t\ttriggerAction,\n\t}\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useContextMenu.js","sources":["../../../src/composables/useContextMenu.js"],"sourcesContent":["import { onBeforeUnmount, ref } from 'vue'\nimport { rowActionPayload } from '../utils/rowActionItem.js'\n\nexport const CTX_MENU_CSS_VAR_X = '--cn-ctx-menu-x'\nexport const CTX_MENU_CSS_VAR_Y = '--cn-ctx-menu-y'\nexport const CTX_MENU_DATA_ATTR = 'data-cn-ctx-menu'\n\n/**\n * Marker attribute `CnContextMenu` stamps on its own `.v-popper__popper`\n * element, and the only thing the cursor-positioning CSS keys on. See\n * `src/css/context-menu.css` and `CnContextMenu#tagPopper`.\n *\n * An attribute, not a class: floating-vue binds a dynamic `class` on that\n * element (`--shown` / `--hidden` / `--show-from` / …) and Vue's `patchClass`\n * assigns `el.className` wholesale, so a manually added class is wiped the\n * first time the popper opens. Vue only patches props that appear in the\n * vnode, so an attribute it never renders survives untouched.\n */\nexport const CTX_MENU_POPPER_ATTR = 'data-cn-ctx-menu-popper'\n\n// Internal aliases — keep the original short names for the existing code below.\nconst CSS_VAR_X = CTX_MENU_CSS_VAR_X\nconst CSS_VAR_Y = CTX_MENU_CSS_VAR_Y\nconst DATA_ATTR = CTX_MENU_DATA_ATTR\n\n/**\n * Strip the position CSS vars + data attribute from `<html>`.\n *\n * Full tear-down, used on unmount. Note that positioning no longer depends on\n * the data attribute (the CSS keys on `CTX_MENU_POPPER_CLASS` instead), so\n * calling this while a menu is mid-hide-animation is harmless.\n */\nexport function clearContextMenuPositionDom() {\n\tdocument.documentElement.style.removeProperty(CSS_VAR_X)\n\tdocument.documentElement.style.removeProperty(CSS_VAR_Y)\n\tdocument.documentElement.removeAttribute(DATA_ATTR)\n}\n\n/**\n * Composable for managing a right-click context menu positioned at the cursor.\n *\n * Handles cursor-based positioning through CSS custom properties and a data\n * attribute on `document.documentElement`. The `<NcActions>` template stays in\n * the consuming component — this composable only manages open/close state,\n * the target item reference, and action helpers.\n *\n * Pair with the shared CSS in `src/css/context-menu.css` (auto-imported via\n * `src/css/index.css`), which overrides the transform of the one popper\n * carrying `CTX_MENU_POPPER_ATTR`.\n *\n * @example In a Vue Options API component with setup()\n * ```js\n * import { useContextMenu } from '@conduction/nextcloud-vue'\n *\n * export default {\n * setup() {\n * const ctx = useContextMenu()\n * return {\n * contextMenuOpen: ctx.isOpen,\n * contextMenuRow: ctx.targetItem,\n * openContextMenu: ctx.open,\n * closeContextMenu: ctx.close,\n * isContextActionDisabled: ctx.isActionDisabled,\n * triggerContextAction: ctx.triggerAction,\n * }\n * },\n * }\n * ```\n *\n * @return {{\n * isOpen: import('vue').Ref<boolean>,\n * targetItem: import('vue').Ref<object>,\n * open: (params: { item: object, event: MouseEvent }) => void,\n * close: () => void,\n * isActionDisabled: (action: { disabled?: boolean | ((item: object) => boolean) }) => boolean,\n * triggerAction: (action: { label: string, handler?: (item: object) => void }) => { action: string, row: object },\n * }}\n */\nexport function useContextMenu() {\n\tconst isOpen = ref(false)\n\tconst targetItem = ref(null)\n\n\t/**\n\t * Open the context menu at the cursor position.\n\t *\n\t * Sets CSS custom properties for x/y coordinates on `document.documentElement`\n\t * so the shared CSS can override Popper positioning, plus a data attribute\n\t * marking \"a context menu is currently open\" for consumer CSS and tests.\n\t *\n\t * The vars are written *before* `isOpen` flips, so the popper is always\n\t * positioned with fresh coordinates on the very first frame it shows.\n\t *\n\t * @param {object} params - Context menu trigger parameters.\n\t * @param {object} params.item The item associated with the right-click (row, folder, etc.)\n\t * @param {MouseEvent} params.event The native contextmenu event\n\t */\n\tfunction open({ item, event }) {\n\t\tdocument.documentElement.style.setProperty(CSS_VAR_X, event.clientX + 'px')\n\t\tdocument.documentElement.style.setProperty(CSS_VAR_Y, event.clientY + 'px')\n\t\tdocument.documentElement.setAttribute(DATA_ATTR, '')\n\t\ttargetItem.value = item\n\t\tisOpen.value = true\n\t}\n\n\t/**\n\t * Close the context menu.\n\t *\n\t * Only flips reactive state. `CnContextMenu` drops the data attribute as\n\t * soon as it closes; the CSS vars are deliberately left in place so the\n\t * hide animation keeps its transform, and are overwritten by the next\n\t * `open()` (or cleared wholesale on unmount).\n\t *\n\t * Use as the `@close` handler on `<NcActions>` / `<CnContextMenu>`.\n\t */\n\tfunction close() {\n\t\tisOpen.value = false\n\t\ttargetItem.value = null\n\t}\n\n\t/**\n\t * Resolve whether an action is disabled for the current target item.\n\t * Supports both a static boolean and a function `(item) => boolean`.\n\t *\n\t * @param {object} action Action definition with optional `disabled` field\n\t * @return {boolean}\n\t */\n\tfunction isActionDisabled(action) {\n\t\tif (typeof action.disabled === 'function') {\n\t\t\treturn action.disabled(targetItem.value)\n\t\t}\n\t\treturn !!action.disabled\n\t}\n\n\t/**\n\t * Execute a context menu action.\n\t *\n\t * Calls `action.handler(targetItem)` if a handler exists, then returns a\n\t * payload object the caller can pass to `$emit('action', payload)`.\n\t * The payload is the one CnRowActions and CnContextMenu emit: the label as `action`, plus `id` and `builtin: true` when the action carries them.\n\t *\n\t * @param {object} action Action definition with `label` and optional `handler`\n\t * @return {{ action: string, row: object, id?: string, builtin?: boolean }}\n\t */\n\tfunction triggerAction(action) {\n\t\tif (action.handler && typeof action.handler === 'function') {\n\t\t\taction.handler(targetItem.value)\n\t\t}\n\t\treturn rowActionPayload(action, targetItem.value)\n\t}\n\n\t// Clean up DOM if the consumer unmounts while the menu is open — nothing\n\t// else would ever remove the leftover vars/attribute.\n\tonBeforeUnmount(() => {\n\t\tisOpen.value = false\n\t\ttargetItem.value = null\n\t\tclearContextMenuPositionDom()\n\t})\n\n\treturn {\n\t\tisOpen,\n\t\ttargetItem,\n\t\topen,\n\t\tclose,\n\t\tisActionDisabled,\n\t\ttriggerAction,\n\t}\n}\n"],"names":[],"mappings":"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"}
@@ -1076,6 +1076,9 @@ var translations = {
1076
1076
  "An attention card needs a title": "An attention card needs a title",
1077
1077
  "Good morning, {name}": "Good morning, {name}",
1078
1078
  "This view has no widgets yet.": "This view has no widgets yet.",
1079
+ "Arrange view": "Arrange view",
1080
+ "Reset view": "Reset view",
1081
+ "Reset layout": "Reset layout",
1079
1082
  "Good morning": "Good morning",
1080
1083
  "Good afternoon, {name}": "Good afternoon, {name}",
1081
1084
  "Good afternoon": "Good afternoon",
@@ -1 +1 @@
1
- {"version":3,"file":"en.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"en.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -1128,6 +1128,9 @@ var translations = {
1128
1128
  "An attention card needs a title": "Een aandachtskaart heeft een titel nodig",
1129
1129
  "Good morning, {name}": "Goedemorgen, {name}",
1130
1130
  "This view has no widgets yet.": "Deze weergave heeft nog geen widgets.",
1131
+ "Arrange view": "Weergave indelen",
1132
+ "Reset view": "Weergave herstellen",
1133
+ "Reset layout": "Indeling herstellen",
1131
1134
  "Good morning": "Goedemorgen",
1132
1135
  "Good afternoon, {name}": "Goedemiddag, {name}",
1133
1136
  "Good afternoon": "Goedemiddag",
@@ -1 +1 @@
1
- {"version":3,"file":"nl.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"nl.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -1,5 +1,6 @@
1
1
  import { translate } from '@nextcloud/l10n';
2
2
  import { readUserPreference, writeUserPreference, USER_PREFERENCE_STORAGE_PREFIX } from '../composables/useUserPreferences.js';
3
+ import { resolveUserLayoutApi, mergeUserLayout, dashboardLayoutKey, LAYOUT_GEOMETRY_FIELDS } from '../store/plugins/dashboardLayouts.js';
3
4
 
4
5
  /**
5
6
  * SPDX-FileCopyrightText: 2026 Conduction B.V.
@@ -21,6 +22,13 @@ import { readUserPreference, writeUserPreference, USER_PREFERENCE_STORAGE_PREFIX
21
22
  * `layout` with its own grid, so a view looks exactly like the page's own
22
23
  * grid.
23
24
  *
25
+ * With `userLayout` on, a user arranges each view's grid for themselves, as
26
+ * they arrange the dashboard's own grid. The arrangement is stored per view,
27
+ * per page and per user, through the same user-preference record the
28
+ * dashboard uses (the server, mirrored in the browser). The manifest stays
29
+ * the base: it decides which widgets a view has, the user decides where they
30
+ * sit, and a reset returns the view to the manifest.
31
+ *
24
32
  * @mixin pageViews
25
33
  * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
26
34
  */
@@ -31,18 +39,32 @@ const PAGE_VIEW_QUERY_KEY = 'view';
31
39
  /** Prefix of the user-preference key; the page id follows it. */
32
40
  const PAGE_VIEW_PREFERENCE_PREFIX = 'cn_page_view:';
33
41
 
42
+ /**
43
+ * The page id one view's user layout is stored under: the page's own id,
44
+ * then `.view.`, then the view id. Spelled once, because a read and a write
45
+ * that disagree about the key read as an arrangement that never persists.
46
+ *
47
+ * @param {string} pageKey The page's id.
48
+ * @param {string} viewId The view id.
49
+ * @return {string} The page id handed to the layout store.
50
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
51
+ */
52
+ function pageViewLayoutId(pageKey, viewId) {
53
+ return `${String(pageKey || 'page')}.view.${String(viewId)}`
54
+ }
55
+
34
56
  let regionSeq = 0;
35
57
 
36
58
  /**
37
- * Read the browser mirror of a user preference synchronously, so the stored
38
- * view is on screen from the first frame. Errors read as nothing stored.
59
+ * Read the browser mirror of a user preference synchronously, so a stored
60
+ * value is on screen from the first frame. Errors read as nothing stored.
39
61
  *
40
62
  * @param {string} appId The app id.
41
63
  * @param {string} key The preference key.
42
- * @return {string|null} The stored id, or null.
43
- * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
64
+ * @return {object|string|number|boolean|Array|null} The stored value, or null.
65
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
44
66
  */
45
- function readStoredViewMirror(appId, key) {
67
+ function readPreferenceMirror(appId, key) {
46
68
  try {
47
69
  if (typeof localStorage === 'undefined') {
48
70
  return null
@@ -51,19 +73,45 @@ function readStoredViewMirror(appId, key) {
51
73
  if (raw === null || raw === undefined) {
52
74
  return null
53
75
  }
54
- const value = JSON.parse(raw);
55
- return typeof value === 'string' && value !== '' ? value : null
76
+ return JSON.parse(raw)
56
77
  } catch {
57
78
  return null
58
79
  }
59
80
  }
60
81
 
82
+ /**
83
+ * Read the browser mirror of the stored view synchronously, so the stored
84
+ * view is on screen from the first frame.
85
+ *
86
+ * @param {string} appId The app id.
87
+ * @param {string} key The preference key.
88
+ * @return {string|null} The stored id, or null.
89
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
90
+ */
91
+ function readStoredViewMirror(appId, key) {
92
+ const value = readPreferenceMirror(appId, key);
93
+ return typeof value === 'string' && value !== '' ? value : null
94
+ }
95
+
96
+ /**
97
+ * Whether a stored layout record holds an arrangement.
98
+ *
99
+ * @param {object|Array<object>|null} record The record, or its items.
100
+ * @return {boolean}
101
+ */
102
+ function holdsArrangement(record) {
103
+ const items = Array.isArray(record) ? record : (record && Array.isArray(record.items) ? record.items : []);
104
+ return items.length > 0
105
+ }
106
+
61
107
  const pageViews = {
62
108
  inject: {
63
109
  /** The host app id, provided by CnAppRoot; keys the stored view. */
64
110
  cnPageViewsAppId: { from: 'cnAppId', default: '' },
65
111
  },
66
112
 
113
+ emits: ['view-layout-change', 'view-layout-reset'],
114
+
67
115
  props: {
68
116
  /**
69
117
  * Views of this page, each its own widget grid behind one segmented
@@ -111,6 +159,17 @@ const pageViews = {
111
159
  viewChosen: false,
112
160
  /** Id of the view region, the target of the options' `aria-controls`. */
113
161
  viewRegionId: `cn-page-view-region-${regionSeq}`,
162
+ /**
163
+ * This user's arrangement of each view, by view id, when the page
164
+ * keeps a user layout. A separate copy, never the view's `layout`:
165
+ * that array is the manifest, and writing a user's drag into it
166
+ * would rearrange the view for everybody.
167
+ *
168
+ * @type {Record<string, Array<object>>}
169
+ */
170
+ viewUserLayouts: {},
171
+ /** The views whose arrangement changed since the last save, by id. */
172
+ viewUserLayoutsDirty: {},
114
173
  }
115
174
  },
116
175
 
@@ -208,6 +267,32 @@ const pageViews = {
208
267
  return this.normalizedViews.find((view) => view.id === this.activeViewId) || null
209
268
  },
210
269
 
270
+ /**
271
+ * Whether a user arranges the views for themselves (`userLayout`).
272
+ *
273
+ * @return {boolean}
274
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
275
+ */
276
+ viewUserLayoutOn() {
277
+ return this.userLayout === true && this.hasViews
278
+ },
279
+
280
+ /**
281
+ * The chosen view's layout: this user's arrangement when they have
282
+ * one, the manifest's otherwise.
283
+ *
284
+ * @return {Array<object>}
285
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
286
+ */
287
+ activeViewLayout() {
288
+ const view = this.activeView;
289
+ if (!view) {
290
+ return []
291
+ }
292
+ const arranged = this.viewUserLayoutOn ? this.viewUserLayouts[view.id] : null;
293
+ return Array.isArray(arranged) ? arranged : view.layout
294
+ },
295
+
211
296
  /**
212
297
  * The switch's options, for CnSegmentedControl.
213
298
  *
@@ -289,8 +374,21 @@ const pageViews = {
289
374
  },
290
375
  },
291
376
 
377
+ watch: {
378
+ /**
379
+ * Read the user's arrangement of a view the first time it is shown.
380
+ *
381
+ * @param {string|null} id The chosen view id.
382
+ * @return {void}
383
+ */
384
+ activeViewId(id) {
385
+ this.loadViewUserLayout(id);
386
+ },
387
+ },
388
+
292
389
  created() {
293
390
  this.loadStoredView();
391
+ this.loadViewUserLayout(this.activeViewId);
294
392
  },
295
393
 
296
394
  methods: {
@@ -362,18 +460,187 @@ const pageViews = {
362
460
  },
363
461
 
364
462
  /**
365
- * Write a drag or resize in the chosen view's grid back onto its
366
- * layout items in place, as the page does for its own layout, so the
367
- * in-app manifest editor sees it.
463
+ * The page id a view's arrangement is stored under.
464
+ *
465
+ * @param {string} viewId The view id.
466
+ * @return {string}
467
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
468
+ */
469
+ viewLayoutPageId(viewId) {
470
+ const pageKey = this.viewPreferenceKey.slice(PAGE_VIEW_PREFERENCE_PREFIX.length);
471
+ return pageViewLayoutId(pageKey, viewId)
472
+ },
473
+
474
+ /**
475
+ * The layout store calls: the page's own (`userLayoutApi`, so an
476
+ * injected `userLayoutStore` covers the views too), else the user
477
+ * preferences.
478
+ *
479
+ * @return {{load: (appId: string, pageId: string) => Promise<object|null>, save: (appId: string, pageId: string, layout: Array<object>) => Promise<boolean>, reset: (appId: string, pageId: string) => Promise<boolean>}}
480
+ */
481
+ viewLayoutApi() {
482
+ if (typeof this.userLayoutApi === 'function') {
483
+ return this.userLayoutApi()
484
+ }
485
+ return resolveUserLayoutApi(null)
486
+ },
487
+
488
+ /**
489
+ * Read this user's arrangement of one view, once: the browser mirror
490
+ * at once, then the server, which wins unless the user already moved
491
+ * something in that view. A page without `userLayout` reads nothing.
492
+ *
493
+ * @param {string|null} viewId The view id.
494
+ * @return {Promise<void>}
495
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
496
+ */
497
+ async loadViewUserLayout(viewId) {
498
+ if (!this.viewUserLayoutOn || viewId === null || viewId === undefined) {
499
+ return
500
+ }
501
+ const view = this.normalizedViews.find((candidate) => candidate.id === String(viewId));
502
+ if (!view || Array.isArray(this.viewUserLayouts[view.id])) {
503
+ return
504
+ }
505
+ const appId = this.viewPreferenceAppId;
506
+ const pageId = this.viewLayoutPageId(view.id);
507
+ const mirror = readPreferenceMirror(appId, dashboardLayoutKey(pageId));
508
+ this.viewUserLayouts = { ...this.viewUserLayouts, [view.id]: mergeUserLayout(view.layout, holdsArrangement(mirror) ? mirror : null) };
509
+ if (!appId) {
510
+ return
511
+ }
512
+ let record;
513
+ try {
514
+ record = await this.viewLayoutApi().load(appId, pageId);
515
+ } catch {
516
+ // The mirror, or the manifest, stands: a store that cannot
517
+ // answer leaves the view as the user last saw it.
518
+ return
519
+ }
520
+ if (this.viewUserLayoutsDirty[view.id] || !holdsArrangement(record)) {
521
+ return
522
+ }
523
+ this.viewUserLayouts = { ...this.viewUserLayouts, [view.id]: mergeUserLayout(view.layout, record) };
524
+ },
525
+
526
+ /**
527
+ * Store the arrangement of every view the user changed since the last
528
+ * save. The page calls it when the user leaves edit mode, so a drag
529
+ * is not a write per pixel gesture.
530
+ *
531
+ * @return {Promise<void>}
532
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
533
+ */
534
+ async saveViewUserLayouts() {
535
+ if (!this.viewUserLayoutOn) {
536
+ return
537
+ }
538
+ const ids = Object.keys(this.viewUserLayoutsDirty).filter((id) => this.viewUserLayoutsDirty[id]);
539
+ this.viewUserLayoutsDirty = {};
540
+ const appId = this.viewPreferenceAppId;
541
+ for (const id of ids) {
542
+ const pageId = this.viewLayoutPageId(id);
543
+ const items = this.viewUserLayouts[id] || [];
544
+ try {
545
+ if (appId) {
546
+ await this.viewLayoutApi().save(appId, pageId, items);
547
+ } else {
548
+ // No app to address: keep it in this browser only, as
549
+ // the chosen view is kept.
550
+ await writeUserPreference('', dashboardLayoutKey(pageId), { items: items.map((item) => this.viewGeometry(item, true)) });
551
+ }
552
+ } catch {
553
+ // The arrangement stands for this session. A toast on a
554
+ // failed layout write is noise on an instance without the
555
+ // preference route.
556
+ }
557
+ }
558
+ },
559
+
560
+ /**
561
+ * Drop this user's arrangement of one view and return it to the
562
+ * manifest layout. Other views keep theirs.
563
+ *
564
+ * @param {string} [viewId] The view id; the chosen view when left out.
565
+ * @return {Promise<void>}
566
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-resetting-a-view-restores-its-manifest-layout
567
+ */
568
+ async resetViewUserLayout(viewId) {
569
+ const id = viewId === undefined || viewId === null ? this.activeViewId : String(viewId);
570
+ const view = this.normalizedViews.find((candidate) => candidate.id === id);
571
+ if (!this.viewUserLayoutOn || !view) {
572
+ return
573
+ }
574
+ const appId = this.viewPreferenceAppId;
575
+ const pageId = this.viewLayoutPageId(view.id);
576
+ try {
577
+ if (appId) {
578
+ await this.viewLayoutApi().reset(appId, pageId);
579
+ } else {
580
+ await writeUserPreference('', dashboardLayoutKey(pageId), { items: [] });
581
+ }
582
+ } catch {
583
+ // The view returns to the manifest either way, which is what
584
+ // the user asked for.
585
+ }
586
+ const dirty = { ...this.viewUserLayoutsDirty };
587
+ delete dirty[view.id];
588
+ this.viewUserLayoutsDirty = dirty;
589
+ this.viewUserLayouts = { ...this.viewUserLayouts, [view.id]: mergeUserLayout(view.layout, null) };
590
+ /**
591
+ * Emitted when the user drops their arrangement of a view and it
592
+ * returns to the manifest layout.
593
+ *
594
+ * @event view-layout-reset
595
+ * @type {{view: string}}
596
+ */
597
+ this.$emit('view-layout-reset', { view: view.id });
598
+ },
599
+
600
+ /**
601
+ * The geometry fields of one layout item, and nothing else.
602
+ *
603
+ * @param {object} item A layout item, or a grid update.
604
+ * @param {boolean} [withWidgetId] Whether to keep the widget id too.
605
+ * @return {object}
606
+ */
607
+ viewGeometry(item, withWidgetId = false) {
608
+ const geometry = withWidgetId ? { widgetId: String(item?.widgetId ?? '') } : {};
609
+ for (const field of LAYOUT_GEOMETRY_FIELDS) {
610
+ if (item?.[field] !== undefined) {
611
+ geometry[field] = item[field];
612
+ }
613
+ }
614
+ return geometry
615
+ },
616
+
617
+ /**
618
+ * A drag or resize in the chosen view's grid. With `userLayout` it
619
+ * moves this user's copy of the view and marks it for saving; the
620
+ * view's manifest `layout` is not touched. Without it the geometry is
621
+ * written back onto the layout items in place, as the page does for
622
+ * its own layout, so the in-app manifest editor sees it.
368
623
  *
369
624
  * @param {Array<object>} updated The layout from the grid.
370
625
  * @return {void}
626
+ * @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
371
627
  */
372
628
  onViewLayoutChange(updated) {
373
629
  const view = this.activeView;
374
630
  if (!view || !Array.isArray(updated)) {
375
631
  return
376
632
  }
633
+ if (this.viewUserLayoutOn) {
634
+ const current = Array.isArray(this.viewUserLayouts[view.id]) ? this.viewUserLayouts[view.id] : mergeUserLayout(view.layout, null);
635
+ const arranged = current.map((item) => {
636
+ const u = updated.find((candidate) => String(candidate.id) === String(item.id));
637
+ return u ? { ...item, ...this.viewGeometry(u) } : item
638
+ });
639
+ this.viewUserLayouts = { ...this.viewUserLayouts, [view.id]: arranged };
640
+ this.viewUserLayoutsDirty = { ...this.viewUserLayoutsDirty, [view.id]: true };
641
+ this.$emit('view-layout-change', { view: view.id, layout: arranged });
642
+ return
643
+ }
377
644
  for (const u of updated) {
378
645
  const item = view.layout.find((l) => String(l.id) === String(u.id));
379
646
  if (!item) {
@@ -396,5 +663,5 @@ const pageViews = {
396
663
  },
397
664
  };
398
665
 
399
- export { PAGE_VIEW_PREFERENCE_PREFIX, PAGE_VIEW_QUERY_KEY, pageViews };
666
+ export { PAGE_VIEW_PREFERENCE_PREFIX, PAGE_VIEW_QUERY_KEY, pageViewLayoutId, pageViews };
400
667
  //# sourceMappingURL=pageViews.js.map