@conduction/nextcloud-vue 2.82.0 → 2.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +2 -2
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
  4. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +24 -0
  5. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
  6. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +9 -4
  7. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
  8. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  9. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +9 -0
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  12. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +96 -12
  13. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  14. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +95 -50
  15. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  16. package/dist/esm/components/CnMassActionBar/CnMassActionBar.vue2.js +2 -2
  17. package/dist/esm/components/CnMassActionBar/CnMassActionBar.vue2.js.map +1 -1
  18. package/dist/esm/components/CnMassImportDialog/CnMassImportDialog.vue2.js +2 -2
  19. package/dist/esm/components/CnMassImportDialog/CnMassImportDialog.vue2.js.map +1 -1
  20. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue.js +1 -1
  21. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue2.js +16 -2
  22. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue2.js.map +1 -1
  23. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue3.js +3 -3
  24. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue3.js.map +1 -1
  25. package/dist/esm/components/CnReportsPage/CnReportsPage.vue.js +1 -1
  26. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js +43 -0
  27. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  28. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js +130 -53
  29. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  30. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue.js +1 -1
  31. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue2.js +70 -5
  32. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue2.js.map +1 -1
  33. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue3.js +61 -35
  34. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue3.js.map +1 -1
  35. package/dist/esm/components/CnStatWidget/CnStatWidget.vue.js +1 -1
  36. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +3 -1
  37. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  38. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  39. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue.js +1 -1
  40. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +55 -3
  41. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  42. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +88 -71
  43. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  44. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue.js +1 -1
  45. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue2.js +18 -4
  46. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue2.js.map +1 -1
  47. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue3.js.map +1 -1
  48. package/dist/esm/l10n/nl.json.js +3 -3
  49. package/dist/esm/library-style.js +1 -1
  50. package/dist/esm/nextcloud-vue.css +549 -338
  51. package/dist/esm/utils/fetchAggregate.js +25 -2
  52. package/dist/esm/utils/fetchAggregate.js.map +1 -1
  53. package/dist/esm/utils/switchValues.js +62 -0
  54. package/dist/esm/utils/switchValues.js.map +1 -0
  55. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  56. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  57. package/dist/nextcloud-vue.cjs.js +36086 -35536
  58. package/dist/nextcloud-vue.cjs.js.map +2 -2
  59. package/dist/nextcloud-vue.css +549 -338
  60. package/l10n/nl.json +3 -3
  61. package/package.json +1 -1
  62. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +31 -2
  63. package/src/components/CnDashboardPage/CnDashboardPage.vue +9 -0
  64. package/src/components/CnIndexPage/CnIndexPage.vue +125 -15
  65. package/src/components/CnRadioCardsWidget/CnRadioCardsWidget.vue +54 -3
  66. package/src/components/CnReportsPage/CnReportsPage.vue +84 -4
  67. package/src/components/CnSettingsFormWidget/CnSettingsFormWidget.vue +110 -18
  68. package/src/components/CnStatWidget/CnStatWidget.vue +3 -1
  69. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +92 -5
  70. package/src/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue +18 -4
  71. package/src/css/index-page.css +11 -0
  72. package/src/css/index.css +1 -0
  73. package/src/css/look-board-reports.css +117 -0
  74. package/src/css/look-board.css +32 -1
  75. package/src/schemas/app-manifest-v2.schema.json +2 -2
  76. package/src/utils/fetchAggregate.js +24 -1
  77. package/src/utils/switchValues.js +59 -0
  78. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -52,18 +52,33 @@
52
52
  {{ field.clearLabel }}
53
53
  </NcButton>
54
54
  </div>
55
- <select
56
- v-else-if="field.type === 'reference'"
57
- :id="inputId(field)"
58
- v-model="form[field.key]"
59
- class="cn-settings-form__control"
60
- :disabled="field.readOnly"
61
- :aria-busy="referenceState[field.key] === 'loading' ? 'true' : null"
62
- :data-testid="`cn-settings-form-reference-${field.key}`">
63
- <option v-for="option in referenceChoices(field)" :key="option.value" :value="option.value">
64
- {{ option.label }}
65
- </option>
66
- </select>
55
+ <div v-else-if="field.type === 'reference'" class="cn-settings-form__reference">
56
+ <select
57
+ :id="inputId(field)"
58
+ v-model="form[field.key]"
59
+ class="cn-settings-form__control"
60
+ :disabled="field.readOnly"
61
+ :aria-busy="referenceState[field.key] === 'loading' ? 'true' : null"
62
+ :data-testid="`cn-settings-form-reference-${field.key}`">
63
+ <option v-for="option in referenceChoices(field)" :key="option.value" :value="option.value">
64
+ {{ option.label }}
65
+ </option>
66
+ </select>
67
+ <!-- `free`: a typed value (a URL) besides picking an object. -->
68
+ <template v-if="field.free && !field.readOnly">
69
+ <label class="cn-settings-form__sublabel" :for="`${inputId(field)}-free`">{{ field.freeLabel }}</label>
70
+ <input
71
+ :id="`${inputId(field)}-free`"
72
+ class="cn-settings-form__control"
73
+ type="text"
74
+ inputmode="url"
75
+ :value="freeValue(field)"
76
+ :placeholder="field.freePlaceholder"
77
+ :aria-invalid="errors[field.key] ? 'true' : null"
78
+ :data-testid="`cn-settings-form-reference-free-${field.key}`"
79
+ @input="form[field.key] = $event.target.value.trim()">
80
+ </template>
81
+ </div>
67
82
  <select
