@mixd-id/web-scaffold 0.2.250801014 → 0.2.260801002

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 (113) hide show
  1. package/README.md +25 -1
  2. package/docs/components/AIDashboard.md +117 -32
  3. package/docs/components/Alert.md +3 -1
  4. package/docs/components/Card.md +5 -0
  5. package/docs/components/PivotList.md +207 -0
  6. package/docs/components/Textbox.md +20 -1
  7. package/docs/components/Tooltip.md +148 -0
  8. package/package.json +17 -61
  9. package/src/components/AIChat.vue +14 -5
  10. package/src/components/AIChatItem.vue +34 -3
  11. package/src/components/AIDashboard.vue +144 -98
  12. package/src/components/Alert.vue +58 -35
  13. package/src/components/Card.vue +55 -6
  14. package/src/components/Carousel.vue +1 -1
  15. package/src/{widgets → components}/CartSetting.vue +1 -0
  16. package/src/components/Chart.vue +2 -2
  17. package/src/components/Checkbox.vue +22 -24
  18. package/src/components/CodeEditor.vue +2 -0
  19. package/src/{widgets → components}/CollapsiblePanelSetting.vue +1 -0
  20. package/src/components/ColorPicker.vue +2 -2
  21. package/src/{widgets → components}/ComponentSetting2.vue +1 -1
  22. package/src/components/Confirm.vue +2 -0
  23. package/src/{widgets → components}/ContactForm.vue +1 -1
  24. package/src/components/CopyToClipboard.vue +0 -1
  25. package/src/components/Datepicker.vue +2 -0
  26. package/src/components/Datetimepicker.vue +1 -1
  27. package/src/components/Dropdown.vue +1 -1
  28. package/src/{widgets → components}/FeatureList.vue +1 -1
  29. package/src/components/Gmaps.vue +1 -1
  30. package/src/{widgets → components}/IconList.vue +1 -1
  31. package/src/components/Image.vue +1 -1
  32. package/src/components/ImagePreview.vue +32 -51
  33. package/src/components/List.vue +45 -8
  34. package/src/components/MarkdownEdit.vue +1 -1
  35. package/src/components/MarkdownPreview.vue +4 -1
  36. package/src/{widgets → components}/MenuItem1Setting.vue +1 -0
  37. package/src/components/Metric.vue +30 -7
  38. package/src/components/MultiDropdown.vue +3 -1
  39. package/src/{widgets → components}/MultiValueSetting.vue +2 -2
  40. package/src/{widgets → components}/MultiValueSetting2.vue +2 -2
  41. package/src/components/OTPField.vue +3 -1
  42. package/src/components/PivotList.vue +1239 -0
  43. package/src/{widgets → components}/PresetBar.vue +1 -1
  44. package/src/components/PresetSelectorFilterItem.vue +1 -1
  45. package/src/components/Radio.vue +1 -1
  46. package/src/{widgets → components}/Share.vue +1 -1
  47. package/src/components/SvgEditor.vue +1 -1
  48. package/src/components/Switch.vue +1 -1
  49. package/src/components/TextWithTag.vue +2 -2
  50. package/src/components/Textbox.vue +63 -5
  51. package/src/components/Toast.vue +2 -1
  52. package/src/components/Tooltip.vue +626 -0
  53. package/src/components/VirtualGrid.vue +2 -2
  54. package/src/components/VirtualTable.vue +4 -3
  55. package/src/{widgets → components}/WebDatasourceSelector.vue +2 -0
  56. package/src/{widgets → components}/WebLayoutSelector.vue +1 -1
  57. package/src/directives/tooltip.js +131 -0
  58. package/src/index.js +11 -235
  59. package/src/themes/default/index.js +16 -0
  60. package/src/utils/ai-dashboard.js +163 -38
  61. package/src/utils/helpers.mjs +52 -5
  62. package/src/utils/list.mjs +4 -1
  63. package/src/utils/pivot-formula.mjs +358 -0
  64. package/src/utils/pivot-list.mjs +557 -0
  65. package/src/utils/tooltip.mjs +243 -0
  66. package/src/utils/wss.js +26 -6
  67. package/src/utils/wss.mjs +29 -2
  68. /package/src/{widgets → components}/AhrefSetting.vue +0 -0
  69. /package/src/{widgets → components}/ArticleSetting.vue +0 -0
  70. /package/src/{widgets → components}/BackgroundColorSetting.vue +0 -0
  71. /package/src/{widgets → components}/BlockSetting.vue +0 -0
  72. /package/src/{widgets → components}/BorderColorSetting.vue +0 -0
  73. /package/src/{widgets → components}/ButtonSetting.vue +0 -0
  74. /package/src/{widgets → components}/CarouselSetting.vue +0 -0
  75. /package/src/{widgets → components}/ColumnSelector.vue +0 -0
  76. /package/src/{widgets → components}/ComponentSetting.vue +0 -0
  77. /package/src/{widgets → components}/ContactFormSetting.vue +0 -0
  78. /package/src/{widgets → components}/CountdownSetting.vue +0 -0
  79. /package/src/{widgets → components}/DataListSetting.vue +0 -0
  80. /package/src/{widgets → components}/EmbeddedVideo.vue +0 -0
  81. /package/src/{widgets → components}/EmbeddedVideoSetting.vue +0 -0
  82. /package/src/{widgets → components}/FAQ.vue +0 -0
  83. /package/src/{widgets → components}/FAQSetting.vue +0 -0
  84. /package/src/{widgets → components}/FeatureListSetting.vue +0 -0
  85. /package/src/{widgets → components}/FlexSetting.vue +0 -0
  86. /package/src/{widgets → components}/GridSetting.vue +0 -0
  87. /package/src/{widgets → components}/Header2.vue +0 -0
  88. /package/src/{widgets → components}/Header2Setting.vue +0 -0
  89. /package/src/{widgets → components}/IconListSetting.vue +0 -0
  90. /package/src/{widgets → components}/ImageSetting.vue +0 -0
  91. /package/src/{widgets → components}/LLMChat.vue +0 -0
  92. /package/src/{widgets → components}/LinkSetting.vue +0 -0
  93. /package/src/{widgets → components}/LinkSettingModal.vue +0 -0
  94. /package/src/{widgets → components}/ModalSetting.vue +0 -0
  95. /package/src/{widgets → components}/OGSettingModal.vue +0 -0
  96. /package/src/{widgets → components}/ParagraphSetting.vue +0 -0
  97. /package/src/{widgets → components}/PositionSetting.vue +0 -0
  98. /package/src/{widgets → components}/PresetBarPivot.vue +0 -0
  99. /package/src/{widgets → components}/PresetBarPivotColumnEdit.vue +0 -0
  100. /package/src/{widgets → components}/PresetBarPivotValueEdit.vue +0 -0
  101. /package/src/{widgets → components}/PresetSelector.vue +0 -0
  102. /package/src/{widgets → components}/Review.vue +0 -0
  103. /package/src/{widgets → components}/ReviewSetting.vue +0 -0
  104. /package/src/{widgets → components}/SearchModalSetting.vue +0 -0
  105. /package/src/{widgets → components}/ShareSetting.vue +0 -0
  106. /package/src/{widgets → components}/SvgSetting.vue +0 -0
  107. /package/src/{widgets → components}/TableSetting.vue +0 -0
  108. /package/src/{widgets → components}/TestimonialSetting.vue +0 -0
  109. /package/src/{widgets → components}/TextBlockSetting.vue +0 -0
  110. /package/src/{widgets → components}/ThumbnailSetting.vue +0 -0
  111. /package/src/{widgets → components}/WebComponentSelector.vue +0 -0
  112. /package/src/{widgets → components}/WebPageBuilder.vue +0 -0
  113. /package/src/{widgets → components}/YoutubeVideoSetting.vue +0 -0
