@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.
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js +37 -26
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js.map +1 -1
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js +3 -3
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +21 -35
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +17 -0
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +40 -3
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +58 -16
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +87 -18
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +6 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +58 -48
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/defaultActions.js +6 -7
- package/dist/esm/components/CnIndexPage/defaultActions.js.map +1 -1
- package/dist/esm/components/CnRowActions/CnRowActions.vue2.js +33 -34
- package/dist/esm/components/CnRowActions/CnRowActions.vue2.js.map +1 -1
- package/dist/esm/components/CnRowActions/CnRowActions.vue3.js +3 -3
- package/dist/esm/components/CnRowActions/CnRowActions.vue3.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +8 -2
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +2 -2
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +70 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +23 -3
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +3 -2
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -1
- package/dist/esm/composables/useContextMenu.js +4 -2
- package/dist/esm/composables/useContextMenu.js.map +1 -1
- package/dist/esm/l10n/en.json.js +3 -0
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +3 -0
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/mixins/pageViews.js +278 -11
- package/dist/esm/mixins/pageViews.js.map +1 -1
- package/dist/esm/store/plugins/dashboardLayouts.js +35 -1
- package/dist/esm/store/plugins/dashboardLayouts.js.map +1 -1
- package/dist/esm/utils/formatMetric.js +137 -16
- package/dist/esm/utils/formatMetric.js.map +1 -1
- package/dist/esm/utils/resolveRowActions.js +130 -0
- package/dist/esm/utils/resolveRowActions.js.map +1 -0
- package/dist/esm/utils/rowActionItem.js +104 -0
- package/dist/esm/utils/rowActionItem.js.map +1 -0
- package/dist/esm/utils/timelineEvents.js +44 -1
- package/dist/esm/utils/timelineEvents.js.map +1 -1
- package/dist/esm/utils/validateManifest.js +59 -3
- package/dist/esm/utils/validateManifest.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +1251 -232
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/l10n/en.json +3 -0
- package/l10n/nl.json +3 -0
- package/package.json +1 -1
- package/scripts/.jsdoc-baselines.json +2 -2
- package/src/components/CnContextMenu/CnContextMenu.vue +40 -29
- package/src/components/CnDashboardPage/CnDashboardPage.md +2 -0
- package/src/components/CnDashboardPage/CnDashboardPage.vue +34 -35
- package/src/components/CnDetailPage/CnDetailPage.md +6 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +67 -5
- package/src/components/CnFormDialog/CnFormDialog.vue +92 -18
- package/src/components/CnIndexPage/CnIndexPage.md +21 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +61 -51
- package/src/components/CnIndexPage/defaultActions.js +6 -7
- package/src/components/CnRowActions/CnRowActions.vue +36 -37
- package/src/components/CnSetupWizard/CnSetupWizard.vue +8 -2
- package/src/components/CnStatWidget/CnStatWidget.vue +2 -2
- package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +82 -3
- package/src/components/CnTimelineWidget/CnTimelineWidget.vue +3 -2
- package/src/composables/useContextMenu.js +4 -2
- package/src/mixins/pageViews.js +277 -10
- package/src/schemas/app-manifest-v2.schema.json +73 -9
- package/src/store/plugins/dashboardLayouts.js +34 -0
- package/src/utils/formatMetric.js +143 -15
- package/src/utils/resolveRowActions.js +127 -0
- package/src/utils/rowActionItem.js +100 -0
- package/src/utils/timelineEvents.js +43 -0
- package/src/utils/validateManifest.js +60 -3
- 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
|
|
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
|
|
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":";;;AAGY,MAAC,kBAAkB,GAAG,kBAAiB;AACvC,MAAC,kBAAkB,GAAG,kBAAiB;AACvC,MAAC,kBAAkB,GAAG,mBAAkB;AACpD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACY,MAAC,oBAAoB,GAAG,0BAAyB;AAC7D;AACA;AACA,MAAM,SAAS,GAAG,mBAAkB;AACpC,MAAM,SAAS,GAAG,mBAAkB;AACpC,MAAM,SAAS,GAAG,mBAAkB;AACpC;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,SAAS,2BAA2B,GAAG;AAC9C,CAAC,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC,cAAc,CAAC,SAAS,EAAC;AACzD,CAAC,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC,cAAc,CAAC,SAAS,EAAC;AACzD,CAAC,QAAQ,CAAC,eAAe,CAAC,eAAe,CAAC,SAAS,EAAC;AACpD,CAAC;AACD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,SAAS,cAAc,GAAG;AACjC,CAAC,MAAM,MAAM,GAAG,GAAG,CAAC,KAAK,EAAC;AAC1B,CAAC,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAC;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC,SAAS,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE;AAChC,EAAE,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC,WAAW,CAAC,SAAS,EAAE,KAAK,CAAC,OAAO,GAAG,IAAI,EAAC;AAC7E,EAAE,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC,WAAW,CAAC,SAAS,EAAE,KAAK,CAAC,OAAO,GAAG,IAAI,EAAC;AAC7E,EAAE,QAAQ,CAAC,eAAe,CAAC,YAAY,CAAC,SAAS,EAAE,EAAE,EAAC;AACtD,EAAE,UAAU,CAAC,KAAK,GAAG,KAAI;AACzB,EAAE,MAAM,CAAC,KAAK,GAAG,KAAI;AACrB,EAAE;AACF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC,SAAS,KAAK,GAAG;AAClB,EAAE,MAAM,CAAC,KAAK,GAAG,MAAK;AACtB,EAAE,UAAU,CAAC,KAAK,GAAG,KAAI;AACzB,EAAE;AACF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC,SAAS,gBAAgB,CAAC,MAAM,EAAE;AACnC,EAAE,IAAI,OAAO,MAAM,CAAC,QAAQ,KAAK,UAAU,EAAE;AAC7C,GAAG,OAAO,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,KAAK,CAAC;AAC3C,GAAG;AACH,EAAE,OAAO,CAAC,CAAC,MAAM,CAAC,QAAQ;AAC1B,EAAE;AACF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC,SAAS,aAAa,CAAC,MAAM,EAAE;AAChC,EAAE,IAAI,MAAM,CAAC,OAAO,IAAI,OAAO,MAAM,CAAC,OAAO,KAAK,UAAU,EAAE;AAC9D,GAAG,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,KAAK,EAAC;AACnC,GAAG;AACH,EAAE,OAAO,gBAAgB,CAAC,MAAM,EAAE,UAAU,CAAC,KAAK,CAAC;AACnD,EAAE;AACF;AACA;AACA;AACA,CAAC,eAAe,CAAC,MAAM;AACvB,EAAE,MAAM,CAAC,KAAK,GAAG,MAAK;AACtB,EAAE,UAAU,CAAC,KAAK,GAAG,KAAI;AACzB,EAAE,2BAA2B,GAAE;AAC/B,EAAE,EAAC;AACH;AACA,CAAC,OAAO;AACR,EAAE,MAAM;AACR,EAAE,UAAU;AACZ,EAAE,IAAI;AACN,EAAE,KAAK;AACP,EAAE,gBAAgB;AAClB,EAAE,aAAa;AACf,EAAE;AACF;;;;"}
|
package/dist/esm/l10n/en.json.js
CHANGED
|
@@ -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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
package/dist/esm/l10n/nl.json.js
CHANGED
|
@@ -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
|
|
38
|
-
*
|
|
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
|
|
43
|
-
* @spec openspec/changes/view-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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
|