68
83
  v-else-if="field.type === 'select'"
69
84
  :id="inputId(field)"
@@ -97,7 +112,8 @@
97
112
  <p v-if="errors[field.key]" class="cn-settings-form__error" role="alert">
98
113
  {{ errors[field.key] }}
99
114
  </p>
100
- <p v-else-if="field.hint" class="cn-settings-form__hint">
115
+ <!-- A chips field prints its own hint (it gets it in chipsContent). -->
116
+ <p v-else-if="field.hint && field.type !== 'chips'" class="cn-settings-form__hint">
101
117
  {{ field.hint }}
102
118
  </p>
103
119
  </div>
@@ -129,7 +145,9 @@ import { fillTemplate } from '../../utils/fillTemplate.js'
129
145
  import { prefixUrl } from '../../utils/headers.js'
130
146
  import { changedFields, objectUrlOf, writeFields } from '../../utils/objectWrite.js'
131
147
  import { readPath } from '../../utils/readPath.js'
148
+ import { hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
132
149
  import { serverErrorMessage } from '../../utils/serverErrorMessage.js'
150
+ import { switchIsOn, switchStoredValue } from '../../utils/switchValues.js'
133
151
 
134
152
  // The native file-picker modal ships its chrome styles here; without this the
135
153
  // spawned picker renders unstyled. Rules are scoped to the picker/dialog classes.
@@ -163,7 +181,7 @@ export default {
163
181
  /**
164
182
  * The widget configuration.
165
183
  *
166
- * - `fields` (required) — `[{ key, label, type?: 'text'|'textarea'|'select'|'switch'|'number'|'file'|'chips'|'reference', register?, schema?, labelField?, valueTemplate?, filter?, limit?, emptyLabel?, default?, minItems?, required?, minItemsMessage?, lowercase?, addLabel?, confirmLabel?, removeLabel?, picker?: { title?, mimeTypes?, startAt?, valueTemplate? }, chooseLabel?, clearLabel?, hint?, placeholder?, options?: [{ value, label }], required?, pattern?, patternMessage?, min?, max?, rows?, wide?, readOnly? }]`; `key` is a field of the object, a top-level name or a dot-path.
184
+ * - `fields` (required) — `[{ key, label, type?: 'text'|'textarea'|'select'|'switch'|'number'|'file'|'chips'|'reference', register?, schema?, labelField?, valueTemplate?, filter?, limit?, emptyLabel?, free?, freeLabel?, freePlaceholder?, freePattern?, freePatternMessage?, default?, minItems?, required?, minItemsMessage?, values?: { on, off }, lowercase?, addLabel?, confirmLabel?, removeLabel?, picker?: { title?, mimeTypes?, startAt?, valueTemplate? }, chooseLabel?, clearLabel?, hint?, placeholder?, options?: [{ value, label }], required?, pattern?, patternMessage?, min?, max?, rows?, wide?, readOnly? }]`; `key` is a field of the object, a top-level name or a dot-path. A `reference` field's `filter` values may be `@objectId` and `@object.<field>` (the record being edited). A `switch` field's `values: { on, off }` reads and writes those values instead of true/false.
167
185
  * - `save` — `{ url, method?, payload? }`, write through an app endpoint (tokens `{value}`, `{field}`, `{changes}`, `{object.<path>}`).
168
186
  * - `columns` — 1 or 2 (default 2).
169
187
  * - `saveLabel`, `savedText`, `readOnly`.
@@ -212,6 +230,9 @@ export default {
212
230
  // `reference` fields: the objects to choose from and the read's state (`loading`, `ready`, `error`), by field key.
213
231
  referenceOptions: {},
214
232
  referenceState: {},
233
+ // The query each `reference` field was last read with, by field key, so a
234
+ // new object re-reads only the fields whose filter changes with it.
235
+ referenceQuery: {},
215
236
  errors: {},
216
237
  saving: false,
217
238
  notice: '',
@@ -236,6 +257,9 @@ export default {
236
257
  hint: this.tr(field.hint || ''),
237
258
  placeholder: this.tr(field.placeholder || ''),
238
259
  chooseLabel: this.tr(field.chooseLabel || 'Choose a file'),
260
+ free: field.type === 'reference' && field.free === true,
261
+ freeLabel: this.tr(field.freeLabel || 'Or type a value'),
262
+ freePlaceholder: this.tr(field.freePlaceholder || ''),
239
263
  clearLabel: this.tr(field.clearLabel || 'Remove'),
240
264
  rows: field.rows || 3,
241
265
  wide: field.wide === true || field.type === 'textarea',
@@ -269,6 +293,7 @@ export default {
269
293
  watch: {
270
294
  objectData() {
271
295
  this.adopt()
296
+ this.loadReferences()
272
297
  },
273
298
  },
274
299
 
@@ -284,7 +309,14 @@ export default {
284
309
  * parameters, at most `limit`, 100 by default). A failed read leaves the
285
310
  * field with its stored value only, and says so in the hint's place.
286
311
  *
312
+ * `filter` values may be `@objectId` and `@object.<field>`: the record
313
+ * the form edits, so a list can be narrowed to it (the logos of this
314
+ * portal). Until the record has that value the field is not read; it is
315
+ * read again when the record changes the resolved query, never twice
316
+ * for the same query.
317
+ *
287
318
  * @spec openspec/changes/screens-settings-form-reference/specs/settings-form/spec.md#requirement-a-settings-form-field-can-pick-an-object
319
+ * @spec openspec/changes/screens-settings-form-reference-object-filter/specs/settings-form/spec.md#requirement-a-reference-fields-filter-can-name-the-record-being-edited
288
320
  * @return {Promise<void>}
289
321
  */
290
322
  async loadReferences() {
@@ -292,13 +324,35 @@ export default {
292
324
  if (fields.length === 0) {
293
325
  return
294
326
  }
327
+ const ctx = { objectId: this.objectId || (this.objectData && this.objectData.id) || undefined, object: this.objectData || undefined }
328
+ const pending = []
329
+ for (const field of fields) {
330
+ const filter = resolveFilterTokens(field.filter && typeof field.filter === 'object' ? field.filter : {}, ctx)
331
+ if (hasUnresolvedTokens(filter)) {
332
+ // The record has no value for the token yet: nothing to read.
333
+ continue
334
+ }
335
+ const params = { ...filter, _limit: Number(field.limit) > 0 ? Number(field.limit) : 100 }
336
+ const query = JSON.stringify(params)
337
+ if (this.referenceQuery[field.key] === query) {
338
+ continue
339
+ }
340
+ this.referenceQuery = { ...this.referenceQuery, [field.key]: query }
341
+ pending.push({ field, params, query })
342
+ }
343
+ if (pending.length === 0) {
344
+ return
345
+ }
295
346
  const { default: axios } = await import('@nextcloud/axios')
296
- await Promise.all(fields.map(async (field) => {
347
+ await Promise.all(pending.map(async ({ field, params, query }) => {
297
348
  this.referenceState = { ...this.referenceState, [field.key]: 'loading' }
298
349
  try {
299
350
  const url = `/apps/openregister/api/objects/${encodeURIComponent(field.register)}/${encodeURIComponent(field.schema)}`
300
- const params = { ...(field.filter && typeof field.filter === 'object' ? field.filter : {}), _limit: Number(field.limit) > 0 ? Number(field.limit) : 100 }
301
351
  const { data } = await axios.get(prefixUrl(url), { params })
352
+ if (this.referenceQuery[field.key] !== query) {
353
+ // A newer record asked for another list meanwhile.
354
+ return
355
+ }
302
356
  const rows = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : [])
303
357
  const options = []
304
358
  for (const row of rows) {
@@ -340,6 +394,24 @@ export default {
340
394
  return [...choices, ...options]
341
395
  },
342
396
 
397
+ /**
398
+ * The typed value of a `reference` field with `free`: the value the form
399
+ * holds when it is not one of the objects to pick, else empty (picking
400
+ * an object clears the typed value).
401
+ *
402
+ * @spec openspec/changes/screens-settings-form-reference-free-value/specs/settings-form/spec.md#requirement-a-reference-field-can-also-take-a-typed-value
403
+ * @param {object} field The field.
404
+ * @return {string}
405
+ */
406
+ freeValue(field) {
407
+ const current = this.form[field.key]
408
+ if (typeof current !== 'string' || current === '') {
409
+ return ''
410
+ }
411
+ const options = this.referenceOptions[field.key] || []
412
+ return options.some((option) => option.value === current) ? '' : current
413
+ },
414
+
343
415
  tr(text) {
344
416
  if (typeof text !== 'string' || text === '') {
345
417
  return ''
@@ -410,7 +482,7 @@ export default {
410
482
  stored(field) {
411
483
  const value = readPath(this.objectData || {}, field.key)
412
484
  if (field.type === 'switch') {
413
- return value === true
485
+ return switchIsOn(value, field.values)
414
486
  }
415
487
  if (field.type === 'chips') {
416
488
  return Array.isArray(value) ? value.map((item) => String(item)) : []
@@ -534,6 +606,9 @@ export default {
534
606
  if (Number.isNaN(value) || (field.min !== undefined && value < field.min) || (field.max !== undefined && value > field.max)) {
535
607
  errors[field.key] = this.tr('This number is not valid.')
536
608
  }
609
+ } else if (field.free && field.freePattern && this.freeValue(field) !== '' && !new RegExp(field.freePattern).test(String(value))) {
610
+ // A typed value (not one of the objects) answers to `freePattern`.
611
+ errors[field.key] = this.tr(field.freePatternMessage || 'This value is not valid.')
537
612
  } else if (field.pattern && !new RegExp(field.pattern).test(String(value))) {
538
613
  errors[field.key] = this.tr(field.patternMessage || 'This value is not valid.')
539
614
  }
@@ -567,6 +642,12 @@ export default {
567
642
  }
568
643
  }
569
644
  const patch = changedFields(compare, next)
645
+ // A switch with a value map (`values: { on, off }`) writes those values.
646
+ for (const field of this.fields) {
647
+ if (field.type === 'switch' && Object.hasOwn(patch, field.key)) {
648
+ patch[field.key] = switchStoredValue(patch[field.key], field.values)
649
+ }
650
+ }
570
651
  this.saving = true
571
652
  try {
572
653
  if (Object.keys(patch).length > 0) {
@@ -578,7 +659,7 @@ export default {
578
659
  const field = this.fields.find((candidate) => candidate.key === key)
579
660
  const answered = readPath(data, key)
580
661
  const written = answered !== undefined ? answered : patch[key]
581
- baseline[key] = field.type === 'switch' ? written === true : (written === null ? (field.type === 'chips' ? [] : '') : written)
662
+ baseline[key] = field.type === 'switch' ? switchIsOn(written, field.values) : (written === null ? (field.type === 'chips' ? [] : '') : written)
582
663
  form[key] = baseline[key]
583
664
  loaded[key] = baseline[key]
584
665
  }
@@ -639,6 +720,17 @@ export default {
639
720
  color: var(--color-main-text);
640
721
  }
641
722
 
723
+ .cn-settings-form__reference {
724
+ display: flex;
725
+ flex-direction: column;
726
+ gap: 6px;
727
+ }
728
+
729
+ .cn-settings-form__sublabel {
730
+ font-size: 12px;
731
+ color: var(--color-text-maxcontrast);
732
+ }
733
+
642
734
  .cn-settings-form__control {
643
735
  width: 100%;
644
736
  box-sizing: border-box;
@@ -163,6 +163,7 @@ import widgetLink from '../../mixins/widgetLink.js'
163
163
  import { useObjectStore } from '../../store/useObjectStore.js'
164
164
  import { resolveObjectOpType } from '../../utils/actionsDispatcher.js'
165
165
  import { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'
166
+ import { aggFilterParam } from '../../utils/fetchAggregate.js'
166
167
  import { fetchListTotal, needsListEndpoint } from '../../utils/fetchFilterCounts.js'
167
168
  import { formatMetricValue, unwrapAppConfig } from '../../utils/formatMetric.js'
168
169
  import { dropOptionalUnresolved, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
@@ -1783,7 +1784,8 @@ export default {
1783
1784
  target[`filter[${k}][${op}]`] = ov
1784
1785
  }
1785
1786
  } else if (v !== '' && v !== null && v !== undefined) {
1786
- target[`filter[${k}]`] = v
1787
+ // `field[gte]` goes out as `filter[field][gte]`, not `filter[field[gte]]`.
1788
+ target[aggFilterParam(k)] = v
1787
1789
  }
1788
1790
  }
1789
1791
  },
@@ -17,10 +17,29 @@
17
17
  <template>
18
18
  <div
19
19
  class="cn-stats-block-widget"
20
- :class="[iconClass, { 'cn-stats-block-widget--multi': hasEntries && !isRows, 'cn-stats-block-widget--rows': isRows }]">
20
+ :class="[iconClass, { 'cn-stats-block-widget--multi': hasEntries && !isRows && !isGrid, 'cn-stats-block-widget--rows': isRows, 'cn-stats-block-widget--grid': isGrid }]">
21
+ <!-- Grid layout: the `stats` (each a stat tile's content, so every
22
+ source kind a stat takes: ratio, elapsed, computed...) in `columns`
23
+ columns, inside this one card (the board's "Je week tot nu toe"
24
+ card). The card's title is the widget wrapper's, as on every other
25
+ card (`showTitle: true` on the widget): a heading drawn here took
26
+ the page's h2 size under a theme. -->
27
+ <template v-if="isGrid">
28
+ <div
29
+ class="cn-stats-block-widget__grid"
30
+ :style="{ '--cn-stats-block-columns': gridColumns }"
31
+ data-testid="cn-stats-block-grid">
32
+ <CnStatWidget
33
+ v-for="(stat, index) in stats"
34
+ :key="stat.id || index"
35
+ class="cn-stats-block-widget__grid-stat"
36
+ :content="stat"
37
+ data-testid="cn-stats-block-grid-stat" />
38
+ </div>
39
+ </template>
21
40
  <!-- Rows layout: a title and one label-left, number-right row per entry
22
41
  (the board's "Mijn Zuiddrecht nu" card). -->
23
- <template v-if="isRows">
42
+ <template v-else-if="isRows">
24
43
  <h2 v-if="title" class="cn-stats-block-widget__rows-title" data-testid="cn-stats-block-rows-title">
25
44
  {{ tl(title) }}
26
45
  </h2>
@@ -101,7 +120,9 @@
101
120
  <script>
102
121
  import { subscribe, unsubscribe } from '@nextcloud/event-bus'
103
122
  import CnStatsBlock from '../CnStatsBlock/CnStatsBlock.vue'
123
+ import CnStatWidget from '../CnStatWidget/CnStatWidget.vue'
104
124
  import { useDataSource } from '../../composables/useDataSource.js'
125
+ import { aggFilterParam } from '../../utils/fetchAggregate.js'
105
126
  import { formatMetricValue, normalizeMetricFormat, unwrapAppConfig } from '../../utils/formatMetric.js'
106
127
  import { followLinkClick, resolveHref } from '../../utils/linkNavigation.js'
107
128
  import { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
@@ -180,7 +201,7 @@ const PAGE_REFRESH_BUS_CHANNEL = 'cn:page:refresh'
180
201
  export default {
181
202
  name: 'CnStatsBlockWidget',
182
203
 
183
- components: { CnStatsBlock },
204
+ components: { CnStatsBlock, CnStatWidget },
184
205
 
185
206
  inject: {
186
207
  /**
@@ -241,6 +262,32 @@ export default {
241
262
  default: () => [],
242
263
  },
243
264
 
265
+ /**
266
+ * The tiles of the grid layout (`layout: "grid"`): each is a stat
267
+ * tile's `content` (`label`, `caption`, `format`, `source` with any
268
+ * kind a `type: "stat"` widget takes: aggregate, `ratio`, `elapsed`,
269
+ * `computed`, `weighted`, `endpoint`). They share this one card; its
270
+ * title is the widget wrapper's (`showTitle: true` on the widget).
271
+ * Used instead of `dataSource` / `entries`.
272
+ *
273
+ * @type {Array<object>}
274
+ * @spec openspec/changes/screens-stats-block-grid/specs/stats-block-grid/spec.md#requirement-a-stats-block-can-hold-stat-tiles-in-a-grid
275
+ */
276
+ stats: {
277
+ type: Array,
278
+ default: () => [],
279
+ },
280
+
281
+ /**
282
+ * Columns of the grid layout (1 to 4, default 2).
283
+ *
284
+ * @spec openspec/changes/screens-stats-block-grid/specs/stats-block-grid/spec.md#requirement-a-stats-block-can-hold-stat-tiles-in-a-grid
285
+ */
286
+ columns: {
287
+ type: Number,
288
+ default: 2,
289
+ },
290
+
244
291
  /**
245
292
  * How the single-source value is shown: a style name (`'currency'`,
246
293
  * `'number'`, `'percent'`, `'decimal'`, `'duration-hours'`) or
@@ -400,6 +447,28 @@ export default {
400
447
  return this.layout === 'rows'
401
448
  },
402
449
 
450
+ /**
451
+ * Whether the widget draws its `stats` as tiles in a grid
452
+ * (`layout: "grid"` with at least one stat).
453
+ *
454
+ * @return {boolean}
455
+ * @spec openspec/changes/screens-stats-block-grid/specs/stats-block-grid/spec.md#requirement-a-stats-block-can-hold-stat-tiles-in-a-grid
456
+ */
457
+ isGrid() {
458
+ return this.layout === 'grid' && Array.isArray(this.stats) && this.stats.length > 0
459
+ },
460
+
461
+ /**
462
+ * The grid's column count, clamped to 1-4.
463
+ *
464
+ * @return {number}
465
+ * @spec openspec/changes/screens-stats-block-grid/specs/stats-block-grid/spec.md#requirement-a-stats-block-can-hold-stat-tiles-in-a-grid
466
+ */
467
+ gridColumns() {
468
+ const n = Math.floor(Number(this.columns))
469
+ return Number.isFinite(n) ? Math.min(4, Math.max(1, n)) : 2
470
+ },
471
+
403
472
  /**
404
473
  * The rows of the rows layout: one per entry, or a single row from the
405
474
  * `dataSource` (labelled by `countLabel`). A row is `{ key, label,
@@ -617,7 +686,8 @@ export default {
617
686
  // the cross-prop rule lives here (and, for manifests, in
618
687
  // validateManifestV2's post-schema check).
619
688
  const hasDataSource = !!this.dataSource
620
- if (hasDataSource === this.hasEntries) {
689
+ // A grid of stat tiles carries its sources in `stats`.
690
+ if (!this.isGrid && hasDataSource === this.hasEntries) {
621
691
  // eslint-disable-next-line no-console
622
692
  console.error('[CnStatsBlockWidget] Exactly one of `dataSource` / `entries` must be provided.')
623
693
  }
@@ -764,7 +834,8 @@ export default {
764
834
  params[`filter[${k}][${op}]`] = ov
765
835
  }
766
836
  } else if (v !== '' && v !== null && v !== undefined) {
767
- params[`filter[${k}]`] = v
837
+ // `field[gte]` goes out as `filter[field][gte]`, not `filter[field[gte]]`.
838
+ params[aggFilterParam(k)] = v
768
839
  }
769
840
  }
770
841
  const res = await axios.get(url, { params })
@@ -890,6 +961,22 @@ export default {
890
961
  color: var(--color-text-maxcontrast);
891
962
  }
892
963
 
964
+ .cn-stats-block-widget--grid {
965
+ padding: 4px 24px 20px;
966
+ }
967
+
968
+ .cn-stats-block-widget__grid {
969
+ display: grid;
970
+ grid-template-columns: repeat(var(--cn-stats-block-columns, 2), minmax(0, 1fr));
971
+ gap: 16px 20px;
972
+ }
973
+
974
+ /* A tile in the grid is a part of the card, not a card: no box, no inset. */
975
+ .cn-stats-block-widget__grid-stat {
976
+ padding: 0;
977
+ min-width: 0;
978
+ }
979
+
893
980
  .cn-stats-block-widget__rows {
894
981
  display: flex;
895
982
  flex-direction: column;
@@ -32,6 +32,7 @@ import { NcCheckboxRadioSwitch, NcNoteCard } from '@nextcloud/vue'
32
32
  import { objectUrlOf, writeFields } from '../../utils/objectWrite.js'
33
33
  import { readPath } from '../../utils/readPath.js'
34
34
  import { serverErrorMessage } from '../../utils/serverErrorMessage.js'
35
+ import { switchIsOn, switchStoredValue } from '../../utils/switchValues.js'
35
36
 
36
37
  /**
37
38
  * CnSwitchRowsWidget — the `switch-rows` widget: a card of switches, each with
@@ -54,7 +55,8 @@ export default {
54
55
  /**
55
56
  * The widget configuration.
56
57
  *
57
- * - `rows` (required) — `[{ key, label, hint?, invert? }]`; `key` is a boolean field, a top-level name or a dot-path. `invert` shows the switch on when the field is false (a stored `disabled` read as "enabled").
58
+ * - `rows` (required) — `[{ key, label, hint?, invert?, values? }]`; `key` is a boolean field, a top-level name or a dot-path. `invert` shows the switch on when the field is false (a stored `disabled` read as "enabled"). `values: { on, off }` reads and writes those values instead of true/false (an app config flag stored as `"1"`/`"0"`).
59
+ * - `values` — `{ on, off }` for every row that declares none.
58
60
  * - `save` — `{ url, method?, payload? }`, write through an app endpoint (tokens `{value}`, `{field}`, `{changes}`, `{object.<path>}`).
59
61
  * - `readOnly`.
60
62
  *
@@ -137,11 +139,22 @@ export default {
137
139
  return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
138
140
  },
139
141
 
142
+ /**
143
+ * A row's value map: its own `values`, else the widget's.
144
+ *
145
+ * @spec openspec/changes/screens-switch-value-map/specs/switch-rows/spec.md#requirement-a-switch-can-read-and-write-a-value-map
146
+ * @param {object} row The row.
147
+ * @return {object|undefined} The declared `values`.
148
+ */
149
+ valuesOf(row) {
150
+ return row.values !== undefined ? row.values : this.content.values
151
+ },
152
+
140
153
  /** Read every switch off the object. */
141
154
  adopt() {
142
155
  const values = {}
143
156
  for (const row of this.rows) {
144
- const stored = readPath(this.objectData || {}, row.key) === true
157
+ const stored = switchIsOn(readPath(this.objectData || {}, row.key), this.valuesOf(row))
145
158
  values[row.key] = row.invert ? !stored : stored
146
159
  }
147
160
  this.saved = values
@@ -165,10 +178,11 @@ export default {
165
178
  this.busy = row.key
166
179
  this.values = { ...this.values, [row.key]: on }
167
180
  const stored = row.invert ? !on : on
181
+ const values = this.valuesOf(row)
168
182
  try {
169
- const data = await writeFields(this.objectUrl, { [row.key]: stored }, { save: this.content.save, object: this.objectData })
183
+ const data = await writeFields(this.objectUrl, { [row.key]: switchStoredValue(stored, values) }, { save: this.content.save, object: this.objectData })
170
184
  const answered = readPath(data, row.key)
171
- const written = answered !== undefined ? answered === true : stored
185
+ const written = answered !== undefined ? switchIsOn(answered, values) : stored
172
186
  const shown = row.invert ? !written : written
173
187
  this.saved = { ...this.saved, [row.key]: shown }
174
188
  this.values = { ...this.values, [row.key]: shown }
@@ -10,6 +10,17 @@
10
10
  padding: calc(5 * var(--default-grid-baseline));
11
11
  }
12
12
 
13
+ /* A group heading in the header's Actions menu (`{ heading }` in items), as
14
+ the board's "Ga naar" and "Selectie": 12px bold capitals, muted. The menu
15
+ is a popover outside the page, so the class is not under the look class. */
16
+ .cn-index-header-menu__heading {
17
+ font-size: 12px;
18
+ font-weight: 700;
19
+ letter-spacing: 0.04em;
20
+ text-transform: uppercase;
21
+ color: var(--color-text-maxcontrast);
22
+ }
23
+
13
24
  /* The board's header buttons (`headerButtons`) at the end of the title row,
14
25
  centred on the title and its count line. They wrap under the title on a
15
26
  narrow page. */
package/src/css/index.css CHANGED
@@ -48,6 +48,7 @@
48
48
  @import './dashboard.css';
49
49
  @import './detail-page.css';
50
50
  @import './look-board-detail.css';
51
+ @import './look-board-reports.css';
51
52
  @import './reference-preview.css';
52
53
  @import './CnSchemaFormDialog.css';
53
54
  @import './timeline-stages.css';
@@ -0,0 +1,117 @@
1
+ /*
2
+ * The reports page under the board look (screens-reports-page-board-look,
3
+ * board DcRapportages): a 28px title over a muted lead, one section per
4
+ * category headed at 18px, and two-up cards with an icon tile. Scoped under
5
+ * `.cn-look-board` with the page guard, so an app without the look renders as
6
+ * before.
7
+ */
8
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) {
9
+ box-sizing: border-box;
10
+ display: flex;
11
+ flex-direction: column;
12
+ gap: var(--cn-board-section-gap);
13
+ padding: var(--cn-board-content-padding);
14
+ max-width: var(--cn-board-content-max-width);
15
+ }
16
+
17
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__header {
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: 14px;
21
+ padding: 4px 0 8px;
22
+ }
23
+
24
+ /* The 56px inset clears the navigation toggle only while the navigation is
25
+ closed, as on the other board pages. */
26
+ .cn-look-board:has(.app-navigation--closed) .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__header {
27
+ padding-inline-start: 56px;
28
+ }
29
+
30
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__title {
31
+ margin: 0;
32
+ font-size: 28px;
33
+ font-weight: 700;
34
+ line-height: 1.2;
35
+ }
36
+
37
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__description {
38
+ margin: 0;
39
+ font-size: 14px;
40
+ color: var(--color-text-maxcontrast);
41
+ }
42
+
43
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__section {
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 12px;
47
+ /* A host's own `section` rule (a width cap, a margin) does not reach it. */
48
+ max-width: none;
49
+ margin: 0;
50
+ }
51
+
52
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__section-title {
53
+ margin: 0;
54
+ font-size: 18px;
55
+ font-weight: 700;
56
+ }
57
+
58
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__grid {
59
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
60
+ gap: 14px;
61
+ }
62
+
63
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card {
64
+ box-sizing: border-box;
65
+ flex-direction: row;
66
+ align-items: flex-start;
67
+ gap: 14px;
68
+ padding: 18px 20px;
69
+ border: 1px solid var(--color-border);
70
+ border-radius: var(--cn-board-card-radius);
71
+ background: var(--color-main-background);
72
+ color: var(--color-main-text);
73
+ }
74
+
75
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card:hover,
76
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card:focus-visible {
77
+ background: var(--color-background-hover);
78
+ }
79
+
80
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card-icon {
81
+ flex: none;
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ width: 44px;
86
+ height: 44px;
87
+ border-radius: 10px;
88
+ background: var(--color-primary-element-light);
89
+ color: var(--color-primary-element-light-text);
90
+ }
91
+
92
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card-text {
93
+ display: flex;
94
+ flex-direction: column;
95
+ gap: 4px;
96
+ }
97
+
98
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card-title {
99
+ font-size: 17px;
100
+ font-weight: 700;
101
+ }
102
+
103
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card-description {
104
+ font-size: 14px;
105
+ line-height: 1.5;
106
+ color: var(--color-text-maxcontrast);
107
+ }
108
+
109
+ /* The widgets under the cards sit in the page's own padding: the dashboard
110
+ inside draws no second frame. */
111
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__widgets {
112
+ margin-top: 0;
113
+ }
114
+
115
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-dashboard-page {
116
+ padding: 0;
117
+ }
@@ -436,9 +436,20 @@
436
436
  align-items: start;
437
437
  }
438
438
 
439
- /* The card: a section of the shell, or a CnSettingsCard of the page. */
439
+ /* An app component that wraps its sections in one element (decidiq's
440
+ Settings.vue) takes no box of its own: its sections are grid items and
441
+ cards, as if the shell held them directly. Only an element whose own
442
+ children are sections; anything else keeps spanning the grid. */
443
+ .cn-look-board .cn-admin-settings-shell:not(.cn-look-nextcloud *) > :not(.settings-section):has(> .settings-section),
444
+ .cn-look-board.cn-admin-settings-shell:not(.cn-look-nextcloud *) > :not(.settings-section):has(> .settings-section) {
445
+ display: contents;
446
+ }
447
+
448
+ /* The card: a section of the shell (also one inside a wrapping element), or a CnSettingsCard of the page. */
440
449
  .cn-look-board .cn-admin-settings-shell:not(.cn-look-nextcloud *) > .settings-section,
441
450
  .cn-look-board.cn-admin-settings-shell:not(.cn-look-nextcloud *) > .settings-section,
451
+ .cn-look-board .cn-admin-settings-shell:not(.cn-look-nextcloud *) > :not(.settings-section):has(> .settings-section) > .settings-section,
452
+ .cn-look-board.cn-admin-settings-shell:not(.cn-look-nextcloud *) > :not(.settings-section):has(> .settings-section) > .settings-section,
442
453
  .cn-look-board .cn-settings-page__sections:not(.cn-look-nextcloud *) > .cn-settings-card {
443
454
  box-sizing: border-box;
444
455
  display: flex;
@@ -465,7 +476,12 @@
465
476
  display: none;
466
477
  }
467
478
 
479
+ /* NcSettingsSection draws its name `display: inline-flex; justify-content:
480
+ center`; in the card's flex column the name spans the card, so the title
481
+ sat centred. The board's card titles start at the left edge. */
468
482
  .cn-look-board .settings-section:not(.cn-look-nextcloud *) .settings-section__name {
483
+ justify-content: flex-start;
484
+ text-align: start;
469
485
  margin: 0;
470
486
  font-size: 19px;
471
487
  font-weight: 700;
@@ -667,6 +683,21 @@
667
683
  margin-inline-start: auto;
668
684
  }
669
685
 
686
+ /* The title and meta take the room the pill and the date leave, and a long
687
+ title wraps inside it: the pill and the date stay on the title line, as the
688
+ board draws them. They drop under the text only when the card is too narrow
689
+ to hold 240px of text beside them (PqMijnWerk). */
690
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-record-list__main {
691
+ flex: 1 1 240px;
692
+ }
693
+
694
+ /* ---- Saved views on a dashboard card (PqMijnWerk) ------------------------ */
695
+ /* The card body is flush, so the list and its empty line carry the card's
696
+ inset themselves, the same 24px at the sides as a record list. */
697
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-saved-views-widget {
698
+ padding: 4px 24px 20px;
699
+ }
700
+
670
701
  /* The board draws no chevron after the pill and date. */
671
702
  .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-record-list__chevron {
672
703
  display: none;