package/package.json CHANGED
@@ -1,18 +1,13 @@
1
1
  {
2
2
  "name": "@mixd-id/web-scaffold",
3
3
  "private": false,
4
- "version": "0.2.250801014",
5
- "scripts": {
6
- "dev": "vite serve",
7
- "build": "vite build",
8
- "start": "node server.js"
9
- },
4
+ "version": "0.2.260801002",
10
5
  "main": "./src/index.js",
11
6
  "exports": {
12
7
  ".": "./src/index.js",
13
8
  "./themes/default": "./src/themes/default/index.js",
14
9
  "./components/*": "./src/components/*",
15
- "./widgets/*": "./src/widgets/*",
10
+ "./widgets/*": "./src/components/*",
16
11
  "./mixin/component": "./src/mixin/component.js",
17
12
  "./helpers": {
18
13
  "require": "./src/utils/helpers.js",
@@ -28,6 +23,7 @@
28
23
  "./wss.mjs": "./src/utils/wss.mjs",
29
24
  "./dashboard": "./src/utils/dashboard.js",
30
25
  "./ai-dashboard": "./src/utils/ai-dashboard.js",
26
+ "./ai-dashboard-api": "./src/utils/ai-dashboard-api.js",
31
27
  "./ai-dashboard-chat": "./src/utils/ai-dashboard-chat.mjs",
32
28
  "./ai-chat": "./src/utils/ai-chat.js",
33
29
  "./ai-chat-answer": "./src/utils/ai-chat-answer.mjs",
@@ -36,86 +32,46 @@
36
32
  "import": "./src/utils/preset-selector.mjs"
37
33
  },
38
34
  "./hooks/*": "./src/hooks/*",
35
+ "./directives/*": "./src/directives/*",
36
+ "./tooltip": {
37
+ "import": "./src/utils/tooltip.mjs"
38
+ },
39
+ "./tooltip.mjs": "./src/utils/tooltip.mjs",
39
40
  "./preset-selector.mjs": "./src/utils/preset-selector.mjs",
40
41
  "./list": {
41
42
  "import": "./src/utils/list.mjs"
42
43
  },
43
44
  "./list.mjs": "./src/utils/list.mjs",
44
- "./docs/components/AccountIcon": "./docs/components/AccountIcon.md",
45
- "./docs/components/Ahref": "./docs/components/Ahref.md",
46
- "./docs/components/Alert": "./docs/components/Alert.md",
47
- "./docs/components/Article": "./docs/components/Article.md",
48
- "./docs/components/Block": "./docs/components/Block.md",
49
- "./docs/components/Button": "./docs/components/Button.md",
50
- "./docs/components/Card": "./docs/components/Card.md",
51
- "./docs/components/Carousel": "./docs/components/Carousel.md",
52
- "./docs/components/Cart": "./docs/components/Cart.md",
53
- "./docs/components/CartIcon": "./docs/components/CartIcon.md",
54
- "./docs/components/Chart": "./docs/components/Chart.md",
55
- "./docs/components/ChatTyping": "./docs/components/ChatTyping.md",
56
- "./docs/components/Checkbox": "./docs/components/Checkbox.md",
57
- "./docs/components/CodeEditor": "./docs/components/CodeEditor.md",
58
- "./docs/components/CollapsiblePanel": "./docs/components/CollapsiblePanel.md",
59
- "./docs/components/ColorPicker": "./docs/components/ColorPicker.md",
60
- "./docs/components/Confirm": "./docs/components/Confirm.md",
61
- "./docs/components/ContextMenu": "./docs/components/ContextMenu.md",
62
- "./docs/components/ContextMenuItem": "./docs/components/ContextMenuItem.md",
63
- "./docs/components/CopyToClipboard": "./docs/components/CopyToClipboard.md",
64
- "./docs/components/Countdown": "./docs/components/Countdown.md",
65
- "./docs/components/Datepicker": "./docs/components/Datepicker.md",
66
- "./docs/components/Datetimepicker": "./docs/components/Datetimepicker.md",
67
- "./docs/components/Dropdown": "./docs/components/Dropdown.md",
68
- "./docs/components/Flex": "./docs/components/Flex.md",
69
- "./docs/components/Gmaps": "./docs/components/Gmaps.md",
70
- "./docs/components/Grid": "./docs/components/Grid.md",
71
- "./docs/components/HTMLEditor": "./docs/components/HTMLEditor.md",
72
- "./docs/components/Image": "./docs/components/Image.md",
73
- "./docs/components/Link": "./docs/components/Link.md",
74
- "./docs/components/MultiDropdown": "./docs/components/MultiDropdown.md",
75
- "./docs/components/Radio": "./docs/components/Radio.md",
76
- "./docs/components/Slider": "./docs/components/Slider.md",
77
- "./docs/components/Switch": "./docs/components/Switch.md",
78
- "./docs/components/Table": "./docs/components/Table.md",
79
- "./docs/components/Tabs": "./docs/components/Tabs.md",
80
- "./docs/components/Text": "./docs/components/Text.md",
81
- "./docs/components/Textarea": "./docs/components/Textarea.md",
82
- "./docs/components/Textbox": "./docs/components/Textbox.md",
83
- "./docs/components/Timepicker": "./docs/components/Timepicker.md",
84
- "./docs/components/Toast": "./docs/components/Toast.md",
85
- "./docs/components/TreeView": "./docs/components/TreeView.md",
86
- "./docs/components/Uploader": "./docs/components/Uploader.md",
87
- "./docs/components/Video": "./docs/components/Video.md"
45
+ "./pivot-list": {
46
+ "import": "./src/utils/pivot-list.mjs"
47
+ },
48
+ "./pivot-list.mjs": "./src/utils/pivot-list.mjs",
49
+ "./pivot-formula": {
50
+ "import": "./src/utils/pivot-formula.mjs"
51
+ },
52
+ "./pivot-formula.mjs": "./src/utils/pivot-formula.mjs",
53
+ "./docs/components/*": "./docs/components/*.md"
88
54
  },
89
55
  "dependencies": {
90
56
  "@googlemaps/js-api-loader": "^1.16.8",
91
57
  "@tailwindcss/line-clamp": "^0.4.0",
92
58
  "@vueuse/core": "^9.0.2",
93
- "adm-zip": "^0.5.10",
94
59
  "axios": "1.8.2",
95
60
  "bcrypt": "^5.1.1",
96
61
  "chart.js": "^4.2.1",
97
- "compression": "^1.7.4",
98
62
  "crypto-js": "^4.2.0",
99
63
  "dayjs": "^1.11.2",
100
64
  "eventemitter2": "^6.4.7",
101
- "exceljs": "^4.3.0",
102
- "file-type": "^18.2.1",
103
- "glob": "^8.0.3",
104
65
  "highlight.js": "^11.11.1",
105
66
  "lodash": "^4.17.21",
106
- "lodash-es": "^4.17.21",
107
67
  "marked": "^16.4.1",
108
68
  "md5": "^2.3.0",
109
- "mitt": "^3.0.1",
110
- "pinia": "^2.0.2",
111
69
  "prismjs": "1.30.0",
112
70
  "redis": "^4.6.13",
113
71
  "sequelize": "^6.37.7",
114
72
  "tailwindcss": "^3.2.4",
115
- "tinycolor2": "^1.6.0",
116
73
  "vue": "^3.2.25",
117
74
  "vue-chartjs": "^5.2.0",
118
- "vue-router": "^4.0.14",
119
75
  "ws": "^8.16.0"
120
76
  },
121
77
  "description": "This scaffold based on express vitejs vuejs",
@@ -236,11 +236,20 @@ You can use *italics*, **bold**, \`inline code\`, and [links](https://example.co
236
236
  2. Ordered two
237
237
 
238
238
  ### Table
239
- | Feature | Supported |
240
- | ------- | --------- |
241
- | Tables | ✅ |
242
- | Code | ✅ |
243
- | Images | ✅ |
239
+ Long tables collapse behind a "show more" toggle after the first few rows:
240
+
241
+ | Feature | Supported |
242
+ | ---------- | --------- |
243
+ | Tables | ✅ |
244
+ | Code | ✅ |
245
+ | Images | ✅ |
246
+ | Files | ✅ |
247
+ | Markdown | ✅ |
248
+ | Streaming | ✅ |
249
+ | Blockquote | ✅ |
250
+ | Lists | ✅ |
251
+ | Links | ✅ |
252
+ | Headings | ✅ |
244
253
 
245
254
  > Blockquotes work too — handy for callouts.`,
246
255
  },
