@conduction/nextcloud-vue 2.72.0 → 2.73.1
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/CnAppRoot/CnAppRoot.vue2.js +29 -0
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +18 -0
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +9 -6
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +16 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +5 -4
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
- package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue2.js +25 -0
- package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue2.js.map +1 -1
- package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue3.js +3 -1
- package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue3.js.map +1 -1
- package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue2.js +36 -1
- package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue2.js.map +1 -1
- package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue3.js.map +1 -1
- package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue2.js +37 -0
- package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue2.js.map +1 -1
- package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue3.js +4 -2
- package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +16 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +6 -6
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js +53 -5
- package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js.map +1 -1
- package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js +1 -1
- package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js.map +1 -1
- package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js +133 -0
- package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js.map +1 -1
- package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue3.js +22 -14
- package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue3.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +15 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +3 -2
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue2.js +15 -1
- package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue2.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue3.js +2 -2
- package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue3.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/utils/notificationRuleLabel.js +132 -0
- package/dist/esm/utils/notificationRuleLabel.js.map +1 -0
- package/dist/nextcloud-vue.cjs.js +578 -47
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/package.json +1 -1
- package/scripts/openregister-reliance.js +206 -0
- package/src/components/CnAppRoot/CnAppRoot.md +1 -0
- package/src/components/CnAppRoot/CnAppRoot.vue +29 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +24 -3
- package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +22 -4
- package/src/components/CnFlowDetail/CnFlowDetail.vue +27 -0
- package/src/components/CnGraphCanvas/CnFlowNode.vue +36 -1
- package/src/components/CnGraphCanvas/CnGraphCanvas.vue +40 -1
- package/src/components/CnIndexPage/CnIndexPage.vue +17 -2
- package/src/components/CnNotificationPreferences/CnNotificationPreferences.vue +54 -6
- package/src/components/CnPageRenderer/CnPageRenderer.vue +147 -6
- package/src/components/CnTabsWidget/CnTabsWidget.vue +17 -2
- package/src/components/CnWidgetGrid/CnWidgetGrid.vue +19 -3
- package/src/index.js +2 -0
- package/src/utils/notificationRuleLabel.js +129 -0
- package/webpack/index.d.ts +16 -0
- package/webpack/index.js +70 -0
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
:modelValue="entry.enabled"
|
|
33
33
|
:disabled="entry.saving"
|
|
34
34
|
@update:modelValue="onToggle(entry, $event)">
|
|
35
|
-
{{ notificationLabel(entry
|
|
35
|
+
{{ notificationLabel(entry) }}
|
|
36
36
|
</NcCheckboxRadioSwitch>
|
|
37
37
|
<NcButton v-if="entry.source === 'user-override'"
|
|
38
38
|
variant="tertiary"
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
|
|
50
50
|
<script>
|
|
51
51
|
import axios from '@nextcloud/axios'
|
|
52
|
-
import { translate as t } from '@nextcloud/l10n'
|
|
52
|
+
import { getLanguage, translate as t } from '@nextcloud/l10n'
|
|
53
53
|
import { generateUrl } from '@nextcloud/router'
|
|
54
54
|
/**
|
|
55
55
|
* CnNotificationPreferences
|
|
@@ -82,6 +82,7 @@ import { generateUrl } from '@nextcloud/router'
|
|
|
82
82
|
import { NcAppSettingsSection, NcButton, NcCheckboxRadioSwitch, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
|
83
83
|
import BellOffOutline from 'vue-material-design-icons/BellOffOutline.vue'
|
|
84
84
|
import BellOutline from 'vue-material-design-icons/BellOutline.vue'
|
|
85
|
+
import { notificationRuleLabel } from '../../utils/notificationRuleLabel.js'
|
|
85
86
|
|
|
86
87
|
const PREFS_PATH = '/apps/openregister/api/notification-preferences'
|
|
87
88
|
|
|
@@ -106,6 +107,28 @@ export default {
|
|
|
106
107
|
* than an always-empty pane.
|
|
107
108
|
*/
|
|
108
109
|
cnAppId: { default: () => '' },
|
|
110
|
+
/**
|
|
111
|
+
* App-supplied labels for its notification rules, provided by the
|
|
112
|
+
* CnAppRoot ancestor from its `notificationLabels` prop. Keyed
|
|
113
|
+
* `<schema>.<key>` or `<key>`.
|
|
114
|
+
*/
|
|
115
|
+
cnNotificationLabels: { default: () => ({}) },
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
props: {
|
|
119
|
+
/**
|
|
120
|
+
* Labels for the notification rules, keyed `<schema>.<key>` or
|
|
121
|
+
* `<key>`; values are strings or per-locale maps (`{ nl, en }`).
|
|
122
|
+
* Optional: inside CnAppRoot the app's `notificationLabels` arrive by
|
|
123
|
+
* inject, and a rule without a label still reads as words, never as
|
|
124
|
+
* its raw key. A prop given here wins over the injected map.
|
|
125
|
+
*
|
|
126
|
+
* @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
|
|
127
|
+
*/
|
|
128
|
+
labels: {
|
|
129
|
+
type: Object,
|
|
130
|
+
default: null,
|
|
131
|
+
},
|
|
109
132
|
},
|
|
110
133
|
|
|
111
134
|
data() {
|
|
@@ -231,18 +254,43 @@ export default {
|
|
|
231
254
|
},
|
|
232
255
|
|
|
233
256
|
/**
|
|
234
|
-
* Human-readable label for a notification
|
|
257
|
+
* Human-readable label for a notification rule. Never the raw key: the
|
|
258
|
+
* rule's own label, then the app's, then the library's wording for the
|
|
259
|
+
* generic keys, then the key split into words.
|
|
235
260
|
*
|
|
236
|
-
* @param {
|
|
261
|
+
* @param {object} entry The preference row.
|
|
237
262
|
* @return {string} A display label.
|
|
263
|
+
*
|
|
264
|
+
* @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
|
|
238
265
|
*/
|
|
239
|
-
notificationLabel(
|
|
266
|
+
notificationLabel(entry) {
|
|
240
267
|
const known = {
|
|
241
268
|
object_created: t('nextcloud-vue', 'When an item is created'),
|
|
242
269
|
object_updated: t('nextcloud-vue', 'When an item is updated'),
|
|
243
270
|
object_transitioned: t('nextcloud-vue', 'When an item is assigned or changes status'),
|
|
244
271
|
}
|
|
245
|
-
|
|
272
|
+
const language = this.currentLanguage()
|
|
273
|
+
const injected = typeof this.cnNotificationLabels === 'function'
|
|
274
|
+
? this.cnNotificationLabels()
|
|
275
|
+
: this.cnNotificationLabels
|
|
276
|
+
return notificationRuleLabel(entry, {
|
|
277
|
+
labels: { ...(injected || {}), ...(this.labels || {}) },
|
|
278
|
+
known,
|
|
279
|
+
language,
|
|
280
|
+
})
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The person's language, `en` when it cannot be read.
|
|
285
|
+
*
|
|
286
|
+
* @return {string} The language code.
|
|
287
|
+
*/
|
|
288
|
+
currentLanguage() {
|
|
289
|
+
try {
|
|
290
|
+
return (typeof getLanguage === 'function' && getLanguage()) || 'en'
|
|
291
|
+
} catch {
|
|
292
|
+
return 'en'
|
|
293
|
+
}
|
|
246
294
|
},
|
|
247
295
|
|
|
248
296
|
/**
|
|
@@ -120,7 +120,8 @@
|
|
|
120
120
|
v-if="widgetsBySlot.has('body')"
|
|
121
121
|
:widgets="widgetsBySlot.get('body')"
|
|
122
122
|
:editable="bodyEditable"
|
|
123
|
-
slotName="body"
|
|
123
|
+
slotName="body"
|
|
124
|
+
@selectObject="onRelatedObjectOpen" />
|
|
124
125
|
<component
|
|
125
126
|
:is="resolvedComponent"
|
|
126
127
|
v-else-if="resolvedComponent"
|
|
@@ -130,6 +131,7 @@
|
|
|
130
131
|
@rowClick="onRowOpen"
|
|
131
132
|
@rowAuxClick="onRowOpen"
|
|
132
133
|
@editOpen="onRowOpen"
|
|
134
|
+
@relatedObjectClick="onRelatedObjectOpen"
|
|
133
135
|
@configure="showConfigModal = true">
|
|
134
136
|
<!-- This `<template v-for>` defines dynamic SLOTS, not a
|
|
135
137
|
rendered list, so the rule's advice is inverted here:
|
|
@@ -173,27 +175,32 @@
|
|
|
173
175
|
<CnWidgetGrid
|
|
174
176
|
v-if="widgetsBySlot.has('header-actions')"
|
|
175
177
|
:widgets="widgetsBySlot.get('header-actions')"
|
|
176
|
-
slotName="header-actions"
|
|
178
|
+
slotName="header-actions"
|
|
179
|
+
@selectObject="onRelatedObjectOpen" />
|
|
177
180
|
<!-- footer slot -->
|
|
178
181
|
<CnWidgetGrid
|
|
179
182
|
v-if="widgetsBySlot.has('footer')"
|
|
180
183
|
:widgets="widgetsBySlot.get('footer')"
|
|
181
|
-
slotName="footer"
|
|
184
|
+
slotName="footer"
|
|
185
|
+
@selectObject="onRelatedObjectOpen" />
|
|
182
186
|
<!-- modal slot -->
|
|
183
187
|
<CnWidgetGrid
|
|
184
188
|
v-if="widgetsBySlot.has('modal')"
|
|
185
189
|
:widgets="widgetsBySlot.get('modal')"
|
|
186
|
-
slotName="modal"
|
|
190
|
+
slotName="modal"
|
|
191
|
+
@selectObject="onRelatedObjectOpen" />
|
|
187
192
|
<!-- sidebar slot (gated by cnPageSidebarVisible) -->
|
|
188
193
|
<CnWidgetGrid
|
|
189
194
|
v-if="widgetsBySlot.has('sidebar') && pageSidebarVisibleValue"
|
|
190
195
|
:widgets="widgetsBySlot.get('sidebar')"
|
|
191
|
-
slotName="sidebar"
|
|
196
|
+
slotName="sidebar"
|
|
197
|
+
@selectObject="onRelatedObjectOpen" />
|
|
192
198
|
<!-- dynamic tab:* and section:* slots -->
|
|
193
199
|
<template v-for="dynamicSlot in dynamicSlotKeys" :key="dynamicSlot">
|
|
194
200
|
<CnWidgetGrid
|
|
195
201
|
:widgets="widgetsBySlot.get(dynamicSlot)"
|
|
196
|
-
:slotName="dynamicSlot"
|
|
202
|
+
:slotName="dynamicSlot"
|
|
203
|
+
@selectObject="onRelatedObjectOpen" />
|
|
197
204
|
</template>
|
|
198
205
|
</template>
|
|
199
206
|
|
|
@@ -206,6 +213,7 @@
|
|
|
206
213
|
@rowClick="onRowOpen"
|
|
207
214
|
@rowAuxClick="onRowOpen"
|
|
208
215
|
@editOpen="onRowOpen"
|
|
216
|
+
@relatedObjectClick="onRelatedObjectOpen"
|
|
209
217
|
@configure="showConfigModal = true">
|
|
210
218
|
<!-- Dynamic slot definition, not a rendered list — see the note on
|
|
211
219
|
the identical block above. -->
|
|
@@ -300,6 +308,53 @@ import { resolveRouteSentinels } from '../../utils/resolveRouteSentinels.js'
|
|
|
300
308
|
import { parseSortKeys } from '../../utils/routeFilters.js'
|
|
301
309
|
import { buildRouteParams, routePathFor } from '../../utils/routeParams.js'
|
|
302
310
|
import { isNewTabHandled } from '../../utils/rowAuxClick.js'
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* OpenRegister registers and schemas already described by id, so a click on
|
|
314
|
+
* a related object costs at most one request per register or schema for as
|
|
315
|
+
* long as this module stays loaded (the SPA's lifetime, across page
|
|
316
|
+
* changes). Keyed by `${kind}:${id}`; a failed lookup is dropped again so the
|
|
317
|
+
* next click retries it.
|
|
318
|
+
*
|
|
319
|
+
* @type {Map<string, Promise<{ id: string, slug: string }>>}
|
|
320
|
+
*/
|
|
321
|
+
const entityDescriptorCache = new Map()
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Describe an OpenRegister register or schema (`{ id, slug }`) by its id or
|
|
325
|
+
* slug, or null when it cannot be fetched. An object's `@self` names its
|
|
326
|
+
* register and schema by id while a manifest may name them by slug; this is
|
|
327
|
+
* the bridge. Lazy-imports axios and the router helper like the widgets do.
|
|
328
|
+
*
|
|
329
|
+
* @param {'registers'|'schemas'} kind The collection to ask.
|
|
330
|
+
* @param {string|number} idOrSlug The id (or slug) to describe.
|
|
331
|
+
* @return {Promise<{ id: string, slug: string }|null>} The descriptor.
|
|
332
|
+
*/
|
|
333
|
+
function describeOpenRegisterEntity(kind, idOrSlug) {
|
|
334
|
+
const key = `${kind}:${idOrSlug}`
|
|
335
|
+
if (!entityDescriptorCache.has(key)) {
|
|
336
|
+
entityDescriptorCache.set(key, (async () => {
|
|
337
|
+
try {
|
|
338
|
+
const [{ default: axios }, { generateUrl }] = await Promise.all([
|
|
339
|
+
import('@nextcloud/axios'),
|
|
340
|
+
import('@nextcloud/router'),
|
|
341
|
+
])
|
|
342
|
+
const url = generateUrl('/apps/openregister/api/{kind}/{id}', { kind, id: String(idOrSlug) })
|
|
343
|
+
const res = await axios.get(url)
|
|
344
|
+
const data = res && res.data
|
|
345
|
+
if (!data || typeof data !== 'object') {
|
|
346
|
+
throw new Error(`no descriptor for ${key}`)
|
|
347
|
+
}
|
|
348
|
+
return { id: String(data.id ?? idOrSlug), slug: String(data.slug ?? '') }
|
|
349
|
+
} catch (error) {
|
|
350
|
+
// One failed request must not pin every later click to nowhere.
|
|
351
|
+
entityDescriptorCache.delete(key)
|
|
352
|
+
throw error
|
|
353
|
+
}
|
|
354
|
+
})())
|
|
355
|
+
}
|
|
356
|
+
return entityDescriptorCache.get(key).catch(() => null)
|
|
357
|
+
}
|
|
303
358
|
import { CnMassExportDialog } from '../CnMassExportDialog/index.js'
|
|
304
359
|
import { defaultPageTypes } from './pageTypes.js'
|
|
305
360
|
|
|
@@ -1830,6 +1885,92 @@ export default {
|
|
|
1830
1885
|
})
|
|
1831
1886
|
},
|
|
1832
1887
|
|
|
1888
|
+
/**
|
|
1889
|
+
* Open the detail page of an object clicked in a Related widget —
|
|
1890
|
+
* reaching here as `CnDetailPage`'s `related-object-click`, or as
|
|
1891
|
+
* `CnWidgetGrid`'s `select-object` on a v2 widget-grid page. Nothing
|
|
1892
|
+
* listened to either, so the rows looked like links and went nowhere.
|
|
1893
|
+
*
|
|
1894
|
+
* @param {object} raw The clicked object as the Related widget holds it.
|
|
1895
|
+
* @return {Promise<void>}
|
|
1896
|
+
*/
|
|
1897
|
+
async onRelatedObjectOpen(raw) {
|
|
1898
|
+
const target = await this.relatedObjectTarget(raw)
|
|
1899
|
+
if (target) {
|
|
1900
|
+
this.$router.push(target).catch(() => {})
|
|
1901
|
+
}
|
|
1902
|
+
},
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Where a related object opens: the first `type: 'detail'` page bound
|
|
1906
|
+
* to the object's register and schema, with the id in whichever param
|
|
1907
|
+
* that page's route declares. The object's `@self` names register and
|
|
1908
|
+
* schema by id and a manifest may name them by slug, so a miss on the
|
|
1909
|
+
* literal pair describes both (one request each, cached) and matches
|
|
1910
|
+
* again. Null, silently, when there is no router or the object carries
|
|
1911
|
+
* no register, schema or id (nothing to open); null with a warning,
|
|
1912
|
+
* like a dead row click, when no detail page matches or the router
|
|
1913
|
+
* lacks the route.
|
|
1914
|
+
*
|
|
1915
|
+
* @param {object} raw The clicked object.
|
|
1916
|
+
* @return {Promise<object|null>} The router location, or null.
|
|
1917
|
+
*/
|
|
1918
|
+
async relatedObjectTarget(raw) {
|
|
1919
|
+
const router = this.$router
|
|
1920
|
+
if (!router || !raw || typeof raw !== 'object') {
|
|
1921
|
+
return null
|
|
1922
|
+
}
|
|
1923
|
+
const self = raw['@self'] || {}
|
|
1924
|
+
const id = raw.id ?? self.id ?? self.uuid ?? raw.uuid
|
|
1925
|
+
const register = self.register ?? raw.register
|
|
1926
|
+
const schema = self.schema ?? raw.schema
|
|
1927
|
+
const missing = (v) => v === undefined || v === null || v === ''
|
|
1928
|
+
if (missing(id) || missing(register) || missing(schema)) {
|
|
1929
|
+
return null
|
|
1930
|
+
}
|
|
1931
|
+
let page = this.detailPageFor([String(register)], [String(schema)])
|
|
1932
|
+
if (!page) {
|
|
1933
|
+
const [reg, sch] = await Promise.all([
|
|
1934
|
+
describeOpenRegisterEntity('registers', register),
|
|
1935
|
+
describeOpenRegisterEntity('schemas', schema),
|
|
1936
|
+
])
|
|
1937
|
+
page = this.detailPageFor(
|
|
1938
|
+
[String(register), reg?.id, reg?.slug].filter(Boolean),
|
|
1939
|
+
[String(schema), sch?.id, sch?.slug].filter(Boolean),
|
|
1940
|
+
)
|
|
1941
|
+
}
|
|
1942
|
+
if (!page) {
|
|
1943
|
+
// eslint-disable-next-line no-console
|
|
1944
|
+
console.warn(`[CnPageRenderer] No detail page is bound to register "${register}" and schema "${schema}", so related object "${id}" opens nowhere.`)
|
|
1945
|
+
return null
|
|
1946
|
+
}
|
|
1947
|
+
if (this.routeNameIsKnown(page.id) === false) {
|
|
1948
|
+
// eslint-disable-next-line no-console
|
|
1949
|
+
console.warn(`[CnPageRenderer] Detail page "${page.id}" is not a route the router has, so related object "${id}" opens nowhere.`)
|
|
1950
|
+
return null
|
|
1951
|
+
}
|
|
1952
|
+
const path = page.route ?? routePathFor(router, page.id)
|
|
1953
|
+
return { name: page.id, params: buildRouteParams(path, id, this.$route?.params) }
|
|
1954
|
+
},
|
|
1955
|
+
|
|
1956
|
+
/**
|
|
1957
|
+
* The first `type: 'detail'` page whose register and schema are both
|
|
1958
|
+
* among the given names (ids or slugs, compared as strings).
|
|
1959
|
+
*
|
|
1960
|
+
* @param {Array<string>} registerNames Names the register goes by.
|
|
1961
|
+
* @param {Array<string>} schemaNames Names the schema goes by.
|
|
1962
|
+
* @return {object|null} The page, or null.
|
|
1963
|
+
*/
|
|
1964
|
+
detailPageFor(registerNames, schemaNames) {
|
|
1965
|
+
for (const page of this.detailPageByRegisterSchema.values()) {
|
|
1966
|
+
const cfg = page.config || {}
|
|
1967
|
+
if (registerNames.includes(String(cfg.register)) && schemaNames.includes(String(cfg.schema))) {
|
|
1968
|
+
return page
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
return null
|
|
1972
|
+
},
|
|
1973
|
+
|
|
1833
1974
|
/**
|
|
1834
1975
|
* Whether the router has a route by this name.
|
|
1835
1976
|
*
|
|
@@ -88,7 +88,8 @@
|
|
|
88
88
|
:integrationContext="integrationContext"
|
|
89
89
|
:cnRegistry="cnRegistry"
|
|
90
90
|
@geoSaved="onGeoSaved"
|
|
91
|
-
@openIntegration="onOpenIntegration"
|
|
91
|
+
@openIntegration="onOpenIntegration"
|
|
92
|
+
@selectObject="onSelectObject" />
|
|
92
93
|
<NcEmptyContent v-else :name="missingLabel(entry)" />
|
|
93
94
|
</CnTab>
|
|
94
95
|
</CnTabs>
|
|
@@ -357,7 +358,7 @@ export default {
|
|
|
357
358
|
},
|
|
358
359
|
},
|
|
359
360
|
|
|
360
|
-
emits: ['geo-saved', 'open-integration'],
|
|
361
|
+
emits: ['geo-saved', 'open-integration', 'select-object'],
|
|
361
362
|
|
|
362
363
|
data() {
|
|
363
364
|
return {
|
|
@@ -530,6 +531,20 @@ export default {
|
|
|
530
531
|
this.$emit('open-integration', integrationId)
|
|
531
532
|
},
|
|
532
533
|
|
|
534
|
+
/**
|
|
535
|
+
* Re-emit a related child's object click; the page opens the object.
|
|
536
|
+
*
|
|
537
|
+
* @param {object} raw The clicked object.
|
|
538
|
+
* @return {void}
|
|
539
|
+
*/
|
|
540
|
+
onSelectObject(raw) {
|
|
541
|
+
/**
|
|
542
|
+
* @event select-object Re-emitted from a related child whose object row was clicked. Payload is the raw object.
|
|
543
|
+
* @type {object}
|
|
544
|
+
*/
|
|
545
|
+
this.$emit('select-object', raw)
|
|
546
|
+
},
|
|
547
|
+
|
|
533
548
|
/**
|
|
534
549
|
* Follow the strip's own selection, so keyboard navigation moves the
|
|
535
550
|
* hoisted Actions menu too.
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
<div class="grid-stack-item-content">
|
|
40
40
|
<component
|
|
41
41
|
:is="widget.component"
|
|
42
|
-
v-bind="widget.props"
|
|
42
|
+
v-bind="widget.props"
|
|
43
|
+
@selectObject="onSelectObject" />
|
|
43
44
|
</div>
|
|
44
45
|
</div>
|
|
45
46
|
</div>
|
|
@@ -58,7 +59,8 @@
|
|
|
58
59
|
:style="cnGridCellStyle(widget, gridColumns)">
|
|
59
60
|
<component
|
|
60
61
|
:is="widget.component"
|
|
61
|
-
v-bind="widget.props"
|
|
62
|
+
v-bind="widget.props"
|
|
63
|
+
@selectObject="onSelectObject" />
|
|
62
64
|
</div>
|
|
63
65
|
</template>
|
|
64
66
|
</div>
|
|
@@ -161,7 +163,7 @@ export default {
|
|
|
161
163
|
},
|
|
162
164
|
},
|
|
163
165
|
|
|
164
|
-
emits: ['layout-change'],
|
|
166
|
+
emits: ['layout-change', 'select-object'],
|
|
165
167
|
|
|
166
168
|
data() {
|
|
167
169
|
return {
|
|
@@ -371,6 +373,20 @@ export default {
|
|
|
371
373
|
},
|
|
372
374
|
|
|
373
375
|
methods: {
|
|
376
|
+
/**
|
|
377
|
+
* Pass a related widget's object click up; the page opens the object.
|
|
378
|
+
*
|
|
379
|
+
* @param {object} raw The clicked object.
|
|
380
|
+
* @return {void}
|
|
381
|
+
*/
|
|
382
|
+
onSelectObject(raw) {
|
|
383
|
+
/**
|
|
384
|
+
* @event select-object An object row in a related widget of this grid was clicked. Payload is the raw object.
|
|
385
|
+
* @type {object}
|
|
386
|
+
*/
|
|
387
|
+
this.$emit('select-object', raw)
|
|
388
|
+
},
|
|
389
|
+
|
|
374
390
|
// Expose the shared grid helpers to the template.
|
|
375
391
|
cnGridCellStyle,
|
|
376
392
|
hasGridRow,
|
package/src/index.js
CHANGED
|
@@ -476,6 +476,8 @@ export {
|
|
|
476
476
|
} from './utils/folderCustomization.js'
|
|
477
477
|
export { currentTheme, useCurrentTheme } from './composables/useCurrentTheme.js'
|
|
478
478
|
export { ROADMAP_LABEL_BLOCKLIST } from './utils/roadmapLabelBlocklist.js'
|
|
479
|
+
// The words a person reads for a notification rule key (never the raw key).
|
|
480
|
+
export { humaniseRuleKey, notificationRuleLabel } from './utils/notificationRuleLabel.js'
|
|
479
481
|
|
|
480
482
|
// Shared field validators. These are UX affordances that run in the browser —
|
|
481
483
|
// they do NOT replace server-side validation, which stays authoritative at the
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*
|
|
5
|
+
* The label a person reads for one notification rule in their settings.
|
|
6
|
+
*
|
|
7
|
+
* A rule is declared in a schema's `x-openregister-notifications` map under a
|
|
8
|
+
* KEY (`caseAssigned`, `clientUpdated`, `onCreate`). The key is an identifier
|
|
9
|
+
* for a developer. The settings pane used to print it as is, so a person saw
|
|
10
|
+
* "substitutionRegisteredForSubstitute" next to a switch and had to guess.
|
|
11
|
+
*
|
|
12
|
+
* The label is taken from the first source that has one, most specific first:
|
|
13
|
+
*
|
|
14
|
+
* 1. the rule's own words, when OpenRegister passes them on the preference
|
|
15
|
+
* entry (`label`, then `title`), as a plain string or a per-locale map
|
|
16
|
+
* like the dialect's `subject` (`{ "nl": "…", "en": "…" }`);
|
|
17
|
+
* 2. the app's own label for the rule, from the `labels` map the app hands
|
|
18
|
+
* to CnAppRoot (`notificationLabels`), keyed `<schema>.<key>` or `<key>`;
|
|
19
|
+
* 3. the library's wording for the generic object-event keys;
|
|
20
|
+
* 4. the key made readable: `caseAssigned` becomes "Case assigned".
|
|
21
|
+
*
|
|
22
|
+
* The rule's `subject` is used only when it carries no `{{placeholder}}`: it
|
|
23
|
+
* is the notification TITLE template, and "Case "{{title}}" assigned to you"
|
|
24
|
+
* reads worse as a setting than the readable key does.
|
|
25
|
+
*
|
|
26
|
+
* Pure: no store, no fetch, no Vue.
|
|
27
|
+
*
|
|
28
|
+
* @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pick the string for the current language out of a string or per-locale map.
|
|
33
|
+
*
|
|
34
|
+
* @param {string|object|null|undefined} value A string, or `{ nl, en, … }`.
|
|
35
|
+
* @param {string} [language] The current language, e.g. `nl` or `de_DE`.
|
|
36
|
+
* @return {string} The resolved text, or '' when there is none.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveLocalised(value, language = 'en') {
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
return value.trim()
|
|
41
|
+
}
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
43
|
+
return ''
|
|
44
|
+
}
|
|
45
|
+
const lang = String(language || 'en')
|
|
46
|
+
const base = lang.split(/[-_]/)[0]
|
|
47
|
+
for (const candidate of [lang, base, 'en']) {
|
|
48
|
+
if (typeof value[candidate] === 'string' && value[candidate].trim() !== '') {
|
|
49
|
+
return value[candidate].trim()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const first = Object.values(value).find((text) => typeof text === 'string' && text.trim() !== '')
|
|
53
|
+
return first ? first.trim() : ''
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Make a rule key readable: split camelCase, snake_case and kebab-case into
|
|
58
|
+
* words, keep acronyms, and capitalise only the first word.
|
|
59
|
+
*
|
|
60
|
+
* `caseAssigned` → "Case assigned", `object_created` → "Object created",
|
|
61
|
+
* `newBRPRecord` → "New BRP record".
|
|
62
|
+
*
|
|
63
|
+
* @param {string} key The rule key.
|
|
64
|
+
* @return {string} A readable sentence-case label, or the key when it has no words.
|
|
65
|
+
*/
|
|
66
|
+
export function humaniseRuleKey(key) {
|
|
67
|
+
const words = String(key || '')
|
|
68
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
69
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
70
|
+
.split(/[\s_\-.]+/)
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
if (words.length === 0) {
|
|
73
|
+
return String(key || '')
|
|
74
|
+
}
|
|
75
|
+
return words
|
|
76
|
+
.map((word, index) => {
|
|
77
|
+
const isAcronym = word.length > 1 && word === word.toUpperCase() && /[A-Z]/.test(word)
|
|
78
|
+
if (isAcronym) {
|
|
79
|
+
return word
|
|
80
|
+
}
|
|
81
|
+
const lower = word.toLowerCase()
|
|
82
|
+
return index === 0 ? lower.charAt(0).toUpperCase() + lower.slice(1) : lower
|
|
83
|
+
})
|
|
84
|
+
.join(' ')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The label for one preference entry.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} entry A preference entry: `{ schema, notification, label?, title?, subject? }`.
|
|
91
|
+
* @param {object} [options] Where else a label may come from.
|
|
92
|
+
* @param {object} [options.labels] App-supplied labels keyed `<schema>.<key>` or `<key>`;
|
|
93
|
+
* values are strings or per-locale maps.
|
|
94
|
+
* @param {object} [options.known] Library wording for generic keys, keyed by rule key.
|
|
95
|
+
* @param {string} [options.language] The current language.
|
|
96
|
+
* @return {string} The label to show.
|
|
97
|
+
*/
|
|
98
|
+
export function notificationRuleLabel(entry, { labels = {}, known = {}, language = 'en' } = {}) {
|
|
99
|
+
const key = String(entry?.notification ?? '')
|
|
100
|
+
const schema = String(entry?.schema ?? '')
|
|
101
|
+
|
|
102
|
+
for (const field of ['label', 'title']) {
|
|
103
|
+
const own = resolveLocalised(entry?.[field], language)
|
|
104
|
+
if (own) {
|
|
105
|
+
return own
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const map = labels && typeof labels === 'object' ? labels : {}
|
|
110
|
+
for (const lookup of [schema ? `${schema}.${key}` : '', key]) {
|
|
111
|
+
if (lookup && Object.hasOwn(map, lookup)) {
|
|
112
|
+
const fromApp = resolveLocalised(map[lookup], language)
|
|
113
|
+
if (fromApp) {
|
|
114
|
+
return fromApp
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (known && typeof known[key] === 'string' && known[key]) {
|
|
120
|
+
return known[key]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const subject = resolveLocalised(entry?.subject, language)
|
|
124
|
+
if (subject && !subject.includes('{{')) {
|
|
125
|
+
return subject
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return humaniseRuleKey(key)
|
|
129
|
+
}
|
package/webpack/index.d.ts
CHANGED
|
@@ -28,3 +28,19 @@ export interface CnPublicPathOptions {
|
|
|
28
28
|
* @param options Where the public path is read from at runtime.
|
|
29
29
|
*/
|
|
30
30
|
export function withPublicPath<T extends object | object[]>(config: T, options?: CnPublicPathOptions): T
|
|
31
|
+
|
|
32
|
+
export interface CnAppVersionDefineOptions {
|
|
33
|
+
/** The initial-state key the app's page provides its version under. Defaults to `version`. */
|
|
34
|
+
key?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A `webpack.DefinePlugin` expression for `appVersion` that reads the
|
|
39
|
+
* installed app version from the page's initial state at runtime, falling
|
|
40
|
+
* back to `fallback` (the build-time version) when the state is absent.
|
|
41
|
+
*
|
|
42
|
+
* @param appId The app id.
|
|
43
|
+
* @param fallback The build-time version.
|
|
44
|
+
* @param options Which initial-state key to read.
|
|
45
|
+
*/
|
|
46
|
+
export function appVersionDefine(appId: string, fallback?: string, options?: CnAppVersionDefineOptions): string
|
package/webpack/index.js
CHANGED
|
@@ -116,7 +116,77 @@ function withPublicPath(config, options = {}) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The `appVersion` define, resolved in the browser from the INSTALLED app.
|
|
121
|
+
*
|
|
122
|
+
* THE PROBLEM
|
|
123
|
+
* -----------
|
|
124
|
+
* `@nextcloud/vue` prints `<app name> <appVersion>` in the footer of every
|
|
125
|
+
* NcAppSettingsDialog, and reads `appVersion` as a global the app's webpack
|
|
126
|
+
* config defines. Apps defined it from `package.json` (a `0.1.0` nobody bumps,
|
|
127
|
+
* so pipelinq read "pipelinq 0.1.0") or from `appinfo/info.xml` at build time.
|
|
128
|
+
* The second is still wrong on every release: the release workflow writes the
|
|
129
|
+
* release version into `info.xml` after the bundle is built, so dossiq
|
|
130
|
+
* 0.4.48-beta said "dossiq 0.4.47-unstable". A build cannot know the version
|
|
131
|
+
* it will be installed as.
|
|
132
|
+
*
|
|
133
|
+
* THE FIX
|
|
134
|
+
* -------
|
|
135
|
+
* Define `appVersion` as an EXPRESSION, not a literal. It runs once in the
|
|
136
|
+
* browser and reads the app's `version` initial state, which the app's page
|
|
137
|
+
* controller provides from what Nextcloud has installed
|
|
138
|
+
* (`IAppConfig::getValueString($appId, 'installed_version')`). It reads the
|
|
139
|
+
* same hidden input `@nextcloud/initial-state` `loadState()` reads; it cannot
|
|
140
|
+
* import that package because a define is pasted into every module as code.
|
|
141
|
+
* When the state is missing (a page that does not provide it, a test, a
|
|
142
|
+
* worker without a document) it falls back to the build-time version.
|
|
143
|
+
*
|
|
144
|
+
* USAGE
|
|
145
|
+
* -----
|
|
146
|
+
* ```js
|
|
147
|
+
* // webpack.config.js
|
|
148
|
+
* const { appVersionDefine } = require('@conduction/nextcloud-vue/webpack')
|
|
149
|
+
* new webpack.DefinePlugin({ appVersion: appVersionDefine('dossiq', readAppVersion()) })
|
|
150
|
+
* ```
|
|
151
|
+
* ```php
|
|
152
|
+
* // the controller that renders the app's page
|
|
153
|
+
* $this->initialState->provideInitialState('version', $this->appConfig->getValueString('dossiq', 'installed_version', ''));
|
|
154
|
+
* ```
|
|
155
|
+
*
|
|
156
|
+
* @param {string} appId The app id, as used for its initial state.
|
|
157
|
+
* @param {string} [fallback] The version to show when no initial state is
|
|
158
|
+
* present, normally the build-time `info.xml` version.
|
|
159
|
+
* @param {object} [options] Options.
|
|
160
|
+
* @param {string} [options.key] The initial-state key. Defaults to `version`.
|
|
161
|
+
*
|
|
162
|
+
* @return {string} A JavaScript expression for `webpack.DefinePlugin`.
|
|
163
|
+
*
|
|
164
|
+
* @throws {TypeError} When `appId` is empty.
|
|
165
|
+
*
|
|
166
|
+
* @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
|
|
167
|
+
*/
|
|
168
|
+
function appVersionDefine(appId, fallback = '', options = {}) {
|
|
169
|
+
const id = String(appId || '').trim()
|
|
170
|
+
if (id === '') {
|
|
171
|
+
throw new TypeError('appVersionDefine() needs the app id whose installed version it reads')
|
|
172
|
+
}
|
|
173
|
+
const key = String(options.key || 'version')
|
|
174
|
+
const elementId = JSON.stringify('initial-state-' + id + '-' + key)
|
|
175
|
+
const fallbackLiteral = JSON.stringify(String(fallback ?? ''))
|
|
176
|
+
return '(function () {'
|
|
177
|
+
+ ' try {'
|
|
178
|
+
+ ' var el = typeof document !== "undefined" && document.getElementById(' + elementId + ');'
|
|
179
|
+
+ ' if (el && el.value) {'
|
|
180
|
+
+ ' var v = JSON.parse(atob(el.value));'
|
|
181
|
+
+ ' if (typeof v === "string" && v !== "") { return v; }'
|
|
182
|
+
+ ' }'
|
|
183
|
+
+ ' } catch (e) {}'
|
|
184
|
+
+ ' return ' + fallbackLiteral + ';'
|
|
185
|
+
+ ' })()'
|
|
186
|
+
}
|
|
187
|
+
|
|
119
188
|
module.exports = {
|
|
120
189
|
AUTO_PUBLIC_PATH,
|
|
190
|
+
appVersionDefine,
|
|
121
191
|
withPublicPath,
|
|
122
192
|
}
|