@conduction/nextcloud-vue 2.56.0-beta.48 → 2.56.0-beta.49
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 +46 -28
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +17 -10
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js.map +1 -1
- package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +56 -16
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue.js +9 -0
- package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue.js.map +1 -0
- package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue2.js +240 -0
- package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue3.js +65 -0
- package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnDeviceFramesWidget/index.js +30 -0
- package/dist/esm/components/CnDeviceFramesWidget/index.js.map +1 -0
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +17 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +30 -31
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue.js +6 -0
- package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue.js.map +1 -0
- package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue2.js +90 -0
- package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue2.js.map +1 -0
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +48 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +20 -2
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue.js +1 -1
- package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js +230 -25
- package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js.map +1 -1
- package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js +447 -221
- package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +18 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +18 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/nextcloud-vue.css +356 -114
- package/dist/esm/utils/libraryWidgetKeys.js +1 -0
- package/dist/esm/utils/libraryWidgetKeys.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 +6964 -5943
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +356 -114
- package/l10n/en.json +18 -1
- package/l10n/nl.json +18 -1
- package/package.json +1 -1
- package/src/components/CnAppRoot/CnAppRoot.vue +20 -0
- package/src/components/CnBrandBar/CnBrandBar.vue +5 -4
- package/src/components/CnDetailPage/CnDetailPage.vue +55 -15
- package/src/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue +360 -0
- package/src/components/CnDeviceFramesWidget/index.js +29 -0
- package/src/components/CnFactsWidget/CnFactsWidget.vue +46 -2
- package/src/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue +107 -0
- package/src/components/CnRecordListWidget/CnRecordListWidget.vue +81 -1
- package/src/components/CnRegisterMapping/CnRegisterMapping.md +10 -0
- package/src/components/CnRegisterMapping/CnRegisterMapping.vue +511 -28
- package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/src/components/index.js +1 -0
- package/src/index.js +1 -0
- package/src/schemas/app-manifest-v2.schema.json +6 -1
- package/src/utils/libraryWidgetKeys.js +1 -0
- package/src/utils/validateManifestV2.compiled.js +2 -2
package/l10n/en.json
CHANGED
|
@@ -1517,7 +1517,12 @@
|
|
|
1517
1517
|
"Your account": "Your account",
|
|
1518
1518
|
"Only for you. Colleagues keep their own settings.": "Only for you. Colleagues keep their own settings.",
|
|
1519
1519
|
"Sections of the settings": "Sections of the settings",
|
|
1520
|
-
"Changes are saved straight away": "Changes are saved straight away"
|
|
1520
|
+
"Changes are saved straight away": "Changes are saved straight away",
|
|
1521
|
+
"Change the schema for {type}": "Change the schema for {type}",
|
|
1522
|
+
"Show all types": "Show all types",
|
|
1523
|
+
"All types set up": "All types set up",
|
|
1524
|
+
"Set up": "Set up",
|
|
1525
|
+
"{configured} of {total} set up": "{configured} of {total} set up"
|
|
1521
1526
|
},
|
|
1522
1527
|
"plurals": {
|
|
1523
1528
|
"{count} selected": [
|
|
@@ -1599,6 +1604,18 @@
|
|
|
1599
1604
|
"{count} more filter": [
|
|
1600
1605
|
"{count} more filter",
|
|
1601
1606
|
"{count} more filters"
|
|
1607
|
+
],
|
|
1608
|
+
"%n type": [
|
|
1609
|
+
"%n type",
|
|
1610
|
+
"%n types"
|
|
1611
|
+
],
|
|
1612
|
+
"And %n more type in this group, set up.": [
|
|
1613
|
+
"And %n more type in this group, set up.",
|
|
1614
|
+
"And %n more types in this group, all set up."
|
|
1615
|
+
],
|
|
1616
|
+
"And %n more type in this group.": [
|
|
1617
|
+
"And %n more type in this group.",
|
|
1618
|
+
"And %n more types in this group."
|
|
1602
1619
|
]
|
|
1603
1620
|
}
|
|
1604
1621
|
}
|
package/l10n/nl.json
CHANGED
|
@@ -1703,7 +1703,12 @@
|
|
|
1703
1703
|
"Your account": "Uw account",
|
|
1704
1704
|
"Only for you. Colleagues keep their own settings.": "Alleen voor jou. Collega's houden hun eigen instellingen.",
|
|
1705
1705
|
"Sections of the settings": "Onderdelen van de instellingen",
|
|
1706
|
-
"Changes are saved straight away": "Wijzigingen worden meteen bewaard"
|
|
1706
|
+
"Changes are saved straight away": "Wijzigingen worden meteen bewaard",
|
|
1707
|
+
"Change the schema for {type}": "Schema wijzigen voor {type}",
|
|
1708
|
+
"Show all types": "Alle soorten tonen",
|
|
1709
|
+
"All types set up": "Alle soorten ingesteld",
|
|
1710
|
+
"Set up": "Ingesteld",
|
|
1711
|
+
"{configured} of {total} set up": "{configured} van {total} ingesteld"
|
|
1707
1712
|
},
|
|
1708
1713
|
"plurals": {
|
|
1709
1714
|
"{count} selected": [
|
|
@@ -1785,6 +1790,18 @@
|
|
|
1785
1790
|
"{count} more filter": [
|
|
1786
1791
|
"Nog {count} filter",
|
|
1787
1792
|
"Nog {count} filters"
|
|
1793
|
+
],
|
|
1794
|
+
"%n type": [
|
|
1795
|
+
"%n soort",
|
|
1796
|
+
"%n soorten"
|
|
1797
|
+
],
|
|
1798
|
+
"And %n more type in this group, set up.": [
|
|
1799
|
+
"En nog %n soort in deze groep, ingesteld.",
|
|
1800
|
+
"En nog %n soorten in deze groep, allemaal ingesteld."
|
|
1801
|
+
],
|
|
1802
|
+
"And %n more type in this group.": [
|
|
1803
|
+
"En nog %n soort in deze groep.",
|
|
1804
|
+
"En nog %n soorten in deze groep."
|
|
1788
1805
|
]
|
|
1789
1806
|
}
|
|
1790
1807
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conduction/nextcloud-vue",
|
|
3
|
-
"version": "2.56.0-beta.
|
|
3
|
+
"version": "2.56.0-beta.49",
|
|
4
4
|
"description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
|
|
5
5
|
"homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -193,6 +193,8 @@
|
|
|
193
193
|
<template v-else>
|
|
194
194
|
<!-- The brand block in Nextcloud's own header (board look); teleported, renders nothing here. -->
|
|
195
195
|
<CnBrandBar v-if="brandInHeader" :brand="resolvedBrandBlock" @unavailable="headerMissing = true" />
|
|
196
|
+
<!-- The app's own line on the header's search field (board look); renders nothing here. -->
|
|
197
|
+
<CnHeaderSearchLabel v-if="headerSearchLabel" :label="headerSearchLabel" />
|
|
196
198
|
<!--
|
|
197
199
|
@slot menu
|
|
198
200
|
@description Left-rail navigation surface. Default:
|
|
@@ -713,6 +715,7 @@ import CnCommandPalette from '../CnCommandPalette/CnCommandPalette.vue'
|
|
|
713
715
|
import CnCredentials from '../CnCredentials/CnCredentials.vue'
|
|
714
716
|
import CnDependencyMissing from '../CnDependencyMissing/CnDependencyMissing.vue'
|
|
715
717
|
import CnEnvironmentBanner from '../CnEnvironmentBanner/CnEnvironmentBanner.vue'
|
|
718
|
+
import CnHeaderSearchLabel from '../CnHeaderSearchLabel/CnHeaderSearchLabel.vue'
|
|
716
719
|
import CnNotificationPreferences from '../CnNotificationPreferences/CnNotificationPreferences.vue'
|
|
717
720
|
import CnObjectSidebar from '../CnObjectSidebar/CnObjectSidebar.vue'
|
|
718
721
|
import CnSetupWizard from '../CnSetupWizard/CnSetupWizard.vue'
|
|
@@ -819,6 +822,7 @@ export default {
|
|
|
819
822
|
|
|
820
823
|
components: {
|
|
821
824
|
CnBrandBar,
|
|
825
|
+
CnHeaderSearchLabel,
|
|
822
826
|
NcAppContent,
|
|
823
827
|
NcAppSettingsDialog,
|
|
824
828
|
NcAppSettingsSection,
|
|
@@ -2195,6 +2199,22 @@ export default {
|
|
|
2195
2199
|
&& this.resolvedBrandBlock.placement !== 'nav'
|
|
2196
2200
|
},
|
|
2197
2201
|
|
|
2202
|
+
/**
|
|
2203
|
+
* The line on the search field in Nextcloud's header: the manifest's
|
|
2204
|
+
* `nav.searchPlaceholder`, translated, under the board look on a page
|
|
2205
|
+
* with a header. Empty otherwise, and then the field keeps Nextcloud's line.
|
|
2206
|
+
*
|
|
2207
|
+
* @spec openspec/changes/screens-board-top-bar-search/specs/layout-components/spec.md#requirement-the-board-look-names-what-the-header-search-finds
|
|
2208
|
+
* @return {string}
|
|
2209
|
+
*/
|
|
2210
|
+
headerSearchLabel() {
|
|
2211
|
+
const key = this.manifest?.nav?.searchPlaceholder
|
|
2212
|
+
if (this.resolvedLook !== 'board' || !this.headerPresent || typeof key !== 'string' || key === '') {
|
|
2213
|
+
return ''
|
|
2214
|
+
}
|
|
2215
|
+
return this.manifestTranslate(key)
|
|
2216
|
+
},
|
|
2217
|
+
|
|
2198
2218
|
/**
|
|
2199
2219
|
* The label lookup provided as `cnTranslate`: the manifest's own
|
|
2200
2220
|
* translations for the language first, then the host `translate`, then the
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
CnBrandBar — the brand block in Nextcloud's own header (screens-brand-block-top-bar).
|
|
3
3
|
|
|
4
|
-
Draws the 237px brand
|
|
5
|
-
divider
|
|
4
|
+
Draws the brand block (a 237px brand link with the emblem, organisation and
|
|
5
|
+
app name, then a 1px divider 12px on) at the START of `#header`, before the app menu, under the
|
|
6
6
|
board look. CnAppRoot mounts it when a brand is declared and `#header` exists.
|
|
7
7
|
|
|
8
8
|
`#header` is Nextcloud's and offers no extension point for apps, so this is
|
|
@@ -170,11 +170,12 @@ export default {
|
|
|
170
170
|
display: flex;
|
|
171
171
|
align-items: center;
|
|
172
172
|
gap: 12px;
|
|
173
|
-
width: var(--cn-board-brand-width, 237px);
|
|
174
173
|
}
|
|
175
174
|
|
|
175
|
+
/* The brand link is 237px; the divider follows it, 12px on. */
|
|
176
176
|
.cn-brand-bar__brand {
|
|
177
|
-
flex:
|
|
177
|
+
flex: none;
|
|
178
|
+
width: var(--cn-board-brand-width, 237px);
|
|
178
179
|
box-sizing: border-box;
|
|
179
180
|
display: flex;
|
|
180
181
|
align-items: center;
|
|
@@ -2101,7 +2101,7 @@ export default {
|
|
|
2101
2101
|
default: false,
|
|
2102
2102
|
},
|
|
2103
2103
|
|
|
2104
|
-
/** Label for the header Edit button. Defaults to a translated "Edit". */
|
|
2104
|
+
/** Label for the header Edit button, run through the host translate. Defaults to a translated "Edit". */
|
|
2105
2105
|
editLabel: {
|
|
2106
2106
|
type: String,
|
|
2107
2107
|
default: '',
|
|
@@ -3416,14 +3416,40 @@ export default {
|
|
|
3416
3416
|
|
|
3417
3417
|
/**
|
|
3418
3418
|
* The side column's widget definitions. A string names a widget
|
|
3419
|
-
* declared in `widgets`; an object is a definition of its own.
|
|
3419
|
+
* declared in `widgets`; an object is a definition of its own. A
|
|
3420
|
+
* definition whose `visibleWhen` does not hold for the record (or the
|
|
3421
|
+
* records `visibilityContext` names) is left out, exactly as in the
|
|
3422
|
+
* body grid; edit mode shows them all.
|
|
3420
3423
|
*
|
|
3421
3424
|
* @return {Array<object>} The definitions that resolve, each with an `id`.
|
|
3422
3425
|
* @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-side-column
|
|
3426
|
+
* @spec openspec/changes/detail-side-visible-when/specs/detail-page/spec.md#requirement-a-side-column-widget-shows-only-while-its-visiblewhen-holds
|
|
3423
3427
|
*/
|
|
3424
3428
|
sideColumnWidgets() {
|
|
3429
|
+
const resolved = this.declaredSideWidgets.filter((widget) => this.sideWidgetVisible(widget))
|
|
3430
|
+
if (!this.isBoard) {
|
|
3431
|
+
return resolved
|
|
3432
|
+
}
|
|
3433
|
+
// The board look: the notice first, the History card last.
|
|
3434
|
+
const rank = (widget) => (widget.type === 'banner' ? 0 : isActivityWidget(widget) ? 2 : 1)
|
|
3435
|
+
return resolved
|
|
3436
|
+
.map((widget, index) => ({ widget, index }))
|
|
3437
|
+
.sort((a, b) => (rank(a.widget) - rank(b.widget)) || (a.index - b.index))
|
|
3438
|
+
.map(({ widget }) => widget)
|
|
3439
|
+
},
|
|
3440
|
+
|
|
3441
|
+
/**
|
|
3442
|
+
* The side column's entries resolved to definitions, before any
|
|
3443
|
+
* `visibleWhen` is read: a string names a widget declared in
|
|
3444
|
+
* `widgets`, an object is a definition of its own (given an id when it
|
|
3445
|
+
* has none).
|
|
3446
|
+
*
|
|
3447
|
+
* @return {Array<object>}
|
|
3448
|
+
* @spec openspec/changes/detail-side-visible-when/specs/detail-page/spec.md#requirement-a-side-column-widget-shows-only-while-its-visiblewhen-holds
|
|
3449
|
+
*/
|
|
3450
|
+
declaredSideWidgets() {
|
|
3425
3451
|
const declared = Array.isArray(this.widgets) ? this.widgets : []
|
|
3426
|
-
|
|
3452
|
+
return (Array.isArray(this.sideColumn) ? this.sideColumn : [])
|
|
3427
3453
|
.map((entry, index) => {
|
|
3428
3454
|
if (typeof entry === 'string') {
|
|
3429
3455
|
return declared.find((widget) => widget && widget.id === entry) || null
|
|
@@ -3434,15 +3460,6 @@ export default {
|
|
|
3434
3460
|
return entry.id ? entry : { ...entry, id: `cn-side-${index}` }
|
|
3435
3461
|
})
|
|
3436
3462
|
.filter(Boolean)
|
|
3437
|
-
if (!this.isBoard) {
|
|
3438
|
-
return resolved
|
|
3439
|
-
}
|
|
3440
|
-
// The board look: the notice first, the History card last.
|
|
3441
|
-
const rank = (widget) => (widget.type === 'banner' ? 0 : isActivityWidget(widget) ? 2 : 1)
|
|
3442
|
-
return resolved
|
|
3443
|
-
.map((widget, index) => ({ widget, index }))
|
|
3444
|
-
.sort((a, b) => (rank(a.widget) - rank(b.widget)) || (a.index - b.index))
|
|
3445
|
-
.map(({ widget }) => widget)
|
|
3446
3463
|
},
|
|
3447
3464
|
|
|
3448
3465
|
/**
|
|
@@ -3763,12 +3780,14 @@ export default {
|
|
|
3763
3780
|
},
|
|
3764
3781
|
|
|
3765
3782
|
/**
|
|
3766
|
-
* Label for the header Edit button
|
|
3783
|
+
* Label for the header Edit button: the manifest's `editLabel` through
|
|
3784
|
+
* the host translate, else the library's translated "Edit".
|
|
3767
3785
|
*
|
|
3768
3786
|
* @return {string}
|
|
3787
|
+
* @spec openspec/changes/detail-edit-label-translate/specs/detail-page/spec.md#requirement-the-edit-button-label-follows-the-reader-s-language
|
|
3769
3788
|
*/
|
|
3770
3789
|
editActionLabel() {
|
|
3771
|
-
return this.editLabel
|
|
3790
|
+
return this.editLabel ? this.effectiveTranslate(this.editLabel) : t('nextcloud-vue', 'Edit')
|
|
3772
3791
|
},
|
|
3773
3792
|
|
|
3774
3793
|
/**
|
|
@@ -4022,7 +4041,7 @@ export default {
|
|
|
4022
4041
|
if (!this.widgetVisibilityData || this.visibilityRowsPending) {
|
|
4023
4042
|
return ''
|
|
4024
4043
|
}
|
|
4025
|
-
const defs = [...(this.bodyGridWidgets || []), ...((this.activeView && this.activeView.widgets) || [])]
|
|
4044
|
+
const defs = [...(this.bodyGridWidgets || []), ...((this.activeView && this.activeView.widgets) || []), ...this.declaredSideWidgets]
|
|
4026
4045
|
const remote = defs.filter((def) => def && def.visibleWhen && typeof def.visibleWhen === 'object'
|
|
4027
4046
|
&& !isLocallyDecidableVisibleWhen(def.visibleWhen))
|
|
4028
4047
|
if (remote.length === 0) {
|
|
@@ -5897,6 +5916,27 @@ export default {
|
|
|
5897
5916
|
return compactLayoutRows(drawable)
|
|
5898
5917
|
},
|
|
5899
5918
|
|
|
5919
|
+
/**
|
|
5920
|
+
* Whether a side column widget shows: always in edit mode or without a
|
|
5921
|
+
* `visibleWhen`, else while it holds for the record and the records
|
|
5922
|
+
* `visibilityContext` names, read by the same `detailWidgetVisible` the
|
|
5923
|
+
* body grid uses (local field conditions, `@context.<name>.*`, and the
|
|
5924
|
+
* answers of endpoint and source conditions).
|
|
5925
|
+
*
|
|
5926
|
+
* @param {object} widget A side column widget definition.
|
|
5927
|
+
* @return {boolean}
|
|
5928
|
+
* @spec openspec/changes/detail-side-visible-when/specs/detail-page/spec.md#requirement-a-side-column-widget-shows-only-while-its-visiblewhen-holds
|
|
5929
|
+
*/
|
|
5930
|
+
sideWidgetVisible(widget) {
|
|
5931
|
+
if (this.editingBody || widget.visibleWhen === undefined || widget.visibleWhen === null) {
|
|
5932
|
+
return true
|
|
5933
|
+
}
|
|
5934
|
+
return detailWidgetVisible(widget, this.widgetVisibilityData, {
|
|
5935
|
+
pending: this.visibilityRowsPending,
|
|
5936
|
+
answers: this.widgetConditionAnswers,
|
|
5937
|
+
})
|
|
5938
|
+
},
|
|
5939
|
+
|
|
5900
5940
|
/**
|
|
5901
5941
|
* Whether a layout item has anything to draw, so a grid cell is worth
|
|
5902
5942
|
* spending on it.
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
- SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
3
|
+
- SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
-->
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div class="cn-device-frames-widget" data-testid="cn-device-frames-widget">
|
|
8
|
+
<section
|
|
9
|
+
v-for="frame in frames"
|
|
10
|
+
:key="frame.key"
|
|
11
|
+
class="cn-device-frames-widget__frame"
|
|
12
|
+
:style="frameStyle"
|
|
13
|
+
:aria-labelledby="headingId(frame.key)"
|
|
14
|
+
:data-testid="`cn-device-frame-${frame.key}`">
|
|
15
|
+
<header class="cn-device-frames-widget__head">
|
|
16
|
+
<span
|
|
17
|
+
v-if="frame.status"
|
|
18
|
+
class="cn-device-frames-widget__status"
|
|
19
|
+
:class="`cn-device-frames-widget__status--${frame.status.tone}`"
|
|
20
|
+
data-testid="cn-device-frame-status">
|
|
21
|
+
<component :is="frame.status.icon" :size="16" aria-hidden="true" />
|
|
22
|
+
{{ frame.status.text }}
|
|
23
|
+
</span>
|
|
24
|
+
<h3 :id="headingId(frame.key)" class="cn-device-frames-widget__title">
|
|
25
|
+
{{ frame.title }}
|
|
26
|
+
</h3>
|
|
27
|
+
</header>
|
|
28
|
+
<div class="cn-device-frames-widget__body">
|
|
29
|
+
<CnSectionsWidget
|
|
30
|
+
:content="{ sections: frame.sections, cards: false, hideEmpty: true }"
|
|
31
|
+
:availableWidgets="availableWidgets"
|
|
32
|
+
:objectId="objectId"
|
|
33
|
+
:objectData="objectData"
|
|
34
|
+
:objectType="objectType"
|
|
35
|
+
:schemaObject="schemaObject"
|
|
36
|
+
:register="register"
|
|
37
|
+
:schema="schema"
|
|
38
|
+
:store="store"
|
|
39
|
+
:surface="surface"
|
|
40
|
+
:integrationContext="integrationContext"
|
|
41
|
+
:cnRegistry="cnRegistry" />
|
|
42
|
+
</div>
|
|
43
|
+
</section>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<script>
|
|
48
|
+
import { markRaw } from 'vue'
|
|
49
|
+
import CloudCheckOutline from 'vue-material-design-icons/CloudCheckOutline.vue'
|
|
50
|
+
import CloudOffOutline from 'vue-material-design-icons/CloudOffOutline.vue'
|
|
51
|
+
import InformationOutline from 'vue-material-design-icons/InformationOutline.vue'
|
|
52
|
+
import CnSectionsWidget from '../CnSectionsWidget/CnSectionsWidget.vue'
|
|
53
|
+
|
|
54
|
+
/** Counter for unique heading ids across instances. */
|
|
55
|
+
let instanceCounter = 0
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* CnDeviceFramesWidget - the `device-frames` widget: several small pages side
|
|
59
|
+
* by side, each in a phone frame with its own header (board DqInspectie,
|
|
60
|
+
* "Inspectie in het veld": the day offline, a checklist question with a
|
|
61
|
+
* photo, and the sync queue).
|
|
62
|
+
*
|
|
63
|
+
* Each frame stacks widgets of the surface, as the `sections` widget does,
|
|
64
|
+
* under a header with the frame's title and an optional status line. The
|
|
65
|
+
* status can follow the connection ("Offline · synced at 07:42" while the
|
|
66
|
+
* device is offline). The frames wrap onto the next row when the page is
|
|
67
|
+
* narrower, and a frame takes the full width on a phone.
|
|
68
|
+
*
|
|
69
|
+
* ```json
|
|
70
|
+
* {
|
|
71
|
+
* "type": "device-frames",
|
|
72
|
+
* "content": {
|
|
73
|
+
* "width": 350,
|
|
74
|
+
* "status": { "followConnection": true, "offlineText": "Offline · synced at 07:42", "onlineText": "Online" },
|
|
75
|
+
* "frames": [
|
|
76
|
+
* { "key": "today", "title": "Today", "widgetIds": ["today-runs", "offline-note"] },
|
|
77
|
+
* { "key": "question", "title": "Kerkplein 7", "widgetIds": ["checklist-run"] },
|
|
78
|
+
* { "key": "sync", "title": "Synchronise", "widgetIds": ["sync-queue"] }
|
|
79
|
+
* ]
|
|
80
|
+
* }
|
|
81
|
+
* }
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* @spec openspec/changes/device-frames-widget/specs/device-frames-widget/spec.md#requirement-a-page-can-show-several-small-pages-as-phone-frames
|
|
85
|
+
*/
|
|
86
|
+
export default {
|
|
87
|
+
name: 'CnDeviceFramesWidget',
|
|
88
|
+
|
|
89
|
+
components: { CnSectionsWidget },
|
|
90
|
+
|
|
91
|
+
inject: {
|
|
92
|
+
cnTranslate: { default: () => (key) => key },
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
props: {
|
|
96
|
+
/**
|
|
97
|
+
* The widget configuration.
|
|
98
|
+
*
|
|
99
|
+
* - `frames` — `[{ key?, title, widgetIds? | sections?, status? }]`, in order. `widgetIds` names
|
|
100
|
+
* widgets of the surface, stacked; `sections` takes the `sections` widget's entries instead.
|
|
101
|
+
* - `status` — the status line of every frame (a frame's own `status` wins): `{ text?, tone?,
|
|
102
|
+
* followConnection?, onlineText?, offlineText? }`. With `followConnection` the line reads
|
|
103
|
+
* `offlineText` while the device is offline and `onlineText` otherwise. `tone` is `warning`
|
|
104
|
+
* (default while offline), `success` or `info`.
|
|
105
|
+
* - `width` — the frame width in pixels (default 350).
|
|
106
|
+
*
|
|
107
|
+
* @type {{frames?: Array<object>, status?: object, width?: number}}
|
|
108
|
+
*/
|
|
109
|
+
content: {
|
|
110
|
+
type: Object,
|
|
111
|
+
default: () => ({}),
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/** Sibling widget definitions on the surface, for `widgetIds`. */
|
|
115
|
+
availableWidgets: {
|
|
116
|
+
type: Array,
|
|
117
|
+
default: () => [],
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/** The bound record's id, forwarded to the children. */
|
|
121
|
+
objectId: {
|
|
122
|
+
type: [String, Number],
|
|
123
|
+
default: '',
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/** The loaded record, forwarded to the children. */
|
|
127
|
+
objectData: {
|
|
128
|
+
type: Object,
|
|
129
|
+
default: null,
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
/** The resolved object-type slug. */
|
|
133
|
+
objectType: {
|
|
134
|
+
type: String,
|
|
135
|
+
default: '',
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/** The resolved JSON Schema object. */
|
|
139
|
+
schemaObject: {
|
|
140
|
+
type: Object,
|
|
141
|
+
default: null,
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/** OpenRegister register slug of the surface. */
|
|
145
|
+
register: {
|
|
146
|
+
type: [String, Object],
|
|
147
|
+
default: '',
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/** OpenRegister schema slug of the surface. */
|
|
151
|
+
schema: {
|
|
152
|
+
type: [String, Object],
|
|
153
|
+
default: '',
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
/** The effective object store. */
|
|
157
|
+
store: {
|
|
158
|
+
type: Object,
|
|
159
|
+
default: null,
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
/** Rendering surface forwarded to the children. */
|
|
163
|
+
surface: {
|
|
164
|
+
type: String,
|
|
165
|
+
default: 'app-dashboard',
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/** Object context forwarded to integration children. */
|
|
169
|
+
integrationContext: {
|
|
170
|
+
type: Object,
|
|
171
|
+
default: null,
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/** The consumer's component registry, for custom child widget types. */
|
|
175
|
+
cnRegistry: {
|
|
176
|
+
type: Object,
|
|
177
|
+
default: () => ({}),
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
data() {
|
|
182
|
+
instanceCounter += 1
|
|
183
|
+
return {
|
|
184
|
+
uid: instanceCounter,
|
|
185
|
+
online: typeof navigator === 'undefined' || navigator.onLine !== false,
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
computed: {
|
|
190
|
+
/** @return {object} The frame width. */
|
|
191
|
+
frameStyle() {
|
|
192
|
+
const width = Number(this.content?.width)
|
|
193
|
+
return { '--cn-device-frame-width': `${Number.isFinite(width) && width >= 240 ? width : 350}px` }
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The frames to draw: a key, a translated title, the sections and the
|
|
198
|
+
* status line.
|
|
199
|
+
*
|
|
200
|
+
* @return {Array<{key: string, title: string, sections: Array<object>, status: object|null}>}
|
|
201
|
+
*/
|
|
202
|
+
frames() {
|
|
203
|
+
const list = Array.isArray(this.content?.frames) ? this.content.frames : []
|
|
204
|
+
return list
|
|
205
|
+
.filter((frame) => frame && typeof frame === 'object')
|
|
206
|
+
.map((frame, index) => {
|
|
207
|
+
const sections = Array.isArray(frame.sections)
|
|
208
|
+
? frame.sections
|
|
209
|
+
: (Array.isArray(frame.widgetIds) ? frame.widgetIds : []).filter((id) => typeof id === 'string' && id !== '').map((widgetId) => ({ widgetId }))
|
|
210
|
+
return {
|
|
211
|
+
key: String(frame.key || `frame-${index}`).replace(/[^\w-]/g, '-'),
|
|
212
|
+
title: this.tr(frame.title || ''),
|
|
213
|
+
sections,
|
|
214
|
+
status: this.statusOf(frame.status || this.content?.status || null),
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
mounted() {
|
|
221
|
+
if (typeof window !== 'undefined') {
|
|
222
|
+
window.addEventListener('online', this.onConnection)
|
|
223
|
+
window.addEventListener('offline', this.onConnection)
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
beforeUnmount() {
|
|
228
|
+
if (typeof window !== 'undefined') {
|
|
229
|
+
window.removeEventListener('online', this.onConnection)
|
|
230
|
+
window.removeEventListener('offline', this.onConnection)
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
methods: {
|
|
235
|
+
/**
|
|
236
|
+
* Translate through the app's translate.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} key The source string.
|
|
239
|
+
* @return {string}
|
|
240
|
+
*/
|
|
241
|
+
tr(key) {
|
|
242
|
+
return key && typeof this.cnTranslate === 'function' ? this.cnTranslate(key) : (key || '')
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The status line of a frame, or null.
|
|
247
|
+
*
|
|
248
|
+
* @param {object|null} status The configured status.
|
|
249
|
+
* @return {{text: string, tone: string, icon: object}|null}
|
|
250
|
+
*/
|
|
251
|
+
statusOf(status) {
|
|
252
|
+
if (!status || typeof status !== 'object') {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
let text = status.text
|
|
256
|
+
let tone = status.tone
|
|
257
|
+
if (status.followConnection === true) {
|
|
258
|
+
text = this.online ? status.onlineText : status.offlineText
|
|
259
|
+
tone = tone || (this.online ? 'success' : 'warning')
|
|
260
|
+
}
|
|
261
|
+
if (typeof text !== 'string' || text === '') {
|
|
262
|
+
return null
|
|
263
|
+
}
|
|
264
|
+
const known = ['warning', 'success', 'info'].includes(tone) ? tone : 'info'
|
|
265
|
+
const icon = known === 'warning' ? CloudOffOutline : (known === 'success' ? CloudCheckOutline : InformationOutline)
|
|
266
|
+
return { text: this.tr(text), tone: known, icon: markRaw(icon) }
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
/** Follow the browser's connection state. */
|
|
270
|
+
onConnection() {
|
|
271
|
+
this.online = typeof navigator === 'undefined' || navigator.onLine !== false
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The id of a frame's heading.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} key The frame key.
|
|
278
|
+
* @return {string}
|
|
279
|
+
*/
|
|
280
|
+
headingId(key) {
|
|
281
|
+
return `cn-device-frame-${this.uid}-${key}`
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
</script>
|
|
286
|
+
|
|
287
|
+
<style scoped>
|
|
288
|
+
.cn-device-frames-widget {
|
|
289
|
+
display: flex;
|
|
290
|
+
flex-wrap: wrap;
|
|
291
|
+
align-items: flex-start;
|
|
292
|
+
gap: 20px;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.cn-device-frames-widget__frame {
|
|
296
|
+
display: flex;
|
|
297
|
+
flex: none;
|
|
298
|
+
flex-direction: column;
|
|
299
|
+
width: var(--cn-device-frame-width, 350px);
|
|
300
|
+
max-width: 100%;
|
|
301
|
+
min-height: 600px;
|
|
302
|
+
box-sizing: border-box;
|
|
303
|
+
overflow: hidden;
|
|
304
|
+
border: 10px solid var(--color-main-text);
|
|
305
|
+
border-radius: 36px;
|
|
306
|
+
background: var(--color-background-hover);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.cn-device-frames-widget__head {
|
|
310
|
+
display: flex;
|
|
311
|
+
flex-direction: column;
|
|
312
|
+
gap: 6px;
|
|
313
|
+
padding: 14px 18px 10px;
|
|
314
|
+
border-bottom: 1px solid var(--color-border);
|
|
315
|
+
background: var(--color-main-background);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.cn-device-frames-widget__status {
|
|
319
|
+
display: inline-flex;
|
|
320
|
+
align-items: center;
|
|
321
|
+
gap: 6px;
|
|
322
|
+
font-size: 13px;
|
|
323
|
+
font-weight: 600;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.cn-device-frames-widget__status--warning {
|
|
327
|
+
color: var(--color-warning-text, var(--color-main-text));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.cn-device-frames-widget__status--success {
|
|
331
|
+
color: var(--color-success-text, var(--color-main-text));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.cn-device-frames-widget__status--info {
|
|
335
|
+
color: var(--color-text-maxcontrast);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.cn-device-frames-widget__title {
|
|
339
|
+
margin: 0;
|
|
340
|
+
font-size: 20px;
|
|
341
|
+
font-weight: 700;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.cn-device-frames-widget__body {
|
|
345
|
+
display: flex;
|
|
346
|
+
flex-direction: column;
|
|
347
|
+
gap: 12px;
|
|
348
|
+
padding: 14px;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* On a phone the frame is the screen: no bezel, the full width. */
|
|
352
|
+
@media (max-width: 640px) {
|
|
353
|
+
.cn-device-frames-widget__frame {
|
|
354
|
+
width: 100%;
|
|
355
|
+
min-height: 0;
|
|
356
|
+
border-width: 0;
|
|
357
|
+
border-radius: var(--cn-board-card-radius, 12px);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*
|
|
5
|
+
* CnDeviceFramesWidget — self-registers the `device-frames` widget type
|
|
6
|
+
* (several small pages side by side, each in a phone frame) into the shared
|
|
7
|
+
* dashboardWidgetRegistry at module load. A container: it renders other
|
|
8
|
+
* widgets of the surface, so the host hands it their definitions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import CnDeviceFramesWidget from './CnDeviceFramesWidget.vue'
|
|
12
|
+
import { registerDashboardWidget } from '../CnWidgetGrid/dashboardWidgetRegistry.js'
|
|
13
|
+
|
|
14
|
+
registerDashboardWidget('device-frames', {
|
|
15
|
+
renderer: CnDeviceFramesWidget,
|
|
16
|
+
form: null,
|
|
17
|
+
defaultContent: {
|
|
18
|
+
frames: [],
|
|
19
|
+
width: 350,
|
|
20
|
+
},
|
|
21
|
+
displayName: 'Device frames',
|
|
22
|
+
icon: 'CellphoneText',
|
|
23
|
+
container: true,
|
|
24
|
+
ownsTitle: true,
|
|
25
|
+
surfaces: ['app-dashboard', 'detail-page'],
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export { CnDeviceFramesWidget }
|
|
29
|
+
export default CnDeviceFramesWidget
|