@@ -36,11 +36,12 @@
36
36
 
37
37
  <!-- File attachments -->
38
38
  <div v-if="files.length" class="mt-3 flex flex-wrap gap-2">
39
- <a v-for="(url, n) in files" :key="n" :href="url" target="_blank" rel="noopener"
40
- class="flex items-center gap-2 p-2 pr-3 rounded-xl bg-base-300 hover:bg-base-500 transition-colors no-underline max-w-[260px]">
39
+ <button v-for="(url, n) in files" :key="n" type="button" @click="$download(url, fileName(url))"
40
+ class="flex items-center gap-2 p-2 pr-3 rounded-xl bg-base-300 hover:bg-base-500 transition-colors max-w-[260px]">
41
41
  <span class="shrink-0 px-2 py-1 rounded-lg text-[10px] font-bold uppercase text-white" :style="{ background: fileColor(url) }">{{ fileExt(url) }}</span>
42
42
  <span class="text-sm text-text truncate">{{ fileName(url) }}</span>
43
- </a>
43
+ <svg width="12" height="12" class="shrink-0 fill-text-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32V274.7l-73.4-73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l128 128c12.5 12.5 32.8 12.5 45.3 0l128-128c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L288 274.7V32zM64 352c-35.3 0-64 28.7-64 64v32c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V416c0-35.3-28.7-64-64-64H345.5l-45.3 45.3c-25 25-65.5 25-90.5 0L164.5 352H64zm368 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/></svg>
44
+ </button>
44
45
  </div>
