@conduction/nextcloud-vue 2.121.0 → 2.123.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 (102) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +1 -0
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  4. package/dist/esm/components/CnAttentionFlagWidget/CnAttentionFlagWidget.vue.js +1 -1
  5. package/dist/esm/components/CnAttentionFlagWidget/CnAttentionFlagWidget.vue2.js +18 -4
  6. package/dist/esm/components/CnAttentionFlagWidget/CnAttentionFlagWidget.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAttentionFlagWidget/CnAttentionFlagWidget.vue3.js +63 -32
  8. package/dist/esm/components/CnAttentionFlagWidget/CnAttentionFlagWidget.vue3.js.map +1 -1
  9. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  10. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  11. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  12. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +35 -0
  13. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  14. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +11 -5
  15. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  16. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  17. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +13 -0
  18. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  19. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +37 -5
  20. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  21. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  22. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +40 -0
  23. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  24. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +71 -65
  25. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  26. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue.js +1 -1
  27. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue2.js +20 -0
  28. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue2.js.map +1 -1
  29. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue3.js +2 -1
  30. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue3.js.map +1 -1
  31. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue.js +1 -1
  32. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue2.js +2 -0
  33. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue2.js.map +1 -1
  34. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue3.js +49 -44
  35. package/dist/esm/components/CnFlowsPage/CnFlowBoard.vue3.js.map +1 -1
  36. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue2.js +10 -0
  37. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue2.js.map +1 -1
  38. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue3.js +2 -1
  39. package/dist/esm/components/CnFlowsPage/CnFlowEditorPage.vue3.js.map +1 -1
  40. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue2.js +31 -0
  41. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue2.js.map +1 -1
  42. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue3.js +4 -3
  43. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue3.js.map +1 -1
  44. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +34 -2
  45. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  46. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  47. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js.map +1 -1
  48. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js +1 -0
  49. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js.map +1 -1
  50. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
  51. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +2 -0
  52. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  53. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +2 -1
  54. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  55. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue.js +1 -1
  56. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue2.js +31 -1
  57. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue2.js.map +1 -1
  58. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue3.js +14 -4
  59. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue3.js.map +1 -1
  60. package/dist/esm/composables/flowGraphLayout.js +36 -7
  61. package/dist/esm/composables/flowGraphLayout.js.map +1 -1
  62. package/dist/esm/composables/useFlowStore.js +7 -3
  63. package/dist/esm/composables/useFlowStore.js.map +1 -1
  64. package/dist/esm/l10n/en.json.js +3 -1
  65. package/dist/esm/l10n/en.json.js.map +1 -1
  66. package/dist/esm/l10n/nl.json.js +3 -1
  67. package/dist/esm/l10n/nl.json.js.map +1 -1
  68. package/dist/esm/library-style.js +1 -1
  69. package/dist/esm/nextcloud-vue.css +670 -316
  70. package/dist/esm/utils/manifestTranslate.js +12 -3
  71. package/dist/esm/utils/manifestTranslate.js.map +1 -1
  72. package/dist/esm/utils/routeFilters.js +3 -3
  73. package/dist/esm/utils/routeFilters.js.map +1 -1
  74. package/dist/nextcloud-vue.cjs.js +1218 -847
  75. package/dist/nextcloud-vue.cjs.js.map +2 -2
  76. package/dist/nextcloud-vue.css +670 -316
  77. package/l10n/en.json +3 -1
  78. package/l10n/nl.json +3 -1
  79. package/package.json +1 -1
  80. package/src/components/CnAppRoot/CnAppRoot.vue +1 -0
  81. package/src/components/CnAttentionFlagWidget/CnAttentionFlagWidget.vue +50 -5
  82. package/src/components/CnChartWidget/CnChartWidget.vue +10 -8
  83. package/src/components/CnDetailPage/CnDetailPage.md +10 -0
  84. package/src/components/CnDetailPage/CnDetailPage.vue +42 -1
  85. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +39 -0
  86. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +95 -37
  87. package/src/components/CnFlowDetail/CnFlowDetail.vue +21 -0
  88. package/src/components/CnFlowsPage/CnFlowBoard.vue +35 -18
  89. package/src/components/CnFlowsPage/CnFlowEditorPage.vue +11 -0
  90. package/src/components/CnGraphCanvas/CnGraphCanvas.vue +34 -2
  91. package/src/components/CnIndexPage/CnIndexPage.vue +34 -2
  92. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +1 -0
  93. package/src/components/CnObjectSidebar/CnFilesTab.vue +3 -0
  94. package/src/components/CnSectionsWidget/CnSectionsWidget.vue +53 -2
  95. package/src/composables/flowGraphLayout.js +35 -6
  96. package/src/composables/useFlowStore.js +7 -3
  97. package/src/css/detail-page.css +6 -0
  98. package/src/css/look-board-detail.css +53 -6
  99. package/src/css/look-board-index.css +151 -12
  100. package/src/css/look-board.css +116 -4
  101. package/src/utils/manifestTranslate.js +12 -3
  102. package/src/utils/routeFilters.js +3 -3