45
46
 
46
47
  <!-- Footer meta -->
@@ -64,6 +65,9 @@ const { message } = defineProps({
64
65
  message: { type: Object, required: true },
65
66
  })
66
67
 
68
+ // Tables past this many data rows get collapsed behind a "Show N more rows" toggle.
69
+ const MAX_TABLE_ROWS = 8
70
+
67
71
  // Local Marked instance — avoids mutating global marked state on each mount.
68
72
  const markdown = new Marked({
69
73
  gfm: true,
@@ -74,6 +78,23 @@ const markdown = new Marked({
74
78
  const html = hljs.highlight(text, { language }).value
75
79
  return `<pre><code class="hljs language-${language}">${html}</code></pre>`
76
80
  },
81
+ table(token) {
82
+ const cellStyle = (align) => align ? ` style="text-align:${align}"` : ''
83
+ const cell = (tag, data, i) => `<${tag}${cellStyle(token.align[i])}>${this.parser.parseInline(data.tokens)}</${tag}>`
84
+ const row = (data) => `<tr>${data.map((d, i) => cell('td', d, i)).join('')}</tr>`
85
+
86
+ const head = `<tr>${token.header.map((d, i) => cell('th', d, i)).join('')}</tr>`
87
+ const visible = token.rows.slice(0, MAX_TABLE_ROWS)
88
+ const hidden = token.rows.slice(MAX_TABLE_ROWS)
89
+
90
+ let body = visible.map(row).join('')
91
+ if(hidden.length) {
92
+ body += `<tr class="table-more-row"><td colspan="${token.header.length}">`
93
+ + `<details><summary>Show ${hidden.length} more row${hidden.length > 1 ? 's' : ''}</summary>`
94
+ + `<table><tbody>${hidden.map(row).join('')}</tbody></table></details></td></tr>`
95
+ }
96
+ return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`
97
+ },
77
98
  },
78
99
  })
79
100
 
@@ -134,6 +155,16 @@ function fileColor(url) {
134
155
  .md :global(table) { border-collapse: collapse !important; width: 100% !important; margin: 0.8em 0 !important; display: block; overflow-x: auto; }
135
156
  .md :global(th), .md :global(td) { border: 1px solid rgba(128,128,128,0.35) !important; padding: 0.4em 0.7em !important; text-align: left !important; }
136
157
  .md :global(th) { font-weight: 600 !important; background: rgba(128,128,128,0.1) !important; }
158
+ .md :global(.table-more-row td) { border: none !important; padding: 0 !important; }
159
+ .md :global(.table-more-row summary) {
160
+ cursor: pointer !important;
161
+ padding: 0.4em 0.7em !important;
162
+ font-size: 0.85em !important;
163
+ color: rgba(128,128,128,0.9) !important;
164
+ list-style: none;
165
+ }
166
+ .md :global(.table-more-row summary::-webkit-details-marker) { display: none; }
167
+ .md :global(.table-more-row details table) { margin: 0 !important; }
137
168
  .md :global(blockquote) { border-left: 3px solid rgba(128,128,128,0.4) !important; padding-left: 1em !important; margin: 0.6em 0 !important; opacity: 0.8; }
138
169
  .md :global(hr) { border-color: rgba(128,128,128,0.3) !important; margin: 1em 0 !important; }
139
170
  .md :global(:not(pre) > code) {
@@ -161,89 +161,119 @@
161
161
  </template>
162
162
  </template>
163
163
 
164
- <!-- Metric static data (offline / manual) -->
165
- <template v-else-if="selectedNode.type === 'metric' && !hasDatasources">
166
- <label class="text-xs text-text-400">Label</label>
167
- <Textbox v-model="metricData(selectedNode).label" placeholder="Metric label" />
168
- <label class="text-xs text-text-400">Value</label>
169
- <Textbox v-model="metricData(selectedNode).value" type="tel" item-class="text-right" />
170
- <label class="text-xs text-text-400">Delta % (optional)</label>
171
- <Textbox v-model="metricData(selectedNode).delta" type="tel" item-class="text-right" />
172
- </template>
164
+ <!-- Leaf: authored payload (`data`) — the caption every widget card
165
+ shows, plus a metric's hand-written value / delta. Kept editable
166
+ even when a query drives the numbers: `load-dashboard` overwrites
167
+ value/series/rows, never label or delta. -->
168
+ <template v-else>
169
+ <label class="text-xs text-text-400">Caption</label>
170
+ <Textbox v-model="nodeData(selectedNode).label" :placeholder="selectedNode.name || labelFor(selectedNode.type)" @change="persist" />
171
+
172
+ <template v-if="selectedNode.type === 'metric'">
173
+ <template v-if="!selectedNode.query?.datasource">
174
+ <label class="text-xs text-text-400">Value</label>
175
+ <Textbox v-model="nodeData(selectedNode).value" type="tel" item-class="text-right" @change="persist" />
176
+ </template>
177
+ <label class="text-xs text-text-400">Delta % (optional)</label>
178
+ <Textbox v-model="nodeData(selectedNode).delta" type="tel" item-class="text-right" @change="persist" />
179
+ </template>
173
180
 
174
- <!-- Query builder (data-source backed) -->
175
- <template v-else-if="hasDatasources">
176
- <label class="text-xs text-text-400">Data source</label>
177
- <Dropdown :model-value="query(selectedNode).datasource || ''"
178
- @update:model-value="setDatasource(selectedNode, $event)">
179
- <option value="">— none —</option>
180
- <option v-for="ds in dsList" :key="ds.key" :value="ds.key">{{ ds.label }}</option>
181
- </Dropdown>
181
+ <!-- Query builder (data-source backed) -->
182
+ <template v-if="hasDatasources">
183
+ <label class="text-xs text-text-400 pt-1">Data source</label>
184
+ <Dropdown :model-value="query(selectedNode).datasource || ''"
185
+ @update:model-value="setDatasource(selectedNode, $event)">
186
+ <option value="">— none —</option>
187
+ <option v-for="ds in dsList" :key="ds.key" :value="ds.key">{{ ds.label }}</option>
188
+ </Dropdown>
182
189
 
183
- <template v-if="query(selectedNode).datasource">
184
- <!-- metric query -->
185
- <template v-if="selectedNode.type === 'metric'">
186
- <label class="text-xs text-text-400">Aggregation</label>
187
- <Dropdown v-model="query(selectedNode).aggregation" @change="onQueryEdit">
188
- <option v-for="a in aggOptions(selectedNode)" :key="a" :value="a">{{ aggLabel(a) }}</option>
189
- </Dropdown>
190
- <template v-if="query(selectedNode).aggregation && query(selectedNode).aggregation !== 'count'">
191
- <label class="text-xs text-text-400">Field</label>
192
- <Dropdown v-model="query(selectedNode).field" @change="onQueryEdit">
193
- <option value="">— select —</option>
194
- <option v-for="f in aggFields(selectedNode)" :key="f.key" :value="f.key">{{ f.label }}</option>
190
+ <template v-if="query(selectedNode).datasource">
191
+ <!-- metric + chart: what the number / each bar measures -->
192
+ <template v-if="selectedNode.type !== 'table'">
193
+ <label class="text-xs text-text-400">Aggregation</label>
194
+ <Dropdown v-model="query(selectedNode).aggregation" @change="onQueryEdit">
195
+ <option v-for="a in aggOptions(selectedNode)" :key="a" :value="a">{{ aggLabel(a) }}</option>
195
196
  </Dropdown>
197
+ <template v-if="query(selectedNode).aggregation && query(selectedNode).aggregation !== 'count'">
198
+ <label class="text-xs text-text-400">Field</label>
199
+ <Dropdown v-model="query(selectedNode).field" @change="onQueryEdit">
200
+ <option value="">— select —</option>
201
+ <option v-for="f in aggFields(selectedNode)" :key="f.key" :value="f.key">{{ f.label }}</option>
202
+ </Dropdown>
203
+ </template>
196
204
  </template>
197
- </template>
198
205
 
199
- <!-- chart query -->
200
- <template v-else-if="selectedNode.type === 'chart'">
201
- <label class="text-xs text-text-400">Group by</label>
202
- <Dropdown v-model="query(selectedNode).groupBy" @change="onQueryEdit">
203
- <option value="">— select —</option>
204
- <option v-for="f in fieldsOf(selectedNode)" :key="f.key" :value="f.key">{{ f.label }}</option>
205
- </Dropdown>
206
- <label class="text-xs text-text-400">Bucket</label>
207
- <Dropdown v-model="query(selectedNode).dateModifier" @change="onQueryEdit">
208
- <option value="">Exact value</option>
209
- <option value="day">By day</option>
210
- <option value="week">By week</option>
211
- <option value="month">By month</option>
212
- <option value="year">By year</option>
213
- </Dropdown>
214
- <label class="text-xs text-text-400">Limit</label>
215
- <Textbox v-model.number="query(selectedNode).limit" type="tel" item-class="text-right" @change="onQueryEdit" />
216
- </template>
217
-
218
- <!-- table query -->
219
- <template v-else-if="selectedNode.type === 'table'">
220
- <label class="text-xs text-text-400">Page size</label>
221
- <Textbox v-model.number="query(selectedNode).pageSize" type="tel" item-class="text-right" @change="onQueryEdit" />
222
- <label class="text-xs text-text-400">Order by</label>
223
- <Dropdown v-model="tableOrderField" @change="onQueryEdit">
224
- <option value="">— default —</option>
225
- <option v-for="f in fieldsOf(selectedNode)" :key="f.key" :value="f.key">{{ f.label }}</option>
226
- </Dropdown>
227
- </template>
228
-
229
- <!-- Filters (applied to every data-backed widget) -->
230
- <label class="text-xs text-text-400 pt-1">Filters</label>
231
- <div class="flex flex-col gap-2">
232
- <div v-for="(f, fi) in filtersOf(selectedNode)" :key="fi" class="flex flex-row items-center gap-1">
233
- <Dropdown v-model="f.field" class="flex-1" @change="onFilterField(selectedNode, f)">
234
- <option value="">field</option>
235
- <option v-for="fl in fieldsOf(selectedNode)" :key="fl.key" :value="fl.key">{{ fl.label }}</option>
206
+ <!-- chart: the buckets -->
207
+ <template v-if="selectedNode.type === 'chart'">
208
+ <label class="text-xs text-text-400">Group by</label>
209
+ <Dropdown v-model="query(selectedNode).groupBy" @change="onQueryEdit">
210
+ <option value="">— select —</option>
211
+ <option v-for="f in fieldsOf(selectedNode)" :key="f.key" :value="f.key">{{ f.label }}</option>
236
212
  </Dropdown>
237
- <Dropdown v-model="f.op" class="w-[64px]" @change="onQueryEdit">
238
- <option v-for="o in opsFor(fieldType(selectedNode, f.field))" :key="o" :value="o">{{ opLabel(o) }}</option>
213
+ <label class="text-xs text-text-400">Bucket</label>
214
+ <Dropdown v-model="query(selectedNode).dateModifier" @change="onQueryEdit">
215
+ <option value="">Exact value</option>
216
+ <option value="day">By day</option>
217
+ <option value="week">By week</option>
218
+ <option value="month">By month</option>
219
+ <option value="year">By year</option>
239
220
  </Dropdown>
240
- <Textbox v-model="f.value" class="flex-1" placeholder="value" @change="onQueryEdit" />
241
- <button type="button" class="px-1 text-text-300 hover:text-red-600" title="Remove filter" @click="removeFilter(selectedNode, fi)">
242
- <svg width="11" height="11" class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>
243
- </button>
221
+ <label class="text-xs text-text-400">Limit</label>
222
+ <Textbox v-model.number="query(selectedNode).limit" type="tel" item-class="text-right" @change="onQueryEdit" />
223
+ </template>
224
+
225
+ <!-- table: columns, sorts, page size -->
226
+ <template v-else-if="selectedNode.type === 'table'">
227
+ <label class="text-xs text-text-400">Columns</label>
228
+ <div class="flex flex-col gap-1">
229
+ <label v-for="f in fieldsOf(selectedNode)" :key="f.key" class="flex flex-row items-center gap-2 text-xs cursor-pointer">
230
+ <input type="checkbox" :checked="hasColumn(selectedNode, f.key)" @change="toggleColumn(selectedNode, f)" />
231
+ <span class="flex-1 truncate text-text-300">{{ f.label }}</span>
232
+ <span class="text-[10px] uppercase tracking-wide text-text-400">{{ f.type }}</span>
233
+ </label>
234
+ </div>
235
+
236
+ <label class="text-xs text-text-400 pt-1">Order by</label>
237
+ <div class="flex flex-col gap-2">
238
+ <div v-for="(o, oi) in orderOf(selectedNode)" :key="oi" class="flex flex-row items-center gap-1">
239
+ <Dropdown v-model="o[0]" class="flex-1" @change="onQueryEdit">
240
+ <option value="">field</option>
241
+ <option v-for="f in fieldsOf(selectedNode)" :key="f.key" :value="f.key">{{ f.label }}</option>
242
+ </Dropdown>
243
+ <Dropdown v-model="o[1]" class="w-[86px]" @change="onQueryEdit">
244
+ <option value="asc">Asc</option>
245
+ <option value="desc">Desc</option>
246
+ </Dropdown>
247
+ <button type="button" class="px-1 text-text-300 hover:text-red-600" title="Remove sort" @click="removeOrder(selectedNode, oi)">
248
+ <svg width="11" height="11" class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>
249
+ </button>
250
+ </div>
251
+ <button type="button" class="text-primary text-xs text-left hover:underline" @click="addOrder(selectedNode)">+ Add sort</button>
252
+ </div>
253
+
254
+ <label class="text-xs text-text-400 pt-1">Page size</label>
255
+ <Textbox v-model.number="query(selectedNode).pageSize" type="tel" item-class="text-right" @change="onQueryEdit" />
256
+ </template>
257
+
258
+ <!-- Filters (applied to every data-backed widget) -->
259
+ <label class="text-xs text-text-400 pt-1">Filters</label>
260
+ <div class="flex flex-col gap-2">
261
+ <div v-for="(f, fi) in filtersOf(selectedNode)" :key="fi" class="flex flex-row items-center gap-1">
262
+ <Dropdown v-model="f.field" class="flex-1" @change="onFilterField(selectedNode, f)">
263
+ <option value="">field</option>
264
+ <option v-for="fl in fieldsOf(selectedNode)" :key="fl.key" :value="fl.key">{{ fl.label }}</option>
265
+ </Dropdown>
266
+ <Dropdown v-model="f.op" class="w-[64px]" @change="onQueryEdit">
267
+ <option v-for="o in opsFor(fieldType(selectedNode, f.field))" :key="o" :value="o">{{ opLabel(o) }}</option>
268
+ </Dropdown>
269
+ <Textbox v-model="f.value" class="flex-1" placeholder="value" @change="onQueryEdit" />
270
+ <button type="button" class="px-1 text-text-300 hover:text-red-600" title="Remove filter" @click="removeFilter(selectedNode, fi)">
271
+ <svg width="11" height="11" class="fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>
272
+ </button>
273
+ </div>
274
+ <button type="button" class="text-primary text-xs text-left hover:underline" @click="addFilter(selectedNode)">+ Add filter</button>
244
275
  </div>
245
- <button type="button" class="text-primary text-xs text-left hover:underline" @click="addFilter(selectedNode)">+ Add filter</button>
246
- </div>
276
+ </template>
247
277
  </template>
248
278
  </template>
249
279
 
@@ -359,7 +389,7 @@
359
389
  <script setup>
360
390
  import { computed, defineComponent, h, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
361
391
  import { Bar } from 'vue-chartjs'
362
- import LLMChat from '../widgets/LLMChat.vue'
392
+ import LLMChat from './LLMChat.vue'
363
393
  import VirtualTable from './VirtualTable.vue'
364
394
  import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from 'chart.js'
365
395
  ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend)
@@ -382,7 +412,7 @@ const { controller, socket, config, samples, datasources, autoSave, serverChat }
382
412
  socket: { type: Object, default: null },
383
413
  config: { type: Object, required: true },
384
414
  samples: { type: Object, default: () => ({ structure: [], widgets: {} }) },
385
- datasources: { type: Object, default: () => ({}) },
415
+ datasources: { type: [ Object, Array ], default: () => ({}) },
386
416
  // Persist edits through `${controller}.save`. Turn off when a host page owns
387
417
  // persistence (e.g. via loadConfig/saveConfig) to avoid a double write.
388
418
  autoSave: { type: Boolean, default: true },
@@ -412,8 +442,9 @@ function normalizeDs(list) {
412
442
  }))
413
443
  }
414
444
 
415
- const dsList = computed(() => serverDatasources.value
416
- ?? normalizeDs(Object.entries(datasources).map(([key, v]) => ({ key, ...v }))))
445
+ const dsList = computed(() => serverDatasources.value ?? normalizeDs(Array.isArray(datasources)
446
+ ? datasources
447
+ : Object.entries(datasources).map(([key, v]) => ({ key, ...v }))))
417
448
  const hasDatasources = computed(() => dsList.value.length > 0)
418
449
 
419
450
  async function loadDatasources() {
@@ -677,7 +708,7 @@ function groupStyle(node) {
677
708
  }
678
709
 
679
710
  /* ---------- Inspector data accessors ---------- */
680
- function metricData(node) { if (!node.data) node.data = {}; return node.data }
711
+ function nodeData(node) { if (!node.data) node.data = {}; return node.data }
681
712
  function query(node) { if (!node.query) node.query = {}; return node.query }
682
713
 
683
714
  function currentDs(node) { return dsList.value.find(d => d.key === query(node).datasource) }
@@ -690,11 +721,12 @@ function setDatasource(node, ds) {
690
721
  q.field = ''
691
722
  if (!ds) return
692
723
  // seed sensible query defaults per node type
693
- if (node.type === 'metric' && !q.aggregation) q.aggregation = 'count'
724
+ if (node.type !== 'table' && !q.aggregation) q.aggregation = 'count'
694
725
  if (node.type === 'table') {
695
726
  if (q.pageSize == null) q.pageSize = 10
696
727
  // default columns = all datasource fields
697
728
  q.columns = fieldsOf(node).map(f => ({ key: f.key, label: f.label }))
729
+ q.order = []
698
730
  }
699
731
  onQueryEdit()
700
732
  }
@@ -741,6 +773,7 @@ function newMetricFrom(ds) {
741
773
  if (!activePreset.value) return
742
774
  const node = blankNode('metric')
743
775
  node.name = ds.label
776
+ node.data = { label: ds.label, value: 0 }
744
777
  node.query = { datasource: ds.key, aggregation: 'count', filters: [] }
745
778
  const target = (selectedNode.value && selectedNode.value.type === 'group') ? selectedNode.value.items : structure.value
746
779
  target.push(node)
@@ -749,18 +782,22 @@ function newMetricFrom(ds) {
749
782
  onQueryEdit()
750
783
  }
751
784
 
752
- // Order for table query is stored as [[field, 'desc']]; expose the field only.
753
- const tableOrderField = computed({
754
- get() {
755
- const q = selectedNode.value ? query(selectedNode.value) : {}
756
- return Array.isArray(q.order) && q.order[0] ? q.order[0][0] : ''
757
- },
758
- set(field) {
759
- if (!selectedNode.value) return
760
- const q = query(selectedNode.value)
761
- q.order = field ? [[field, 'desc']] : undefined
762
- },
763
- })
785
+ /* ---- Table columns + order (query.columns, query.order) ---- */
786
+ function columnsOf(node) { const q = query(node); if (!Array.isArray(q.columns)) q.columns = []; return q.columns }
787
+ function hasColumn(node, key) { return columnsOf(node).some(c => (c.key ?? c) === key) }
788
+
789
+ function toggleColumn(node, field) {
790
+ const cols = columnsOf(node)
791
+ const i = cols.findIndex(c => (c.key ?? c) === field.key)
792
+ if (i >= 0) cols.splice(i, 1)
793
+ else cols.push({ key: field.key, label: field.label })
794
+ onQueryEdit()
795
+ }
796
+
797
+ // Order is stored the way Sequelize takes it: [[field, 'desc'], ...].
798
+ function orderOf(node) { const q = query(node); if (!Array.isArray(q.order)) q.order = []; return q.order }
799
+ function addOrder(node) { orderOf(node).push([ fieldsOf(node)[0]?.key ?? '', 'desc' ]); onQueryEdit() }
800
+ function removeOrder(node, i) { orderOf(node).splice(i, 1); onQueryEdit() }
764
801
 
765
802
  /* ---------- Reset from samples ---------- */
766
803
  function resetSamples() {
@@ -774,11 +811,17 @@ function resetSamples() {
774
811
  /* ---------- Server: hydrate + persist ---------- */
775
812
  const hydrating = ref(false)
776
813
 
777
- // Strip ephemeral `data` from query-backed nodes; keep static data otherwise.
814
+ // A query-backed node's value/series/rows are recomputed by the server, so they
815
+ // are not persisted; its authored `label` and `delta` are, since nothing else
816
+ // carries them (docs/dashboard.json metrics keep a delta beside their query).
778
817
  function cleanNode(node) {
779
818
  const { data, ...rest } = node
780
819
  const out = { ...rest }
781
- if (!node.query) out.data = data
820
+ if (node.query?.datasource) {
821
+ const { label, delta } = data ?? {}
822
+ if (label != null || delta != null) out.data = { label, delta }
823
+ }
824
+ else out.data = data
782
825
  if (Array.isArray(node.items)) out.items = node.items.map(cleanNode)
783
826
  return out
784
827
  }
@@ -958,9 +1001,10 @@ const Widget = defineComponent({
958
1001
  setup(props, { emit }) {
959
1002
  const data = computed(() => props.node.data || {})
960
1003
 
1004
+ // The card header already shows the caption (`data.label ?? node.name`),
1005
+ // so the metric body is the number and its delta.
961
1006
  function renderMetric() {
962
1007
  return h('div', { class: 'flex flex-col gap-1' }, [
963
- h('div', { class: 'text-text-300 text-sm' }, data.value.label || props.node.name),
964
1008
  h('div', { class: 'text-4xl font-bold' }, formatNum(data.value.value)),
965
1009
  data.value.delta != null && data.value.delta !== ''
966
1010
  ? h('div', { class: Number(data.value.delta) >= 0 ? 'text-green-600 text-sm' : 'text-red-600 text-sm' },
@@ -1069,7 +1113,7 @@ const Widget = defineComponent({
1069
1113
 
1070
1114
  return () => h('div', { class: 'group bg-base-300 rounded-xl p-5 flex flex-col gap-3 h-full' }, [
1071
1115
  h('div', { class: 'flex flex-row items-center gap-2' }, [
1072
- h('div', { class: 'flex-1 text-sm text-text-300 truncate' }, props.node.name),
1116
+ h('div', { class: 'flex-1 text-sm text-text-300 truncate' }, data.value.label || props.node.name),
1073
1117
  h('button', {
1074
1118
  type: 'button',
1075
1119
  class: 'opacity-0 group-hover:opacity-60 hover:!opacity-100',
@@ -1081,7 +1125,9 @@ const Widget = defineComponent({
1081
1125
  ]),
1082
1126
  ]),
1083
1127
  ]),
1084
- props.node.type === 'metric' ? renderMetric()
1128
+ data.value.error
1129
+ ? h('div', { class: 'text-red-600 text-sm' }, data.value.error)
1130
+ : props.node.type === 'metric' ? renderMetric()
1085
1131
  : props.node.type === 'chart' ? renderChart()
1086
1132
  : props.node.type === 'table' ? renderTable()
1087
1133
  : h('div', { class: 'text-text-400 text-sm' }, 'Empty group — add child widgets.'),