@@ -101,10 +101,10 @@
101
101
  </NcNoteCard>
102
102
 
103
103
  <div class="cn-flow-board__body">
104
- <aside v-if="showPalette" class="cn-flow-board__card cn-flow-board__palette" data-testid="flow-board-palette">
105
- <h3 class="cn-flow-board__card-title">
106
- {{ t('nextcloud-vue', 'Steps') }}
107
- </h3>
104
+ <aside v-if="showPalette"
105
+ class="cn-flow-board__card cn-flow-board__palette"
106
+ :aria-label="t('nextcloud-vue', 'Steps')"
107
+ data-testid="flow-board-palette">
108
108
  <section v-for="group in paletteGroups"
109
109
  :key="group.app"
110
110
  class="cn-flow-board__palette-group">
@@ -132,6 +132,8 @@
132
132
  <CnFlowDetail :id="id"
133
133
  :app="app"
134
134
  :run="run"
135
+ :layoutDirection="flowDirection === 'down' ? 'down' : 'right'"
136
+ :fitMaxZoom="flowDirection === 'down' ? 1 : 0"
135
137
  :toolbarActions="false"
136
138
  :addStep="!showPalette"
137
139
  @save="$emit('save')"
@@ -177,12 +179,14 @@
177
179
  :emptyText="runTab === 'step' && !store.selectedNodeId ? t('nextcloud-vue', 'Select a step on the canvas to see what it did.') : ''" />
178
180
  </template>
179
181
  <template v-else-if="store.selectedNode">
180
- <p class="cn-flow-board__eyebrow">
181
- {{ stepTypeLabel }}
182
- </p>
183
182
  <h3 class="cn-flow-board__card-title" data-testid="flow-board-step-name">
184
183
  {{ stepName }}
185
184
  </h3>
185
+ <!-- The step type as a pill under the name (DqFlow "Beslistabel van OpenRegister"). -->
186
+ <CnStatusBadge :label="stepTypeLabel"
187
+ variant="purple"
188
+ class="cn-flow-board__step-type"
189
+ data-testid="flow-board-step-type" />
186
190
  <dl v-if="stepSettings.length > 0" class="cn-flow-board__settings">
187
191
  <div v-for="setting in stepSettings"
188
192
  :key="setting.key"
@@ -285,6 +289,8 @@ export default {
285
289
  palette: { type: String, default: 'picker' },
286
290
  /** Free text under the path in the palette card. */
287
291
  paletteFootnote: { type: String, default: '' },
292
+ /** `down` runs the steps top to bottom and opens them at their own size (DqFlow); default `right`, left to right and fitted. */
293
+ flowDirection: { type: String, default: 'right' },
288
294
  /** An info notice under the path. */
289
295
  notice: { type: String, default: '' },
290
296
  /** The pill beside the title ("Case type flow"). */
@@ -769,8 +775,9 @@ export default {
769
775
  min-height: 520px;
770
776
  }
771
777
 
778
+ /* Palette, canvas and inspector at 220 / rest / 280 (DqFlow). */
772
779
  .cn-flow-board--with-palette .cn-flow-board__body {
773
- grid-template-columns: 240px minmax(0, 1fr) 340px;
780
+ grid-template-columns: 220px minmax(0, 1fr) 280px;
774
781
  }
775
782
 
776
783
  .cn-flow-board__card {
@@ -784,14 +791,22 @@ export default {
784
791
  background: var(--color-main-background);
785
792
  }
786
793
 
794
+ /* The canvas is the white card with the dot grid; the palette beside it sits
795
+ on the page ground (DqFlow). */
787
796
  .cn-flow-board__canvas {
788
797
  padding: 0;
789
798
  overflow: hidden;
790
- background-color: var(--color-background-hover);
791
- background-image: radial-gradient(var(--color-border-dark) 1px, transparent 1px);
799
+ background-color: var(--color-main-background);
800
+ background-image: radial-gradient(var(--color-border) 1px, transparent 1px);
792
801
  background-size: 16px 16px;
793
802
  }
794
803
 
804
+ .cn-flow-board__palette {
805
+ gap: 14px;
806
+ padding: 14px;
807
+ background: transparent;
808
+ }
809
+
795
810
  .cn-flow-board__canvas > :first-child {
796
811
  flex: 1 1 auto;
797
812
  min-height: 0;
@@ -809,10 +824,8 @@ export default {
809
824
  font-weight: 600;
810
825
  }
811
826
 
812
- .cn-flow-board__eyebrow {
813
- margin: 0;
814
- color: var(--color-text-maxcontrast);
815
- font-size: 13px;
827
+ .cn-flow-board__step-type {
828
+ align-self: flex-start;
816
829
  }
817
830
 
818
831
  .cn-flow-board__validity,
@@ -841,20 +854,23 @@ export default {
841
854
  .cn-flow-board__palette-group {
842
855
  display: flex;
843
856
  flex-direction: column;
844
- gap: 4px;
857
+ gap: 6px;
845
858
  }
846
859
 
860
+ /* The providing app as a small caps label, no title row above the groups. */
847
861
  .cn-flow-board__palette-app {
848
862
  margin: 0;
849
863
  color: var(--color-text-maxcontrast);
850
- font-size: 13px;
864
+ font-size: 12px;
851
865
  font-weight: 600;
866
+ letter-spacing: 0.06em;
867
+ text-transform: uppercase;
852
868
  }
853
869
 
854
870
  .cn-flow-board__palette-list {
855
871
  display: flex;
856
872
  flex-direction: column;
857
- gap: 4px;
873
+ gap: 6px;
858
874
  margin: 0;
859
875
  padding: 0;
860
876
  list-style: none;
@@ -866,11 +882,12 @@ export default {
866
882
  --nldesign-component-button-font-weight: 400;
867
883
  width: 100%;
868
884
  min-height: 34px;
869
- padding: 6px 10px;
885
+ padding: 8px 10px;
870
886
  border: 1px solid var(--color-border);
871
887
  border-radius: 8px;
872
888
  background: var(--color-main-background);
873
889
  color: var(--color-main-text);
890
+ font-size: 14px;
874
891
  font-weight: 400;
875
892
  text-align: start;
876
893
  cursor: pointer;
@@ -9,6 +9,7 @@
9
9
  :listLabel="listLabel ? cnTranslate(listLabel) : ''"
10
10
  :palette="palette"
11
11
  :paletteFootnote="paletteFootnote ? cnTranslate(paletteFootnote) : ''"
12
+ :flowDirection="flowDirection"
12
13
  :notice="notice ? cnTranslate(notice) : ''"
13
14
  :pill="pill ? cnTranslate(pill) : ''"
14
15
  :testSubject="testSubject ? { ...testSubject, label: testSubject.label ? cnTranslate(testSubject.label) : '' } : null"
@@ -123,6 +124,16 @@ export default {
123
124
  default: '',
124
125
  },
125
126
 
127
+ /**
128
+ * Board look: `down` lays the steps out top to bottom and opens them
129
+ * at their own size instead of zoomed to fit; `right` (default) lays
130
+ * them left to right. Manifest key `config.flowDirection`.
131
+ */
132
+ flowDirection: {
133
+ type: String,
134
+ default: 'right',
135
+ },
136
+
126
137
  /**
127
138
  * Board look: the records a test run can take as its subject,
128
139
  * `{ register, schema, labelField?, label? }`. Manifest key
@@ -9,7 +9,7 @@
9
9
  :edges="edges"
10
10
  :minZoom="minZoom"
11
11
  :maxZoom="maxZoom"
12
- :fitViewOnInit="fitView"
12
+ :fitViewOnInit="fitView && !fitMaxZoom"
13
13
  :snapToGrid="snapToGrid"
14
14
  :snapGrid="snapGrid"
15
15
  :nodesDraggable="interactive"
@@ -21,7 +21,8 @@
21
21
  @connect="onConnect"
22
22
  @nodeClick="onNodeClick"
23
23
  @edgeClick="onEdgeClick"
24
- @paneClick="onPaneClick">
24
+ @paneClick="onPaneClick"
25
+ @nodesInitialized="onNodesInitialized">
25
26
  <!-- Per-type node components, Vue Flow's convention. A host that
26
27
  registers `type: 'trigger'` gets `#node-trigger`; anything
27
28
  unregistered falls back to CnFlowNode, which carries the
@@ -206,6 +207,15 @@ export default {
206
207
  default: true,
207
208
  },
208
209
 
210
+ /**
211
+ * The most that first frame may zoom in. 0 lets it go to `maxZoom`;
212
+ * 1 keeps a graph that fits at its own size.
213
+ */
214
+ fitMaxZoom: {
215
+ type: Number,
216
+ default: 0,
217
+ },
218
+
209
219
  /** Snap dragged nodes to the grid. */
210
220
  snapToGrid: {
211
221
  type: Boolean,
@@ -293,6 +303,13 @@ export default {
293
303
  return { project, zoomIn, zoomOut, zoomTo, fitViewNow, viewport }
294
304
  },
295
305
 
306
+ data() {
307
+ return {
308
+ // Whether the capped first frame (`fitMaxZoom`) has been taken.
309
+ framedOnce: false,
310
+ }
311
+ },
312
+
296
313
  computed: {
297
314
  /**
298
315
  * @return {boolean} Whether the canvas accepts interaction at all.
@@ -315,6 +332,21 @@ export default {
315
332
  },
316
333
 
317
334
  methods: {
335
+ /**
336
+ * Frame the graph once its nodes have a size, no closer than
337
+ * `fitMaxZoom`. Only the first time: a step added later must not move
338
+ * the view the author is working in.
339
+ *
340
+ * @return {void}
341
+ */
342
+ onNodesInitialized() {
343
+ if (!this.fitView || !this.fitMaxZoom || this.framedOnce) {
344
+ return
345
+ }
346
+ this.framedOnce = true
347
+ this.fitViewNow({ maxZoom: this.fitMaxZoom, padding: 0.1 })
348
+ },
349
+
318
350
  /**
319
351
  * Forward Vue Flow's node changes.
320
352
  *
@@ -4218,6 +4218,8 @@ export default {
4218
4218
  appliedSavedViewId: '',
4219
4219
  // The sort option the applied view's `overrides.sortOption` selected, so leaving the view can undo it.
4220
4220
  viewSortOption: '',
4221
+ // The applied view's filters as one comparable key, so the address keeps naming the view until they change.
4222
+ appliedViewFiltersKey: '',
4221
4223
  /**
4222
4224
  * The non-`_` query keys this page owns, i.e. may clear on the next
4223
4225
  * persist. Seeded with what it adopted from the query on load, and
@@ -9957,14 +9959,23 @@ export default {
9957
9959
  * (same resulting path/query) is swallowed.
9958
9960
  *
9959
9961
  * @param {{filters?: object, search?: string, sortKey?: ?string, sortOrder?: string, sortKeys?: Array<{key: string, order: string}>}} state Current view state.
9960
- * @param {{savedView?: string}} [options] The saved view the address keeps naming, or none.
9962
+ * Without `savedView` the address keeps the applied view it names while
9963
+ * the filters are still the view's: a sort, a search or the view's own
9964
+ * sort option shows the same view's records.
9965
+ *
9966
+ * @param {{savedView?: string}} [options] The saved view the address keeps naming, or `''` for none.
9961
9967
  * @return {void}
9968
+ * @spec openspec/changes/saved-view-kept-in-route/specs/saved-view-kept-in-route/spec.md#requirement-the-address-keeps-naming-the-applied-view
9962
9969
  */
9963
- persistViewStateToRoute(state, { savedView = '' } = {}) {
9970
+ persistViewStateToRoute(state, { savedView } = {}) {
9964
9971
  if (!this.$router || !this.$route) {
9965
9972
  return
9966
9973
  }
9967
9974
  const query = { ...this.$route.query }
9975
+ if (savedView === undefined) {
9976
+ const named = this.appliedSavedViewId !== '' && query.savedView === this.appliedSavedViewId
9977
+ savedView = named && this.viewFiltersKey(state.filters) === this.appliedViewFiltersKey ? this.appliedSavedViewId : ''
9978
+ }
9968
9979
  // The menu entry of a saved view is active while the address names
9969
9980
  // it; any other change to the filters is no longer that view.
9970
9981
  if (savedView !== '') {
@@ -10005,6 +10016,26 @@ export default {
10005
10016
  this.$router.replace({ query }).catch(() => {})
10006
10017
  },
10007
10018
 
10019
+ /**
10020
+ * Filters as one comparable key: empty values dropped, every value a
10021
+ * sorted list of strings, the keys sorted.
10022
+ *
10023
+ * @param {object} filters The filters.
10024
+ * @return {string}
10025
+ * @spec openspec/changes/saved-view-kept-in-route/specs/saved-view-kept-in-route/spec.md#requirement-the-address-keeps-naming-the-applied-view
10026
+ */
10027
+ viewFiltersKey(filters) {
10028
+ const out = {}
10029
+ for (const key of Object.keys(filters || {}).sort()) {
10030
+ const value = filters[key]
10031
+ const values = (Array.isArray(value) ? value : [value]).filter((v) => v !== undefined && v !== null && v !== '').map(String).sort()
10032
+ if (values.length > 0) {
10033
+ out[key] = values
10034
+ }
10035
+ }
10036
+ return JSON.stringify(out)
10037
+ },
10038
+
10008
10039
  /**
10009
10040
  * "Clear all" from the sidebar: reset search, every active filter, the
10010
10041
  * sort and the folder-sidebar selection in one fetch + one route
@@ -11755,6 +11786,7 @@ export default {
11755
11786
  const ctx = typeof this.selfFetchTokenCtx === 'function' ? this.selfFetchTokenCtx() : {}
11756
11787
  state.filters = resolveFilterTokens(state.filters, ctx)
11757
11788
  }
11789
+ this.appliedViewFiltersKey = this.viewFiltersKey(state.filters)
11758
11790
  // A view that carries columns wins while it is applied; one without leaves the person's own layout.
11759
11791
  const viewColumns = view && view.query && Array.isArray(view.query.columns) ? view.query.columns.map(String) : []
11760
11792
  this.appliedViewColumns = viewColumns.length > 0 ? viewColumns : null
@@ -1,6 +1,7 @@
1
1
  <template>
2
2
  <NcAppSidebar
3
3
  v-model:open="internalOpen"
4
+ class="cn-index-sidebar-panel"
4
5
  :name="resolvedName"
5
6
  :title="resolvedName"
6
7
  :subname="resolvedSubname"
@@ -32,6 +32,7 @@
32
32
  :selectionLabelOne="selectionLabelOne"
33
33
  :showClearSelection="showClearSelection"
34
34
  :sideCards="sideCards"
35
+ :sideCardsPlacement="sideCardsPlacement"
35
36
  :groupBy="groupBy"
36
37
  :facets="facets"
37
38
  @changed="onBrowserChanged" />
@@ -422,6 +423,8 @@ export default {
422
423
  * @type {Array<{id: string, title: string, fields?: Array<object>, actions?: Array<object>}>}
423
424
  */
424
425
  sideCards: { type: Array, default: () => [] },
426
+ /** Forwarded to CnFilesBrowser: `page-side` puts the side cards in the detail page's side column. */
427
+ sideCardsPlacement: { type: String, default: 'beside' },
425
428
  /** Forwarded to CnFilesBrowser: the key of a declared column to group the files on. */
426
429
  groupBy: { type: String, default: '' },
427
430
  /**
@@ -9,6 +9,7 @@
9
9
  :class="{
10
10
  'cn-sections-widget--cards': cards,
11
11
  'cn-sections-widget--two-column': twoColumn,
12
+ 'cn-sections-widget--grid': gridColumns > 1,
12
13
  'cn-sections-widget--plain': plain,
13
14
  }"
14
15
  data-testid="cn-sections-widget">
@@ -16,7 +17,8 @@
16
17
  v-for="column in columns"
17
18
  :key="column.id"
18
19
  class="cn-sections-widget__column"
19
- :class="`cn-sections-widget__column--${column.id}`">
20
+ :class="`cn-sections-widget__column--${column.id}`"
21
+ :style="gridColumns > 1 ? { '--cn-sections-columns': gridColumns } : null">
20
22
  <section
21
23
  v-for="section in column.sections"
22
24
  :key="section.key"
@@ -59,6 +61,9 @@
59
61
  </div>
60
62
  </section>
61
63
  </div>
64
+ <p v-if="footnote" class="cn-sections-widget__footnote" data-testid="cn-sections-widget-footnote">
65
+ {{ footnote }}
66
+ </p>
62
67
  </div>
63
68
  </template>
64
69
 
@@ -123,7 +128,11 @@ export default {
123
128
  * - `dividers` — draw a rule between sections (default true). `false` stacks them 8px apart with no rule,
124
129
  * for a row of strips that each say one thing (a record's banners).
125
130
  *
126
- * @type {{sections?: Array<object>, cards?: boolean, hideEmpty?: boolean, dividers?: boolean}}
131
+ * - `columns` — lay the sections out side by side in this many columns (2 to 4), one column on a narrow
132
+ * screen. Ignored when a section asks for the side column.
133
+ * - `footnote` — a line of muted text under the sections.
134
+ *
135
+ * @type {{sections?: Array<object>, cards?: boolean, hideEmpty?: boolean, dividers?: boolean, columns?: number, footnote?: string}}
127
136
  */
128
137
  content: {
129
138
  type: Object,
@@ -285,6 +294,32 @@ export default {
285
294
  return this.sections.some((section) => section.column === 'side')
286
295
  },
287
296
 
297
+ /**
298
+ * How many columns the sections are laid out in side by side: the
299
+ * configured `columns`, capped at four; 1 (stacked) without it or
300
+ * when a section asks for the side column.
301
+ *
302
+ * @return {number}
303
+ * @spec openspec/changes/sections-card-grid/specs/sections-card-grid/spec.md#requirement-a-sections-widget-can-lay-its-cards-out-in-columns
304
+ */
305
+ gridColumns() {
306
+ const n = Math.floor(Number(this.content?.columns))
307
+ if (!Number.isFinite(n) || n < 2 || this.twoColumn) {
308
+ return 1
309
+ }
310
+ return Math.min(n, 4)
311
+ },
312
+
313
+ /**
314
+ * The translated footnote under the sections, or ''.
315
+ *
316
+ * @return {string}
317
+ * @spec openspec/changes/sections-card-grid/specs/sections-card-grid/spec.md#requirement-a-sections-widget-can-close-with-a-footnote
318
+ */
319
+ footnote() {
320
+ return this.tr(this.content?.footnote || '')
321
+ },
322
+
288
323
  /**
289
324
  * The columns to draw, each with its sections in order.
290
325
  *
@@ -451,6 +486,22 @@ export default {
451
486
  }
452
487
  }
453
488
 
489
+ /* Sections side by side (`columns`): at most that many to a row, each at
490
+ least 240px wide, so a narrow cell wraps them onto more rows. */
491
+ .cn-sections-widget.cn-sections-widget--grid .cn-sections-widget__column {
492
+ --cn-sections-gap: 14px;
493
+ display: grid;
494
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, max(240px, calc((100% - (var(--cn-sections-columns, 2) - 1) * var(--cn-sections-gap)) / var(--cn-sections-columns, 2)))), 1fr));
495
+ gap: var(--cn-sections-gap);
496
+ align-items: start;
497
+ }
498
+
499
+ .cn-sections-widget__footnote {
500
+ margin: calc(var(--default-grid-baseline, 4px) * 3) 0 0;
501
+ color: var(--color-text-maxcontrast);
502
+ font-size: 13px;
503
+ }
504
+
454
505
  /* A host page may space every <section>; these sections are spaced by the column gap only. */
455
506
  .cn-sections-widget__section {
456
507
  margin: 0;
@@ -79,6 +79,34 @@ export const FLOW_LAYOUT_MARGIN = 60
79
79
  */
80
80
  export const FLOW_LAYOUT_TOP = 96
81
81
 
82
+ /**
83
+ * Vertical distance between layers when the run reads top to bottom
84
+ * (`direction: 'down'`), in canvas pixels: a step card (~62px) plus room for
85
+ * the line and its arrowhead, as DqFlow draws its steps 100 to 110px apart.
86
+ *
87
+ * @type {number}
88
+ */
89
+ export const FLOW_LAYOUT_LAYER_HEIGHT_DOWN = 110
90
+
91
+ /**
92
+ * Where a node sits, from its layer (run order) and its lane (branch).
93
+ *
94
+ * `right` (default): layers are columns left to right, lanes are rows.
95
+ * `down`: layers are rows top to bottom, lanes are columns.
96
+ *
97
+ * @param {number} layer Zero-based layer.
98
+ * @param {number} lane Zero-based lane.
99
+ * @param {string} direction `right` or `down`.
100
+ * @param {number} top The y of the first layer or row.
101
+ * @return {{x: number, y: number}} The point.
102
+ */
103
+ function cellPoint(layer, lane, direction, top) {
104
+ if (direction === 'down') {
105
+ return { x: FLOW_LAYOUT_MARGIN + (lane * FLOW_LAYOUT_COLUMN_WIDTH), y: top + (layer * FLOW_LAYOUT_LAYER_HEIGHT_DOWN) }
106
+ }
107
+ return { x: FLOW_LAYOUT_MARGIN + (layer * FLOW_LAYOUT_COLUMN_WIDTH), y: top + (lane * FLOW_LAYOUT_ROW_HEIGHT) }
108
+ }
109
+
82
110
  /**
83
111
  * The position a node carries, in either of the two spellings in circulation.
84
112
  *
@@ -324,9 +352,11 @@ function orderRows(nodes, lines, columns) {
324
352
  * @param {Array<object>} nodes The flow's nodes.
325
353
  * @param {Array<{source: string, target: string}>} lines The expanded edges.
326
354
  * @param {Array<string>} startIds The ids a run enters through.
355
+ * @param {object} [options] `{ direction }`: `right` (default) runs left to
356
+ * right, `down` runs top to bottom with branches side by side.
327
357
  * @return {Array<object>} New node objects carrying computed positions.
328
358
  */
329
- export function layoutFlowNodes(nodes, lines, startIds) {
359
+ export function layoutFlowNodes(nodes, lines, startIds, options = {}) {
330
360
  const list = Array.isArray(nodes) ? nodes : []
331
361
  if (!list.length) {
332
362
  return []
@@ -336,8 +366,7 @@ export function layoutFlowNodes(nodes, lines, startIds) {
336
366
  const rows = orderRows(list, lines, columns)
337
367
 
338
368
  return list.map((node) => {
339
- const x = FLOW_LAYOUT_MARGIN + (columns.get(node.id) * FLOW_LAYOUT_COLUMN_WIDTH)
340
- const y = FLOW_LAYOUT_TOP + (rows.get(node.id) * FLOW_LAYOUT_ROW_HEIGHT)
369
+ const { x, y } = cellPoint(columns.get(node.id), rows.get(node.id), options.direction, FLOW_LAYOUT_TOP)
341
370
  return { ...node, x, y, position: { x, y } }
342
371
  })
343
372
  }
@@ -359,9 +388,10 @@ export function layoutFlowNodes(nodes, lines, startIds) {
359
388
  * @param {Array<object>} nodes The flow's nodes, some positioned and some not.
360
389
  * @param {Array<{source: string, target: string}>} lines The expanded edges.
361
390
  * @param {Array<string>} startIds The ids a run enters through.
391
+ * @param {object} [options] `{ direction }`, as {@link layoutFlowNodes}.
362
392
  * @return {Array<object>} The same array shape, loose nodes placed beneath.
363
393
  */
364
- export function placeLooseNodes(nodes, lines, startIds) {
394
+ export function placeLooseNodes(nodes, lines, startIds, options = {}) {
365
395
  const list = Array.isArray(nodes) ? nodes : []
366
396
  const loose = list.filter((node) => readNodePoint(node) === null)
367
397
  if (!loose.length) {
@@ -395,8 +425,7 @@ export function placeLooseNodes(nodes, lines, startIds) {
395
425
  const row = rowsTaken.get(column) || 0
396
426
  rowsTaken.set(column, row + 1)
397
427
 
398
- const x = FLOW_LAYOUT_MARGIN + (column * FLOW_LAYOUT_COLUMN_WIDTH)
399
- const y = top + (row * FLOW_LAYOUT_ROW_HEIGHT)
428
+ const { x, y } = cellPoint(column, row, options.direction, top)
400
429
  return { ...node, x, y, position: { x, y } }
401
430
  })
402
431
  }
@@ -101,6 +101,10 @@ export const useFlowStore = defineStore('cnFlow', {
101
101
  flow: emptyFlow(),
102
102
  flows: [],
103
103
 
104
+ // How the automatic layout runs: `right` (left to right) or `down`
105
+ // (top to bottom). Set by the editor that hosts the canvas.
106
+ layoutDirection: 'right',
107
+
104
108
  // Snapshots of `flow`, oldest first, for Ctrl+Z.
105
109
  //
106
110
  // A canvas is direct manipulation: a mis-drag or a mis-aimed Delete
@@ -1659,7 +1663,7 @@ export const useFlowStore = defineStore('cnFlow', {
1659
1663
  return
1660
1664
  }
1661
1665
 
1662
- this.flow.nodes = layoutFlowNodes(this.nodes, this.canvasEdges, this.startNodeIds)
1666
+ this.flow.nodes = layoutFlowNodes(this.nodes, this.canvasEdges, this.startNodeIds, { direction: this.layoutDirection })
1663
1667
  this.dirty = true
1664
1668
  },
1665
1669
 
@@ -1693,12 +1697,12 @@ export const useFlowStore = defineStore('cnFlow', {
1693
1697
  }
1694
1698
 
1695
1699
  if (needsFullLayout(nodes)) {
1696
- this.flow.nodes = layoutFlowNodes(nodes, this.canvasEdges, this.startNodeIds)
1700
+ this.flow.nodes = layoutFlowNodes(nodes, this.canvasEdges, this.startNodeIds, { direction: this.layoutDirection })
1697
1701
  return true
1698
1702
  }
1699
1703
 
1700
1704
  if (nodes.some((node) => !this.hasPosition(node))) {
1701
- this.flow.nodes = placeLooseNodes(nodes, this.canvasEdges, this.startNodeIds)
1705
+ this.flow.nodes = placeLooseNodes(nodes, this.canvasEdges, this.startNodeIds, { direction: this.layoutDirection })
1702
1706
  return true
1703
1707
  }
1704
1708
 
@@ -470,6 +470,12 @@
470
470
  gap: calc(5 * var(--default-grid-baseline));
471
471
  }
472
472
 
473
+ /* A body widget's own cards (`cnDetailSideColumn`) stand in the column's
474
+ flow, at its gap, and the slot takes no room while it is empty. */
475
+ .cn-detail-page__side-takeover {
476
+ display: contents;
477
+ }
478
+
473
479
  /* The record as a place (case-page-and-list-as-a-place) */
474
480
 
475
481
  /* A skip link that reaches the page's declared primary action. Off screen
@@ -652,9 +652,10 @@
652
652
  display: flex;
653
653
  }
654
654
 
655
- /* The notice at the top of the side column: radius 12, padding 14px 16px. */
656
- .cn-look-board .cn-detail-page__side-item--notice .cn-widget-wrapper,
657
- .cn-look-board .cn-detail-page__side-item--notice .cn-banner-widget,
655
+ /* The notice at the top of the side column: one tinted block, radius 12,
656
+ padding 14px 16px. Only the outer layer paints; the card wrapper, the
657
+ banner and its note card inside it stay flat, so the notice is not drawn
658
+ as a box in a box. */
658
659
  .cn-look-board .cn-detail-page__side-item--notice > * {
659
660
  background: var(--cn-board-notice-bg);
660
661
  border: none;
@@ -663,6 +664,23 @@
663
664
  padding: 14px 16px;
664
665
  }
665
666
 
667
+ .cn-look-board .cn-detail-page__side-item--notice > * .cn-widget-wrapper,
668
+ .cn-look-board .cn-detail-page__side-item--notice > * .cn-widget-wrapper__content,
669
+ .cn-look-board .cn-detail-page__side-item--notice .cn-banner-widget,
670
+ .cn-look-board .cn-detail-page__side-item--notice .cn-banner-widget__card {
671
+ background: transparent;
672
+ border: none;
673
+ border-radius: 0;
674
+ margin: 0;
675
+ padding: 0;
676
+ }
677
+
678
+ /* A notice whose condition does not hold renders no banner; its slot then
679
+ takes no room instead of standing as an empty tinted block. */
680
+ .cn-look-board .cn-detail-page__side-item--notice:not(:has(.cn-banner-widget)) {
681
+ display: none;
682
+ }
683
+
666
684
  /* The History card ends with the record's identifier. */
667
685
  .cn-look-board .cn-detail-page__side-item--with-identifier .cn-widget-wrapper {
668
686
  border-bottom: none;
@@ -692,14 +710,16 @@
692
710
  }
693
711
 
694
712
  /* The kicker keeps its tone under a theme that colours every h3 with
695
- `!important` (thematiq's heading colour); only `!important` beats it. */
713
+ `!important` (thematiq's heading colour); only `!important` beats it. The
714
+ boards set it in sentence case ("Wat nu? Stap 2: in behandeling", DqZaak,
715
+ DqSociaalDomein), not as the plain look's spaced capitals. */
696
716
  .cn-look-board .cn-next-step-card--board .cn-next-step-card__title.cn-next-step-card__title {
697
717
  color: var(--color-primary-element-light-text) !important;
698
718
  font-size: 13px !important;
699
719
  font-weight: 700;
700
- letter-spacing: 0.06em;
720
+ letter-spacing: 0;
701
721
  line-height: normal;
702
- text-transform: uppercase;
722
+ text-transform: none;
703
723
  }
704
724
 
705
725
  .cn-look-board .cn-next-step-card--board .cn-next-step-card__list {
@@ -974,3 +994,30 @@
974
994
  .cn-look-board .cn-tasks-widget .cn-tasks-widget__cell-actions .button-vue .button-vue__text.button-vue__text {
975
995
  padding-inline: 0;
976
996
  }
997
+
998
+ /* ---- Grouped cards (CnGroupedCardsWidget, DqZaaktype "Fasen en taken") --- */
999
+ /* Groups 14px apart, each padded 14px with a 10px radius and a bold title; a
1000
+ card in a group is padded 9px 12px with the 8px control radius, its title
1001
+ 15px. The doubled class outranks the widget's scoped rules.
1002
+ @spec openspec/changes/board-grouped-cards/specs/board-grouped-cards/spec.md#requirement-board-grouped-cards-take-the-boards-group-and-card-shape */
1003
+ .cn-look-board .cn-grouped-cards__grid.cn-grouped-cards__grid {
1004
+ gap: 14px;
1005
+ }
1006
+
1007
+ .cn-look-board .cn-grouped-cards__group.cn-grouped-cards__group {
1008
+ padding: 14px;
1009
+ border-radius: 10px;
1010
+ }
1011
+
1012
+ .cn-look-board .cn-grouped-cards__group-title.cn-grouped-cards__group-title {
1013
+ font-weight: 700;
1014
+ }
1015
+
1016
+ .cn-look-board .cn-grouped-cards__item.cn-grouped-cards__item {
1017
+ padding: 9px 12px;
1018
+ border-radius: var(--cn-board-control-radius, 8px);
1019
+ }
1020
+
1021
+ .cn-look-board .cn-grouped-cards__item-title.cn-grouped-cards__item-title {
1022
+ font-size: 15px;
1023
+ }