@mixd-id/web-scaffold 0.1.240411125 → 0.1.240411126
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/components/AIDashboardChart.vue +6 -2
- package/src/components/AIDashboardChartSetting.vue +50 -18
- package/src/components/AIDashboardFilterSetting.vue +64 -12
- package/src/components/AIDashboardMetricSetting.vue +32 -6
- package/src/utils/ai-dashboard-chat.mjs +27 -8
- package/src/utils/ai-dashboard-query.js +465 -0
- package/src/utils/ai-dashboard.js +48 -12
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mixd-id/web-scaffold",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.240411126",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "vite serve",
|
|
7
7
|
"build": "vite build",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"./listpage1": "./src/utils/listpage1.js",
|
|
34
34
|
"./dashboard": "./src/utils/dashboard.js",
|
|
35
35
|
"./ai-dashboard": "./src/utils/ai-dashboard.js",
|
|
36
|
+
"./ai-dashboard-query": "./src/utils/ai-dashboard-query.js",
|
|
36
37
|
"./ai-dashboard-chat": "./src/utils/ai-dashboard-chat.mjs",
|
|
37
38
|
"./ai-dashboard-views": {
|
|
38
39
|
"import": "./src/utils/ai-dashboard-views.mjs"
|
|
@@ -165,9 +165,13 @@ export default {
|
|
|
165
165
|
|
|
166
166
|
const nested = (kind === 'pie' || kind === 'doughnut') && this.data.nested && typeof this.data.nested === 'object' ? this.data.nested : null
|
|
167
167
|
let rows = this.rows.map(r => ({ label: `${r.label}`, value: Number(r.value) || 0, other: !!r.other }))
|
|
168
|
+
// Past eight slices the rest is folded into Other, or left out with `groupOther: false`.
|
|
169
|
+
if(round && query.groupOther === false) rows = rows.filter(r => !r.other)
|
|
168
170
|
if(round && rows.length > 8){
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
+
const sorted = [ ...rows ].sort((a, b) => b.value - a.value)
|
|
172
|
+
const top = sorted.slice(0, query.groupOther === false ? 8 : 7)
|
|
173
|
+
rows = query.groupOther === false ? top
|
|
174
|
+
: [ ...top, { label: 'Other', value: rows.reduce((sum, r) => sum + r.value, 0) - top.reduce((sum, r) => sum + r.value, 0), other: true } ]
|
|
171
175
|
}
|
|
172
176
|
const stacks = round ? [] : this.stacks
|
|
173
177
|
const stacked = stacks.length > 0
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
<div class="flex flex-row gap-2">
|
|
25
25
|
<div class="flex-1 min-w-0">
|
|
26
26
|
<label class="text-xs text-text-400">Chart</label>
|
|
27
|
-
<Dropdown :model-value="node.chart || 'bar'" @update:model-value="
|
|
27
|
+
<Dropdown :model-value="node.chart || 'bar'" @update:model-value="setChart($event)">
|
|
28
28
|
<option value="bar">Bar</option>
|
|
29
29
|
<option value="line">Line</option>
|
|
30
30
|
<option value="pie">Pie</option>
|
|
@@ -135,24 +135,24 @@
|
|
|
135
135
|
</Checkbox>
|
|
136
136
|
</div>
|
|
137
137
|
|
|
138
|
-
<
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
</div>
|
|
144
|
-
|
|
145
|
-
<template v-if="!measurable && query.aggregation && query.aggregation !== 'count'">
|
|
146
|
-
<div>
|
|
147
|
-
<label class="text-xs text-text-400">Field</label>
|
|
148
|
-
<Dropdown v-model="query.field" @change="$emit('change')">
|
|
149
|
-
<option value="">— select —</option>
|
|
150
|
-
<option v-for="f in aggFields" :key="f.key" :value="f.key">{{ f.label }}</option>
|
|
138
|
+
<template v-if="node.chart === 'map'">
|
|
139
|
+
<div v-if="!measurable">
|
|
140
|
+
<label class="text-xs text-text-400">Aggregation</label>
|
|
141
|
+
<Dropdown v-model="query.aggregation" @change="$emit('change')">
|
|
142
|
+
<option v-for="a in aggregations" :key="a" :value="a">{{ aggLabel(a) }}</option>
|
|
151
143
|
</Dropdown>
|
|
152
144
|
</div>
|
|
153
|
-
</template>
|
|
154
145
|
|
|
155
|
-
|
|
146
|
+
<template v-if="!measurable && query.aggregation && query.aggregation !== 'count'">
|
|
147
|
+
<div>
|
|
148
|
+
<label class="text-xs text-text-400">Field</label>
|
|
149
|
+
<Dropdown v-model="query.field" @change="$emit('change')">
|
|
150
|
+
<option value="">— select —</option>
|
|
151
|
+
<option v-for="f in aggFields" :key="f.key" :value="f.key">{{ f.label }}</option>
|
|
152
|
+
</Dropdown>
|
|
153
|
+
</div>
|
|
154
|
+
</template>
|
|
155
|
+
|
|
156
156
|
<div>
|
|
157
157
|
<label class="text-xs text-text-400">Location from</label>
|
|
158
158
|
<Dropdown :model-value="'latField' in query ? 'coords' : 'name'" @update:model-value="setLocation">
|
|
@@ -197,6 +197,12 @@
|
|
|
197
197
|
<div class="flex-1 min-w-0">
|
|
198
198
|
<div class="flex flex-row items-center h-5">
|
|
199
199
|
<label class="flex-1 min-w-0 truncate text-xs text-text-400">{{ axisLabels?.group ?? 'Group by' }}</label>
|
|
200
|
+
<label v-if="[ 'pie', 'doughnut', 'polarArea' ].includes(node.chart)" class="flex flex-row items-center gap-1 text-xs text-text-300 cursor-pointer"
|
|
201
|
+
title="Past eight groups, fold the rest into one Other slice">
|
|
202
|
+
<input type="checkbox" class="w-3 h-3 accent-primary cursor-pointer" :checked="query.groupOther !== false"
|
|
203
|
+
@change="$event.target.checked ? delete query.groupOther : query.groupOther = false; $emit('change')" />
|
|
204
|
+
Other
|
|
205
|
+
</label>
|
|
200
206
|
</div>
|
|
201
207
|
<Dropdown v-model="query.groupBy" @change="groupType !== 'date' && (query.dateModifier = '', delete query.weekOfMonth); groupType !== 'number' && delete query.bucketSize; $emit('change')">
|
|
202
208
|
<option value="">— select —</option>
|
|
@@ -230,7 +236,7 @@
|
|
|
230
236
|
</Dropdown>
|
|
231
237
|
</div>
|
|
232
238
|
|
|
233
|
-
<div v-if="groupType === 'number'" class="
|
|
239
|
+
<div v-if="groupType === 'number' && ![ 'pie', 'doughnut', 'polarArea' ].includes(node.chart)" class="w-[90px]">
|
|
234
240
|
<div class="flex flex-row items-center h-5">
|
|
235
241
|
<label class="flex-1 min-w-0 truncate text-xs text-text-400">Range size</label>
|
|
236
242
|
<label v-if="query.bucketSize" class="flex flex-row items-center gap-1 text-xs text-text-300 cursor-pointer" title="Show empty buckets">
|
|
@@ -239,10 +245,27 @@
|
|
|
239
245
|
Empty
|
|
240
246
|
</label>
|
|
241
247
|
</div>
|
|
242
|
-
<Textbox v-model.number="query.bucketSize" type="tel" item-class="text-right" placeholder="
|
|
248
|
+
<Textbox v-model.number="query.bucketSize" type="tel" item-class="text-right" placeholder="" @change="$emit('change')" />
|
|
243
249
|
</div>
|
|
244
250
|
</div>
|
|
245
251
|
|
|
252
|
+
<div v-if="!measurable">
|
|
253
|
+
<label class="text-xs text-text-400">Aggregation</label>
|
|
254
|
+
<Dropdown v-model="query.aggregation" @change="$emit('change')">
|
|
255
|
+
<option v-for="a in aggregations" :key="a" :value="a">{{ aggLabel(a) }}</option>
|
|
256
|
+
</Dropdown>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<template v-if="!measurable && query.aggregation && query.aggregation !== 'count'">
|
|
260
|
+
<div>
|
|
261
|
+
<label class="text-xs text-text-400">Field</label>
|
|
262
|
+
<Dropdown v-model="query.field" @change="$emit('change')">
|
|
263
|
+
<option value="">— select —</option>
|
|
264
|
+
<option v-for="f in aggFields" :key="f.key" :value="f.key">{{ f.label }}</option>
|
|
265
|
+
</Dropdown>
|
|
266
|
+
</div>
|
|
267
|
+
</template>
|
|
268
|
+
|
|
246
269
|
<div v-if="!(query.extraFields ?? []).length" class="flex flex-row gap-2">
|
|
247
270
|
<div class="flex-1 min-w-0">
|
|
248
271
|
<div class="flex flex-row items-center h-5">
|
|
@@ -470,6 +493,15 @@ export default {
|
|
|
470
493
|
if (!allowed.includes(row.aggregation)) row.aggregation = allowed.includes('sum') ? 'sum' : allowed[0]
|
|
471
494
|
this.writeFields(rows)
|
|
472
495
|
},
|
|
496
|
+
// Round charts slice by exact value, so a number range size does not apply to them.
|
|
497
|
+
setChart(chart) {
|
|
498
|
+
this.node.chart = chart
|
|
499
|
+
if ([ 'pie', 'doughnut', 'polarArea' ].includes(chart) && this.query.bucketSize != null) {
|
|
500
|
+
delete this.query.bucketSize
|
|
501
|
+
if (!this.query.dateModifier) delete this.query.fillEmpty
|
|
502
|
+
}
|
|
503
|
+
this.$emit('change')
|
|
504
|
+
},
|
|
473
505
|
// Two or more fields draw one line each, which a split cannot share.
|
|
474
506
|
writeFields(rows) {
|
|
475
507
|
const [ main, ...extras ] = rows
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<option value="" disabled>field</option>
|
|
13
13
|
<option v-for="fl in fields" :key="fl.key" :value="fl.key">{{ fl.label }}</option>
|
|
14
14
|
</Dropdown>
|
|
15
|
-
<Dropdown v-model="f.op" class="w-[64px]" @change="onOp(f)">
|
|
15
|
+
<Dropdown v-model="f.op" :class="opLabel(f.op).length > 3 ? 'w-[110px]' : 'w-[64px]'" @change="onOp(f)">
|
|
16
16
|
<option v-for="o in opsFor(f.field, f.op)" :key="o" :value="o">{{ opLabel(o) }}</option>
|
|
17
17
|
</Dropdown>
|
|
18
18
|
<Dropdown v-if="f.op === 'period'" class="flex-1"
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
<option value="lastMonth">Last month</option>
|
|
27
27
|
<option value="custom">Custom date</option>
|
|
28
28
|
</Dropdown>
|
|
29
|
+
<div v-else-if="[ 'between', 'exceptBetween', 'weekday', 'monthOfYear' ].includes(f.op)" class="flex-1"></div>
|
|
29
30
|
<Dropdown v-else-if="typeOf(f.field) === 'date'" class="flex-1"
|
|
30
31
|
:model-value="isToken(f.value) ? f.value : 'custom'"
|
|
31
32
|
@update:model-value="setDate(f, $event)">
|
|
@@ -38,19 +39,27 @@
|
|
|
38
39
|
<option value="" disabled>value</option>
|
|
39
40
|
<option v-for="o in enumOptions(f.field)" :key="o.value" :value="`${o.value}`">{{ o.label }}</option>
|
|
40
41
|
</Dropdown>
|
|
41
|
-
<Textbox v-else v-model="f.value" class="flex-1" placeholder="value" @change="$emit('change')" />
|
|
42
|
+
<Textbox v-else v-model="f.value" class="flex-1" :placeholder="[ 'in', 'notIn' ].includes(f.op) ? 'a, b, c' : 'value'" @change="$emit('change')" />
|
|
42
43
|
<button type="button" class="px-1 text-text-300 hover:text-red-600" title="Remove filter" @click="filters.splice(fi, 1); $emit('change')">
|
|
43
44
|
<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>
|
|
44
45
|
</button>
|
|
45
46
|
</div>
|
|
46
|
-
<div v-if="f.op === 'period' && /\.\./.test(f.value)" class="flex flex-row items-center gap-1 ml-[16px] mr-[19px]">
|
|
47
|
+
<div v-if="(f.op === 'period' && /\.\./.test(f.value)) || [ 'between', 'exceptBetween' ].includes(f.op)" class="flex flex-row items-center gap-1 ml-[16px] mr-[19px]">
|
|
47
48
|
<Datepicker mode="popup" class="flex-1" :model-value="f.value.split('..')[0]"
|
|
48
49
|
@update:model-value="f.value = `${$event}..${f.value.split('..')[1]}`; $emit('change')" />
|
|
49
50
|
<span class=" text-text-400 mx-1">to</span>
|
|
50
51
|
<Datepicker mode="popup" class="flex-1" :model-value="f.value.split('..')[1]"
|
|
51
52
|
@update:model-value="f.value = `${f.value.split('..')[0]}..${$event}`; $emit('change')" />
|
|
52
53
|
</div>
|
|
53
|
-
<
|
|
54
|
+
<div v-if="[ 'weekday', 'monthOfYear' ].includes(f.op)" class="flex flex-row flex-wrap gap-x-3 gap-y-1 ml-[16px] mr-[19px]">
|
|
55
|
+
<label v-for="[ value, label ] in f.op === 'weekday' ? WEEKDAYS : MONTHS" :key="value"
|
|
56
|
+
class="flex flex-row items-center gap-1 text-xs text-text-400 cursor-pointer">
|
|
57
|
+
<input type="checkbox" class="w-3 h-3 accent-primary cursor-pointer" :checked="listOf(f.value).includes(value)"
|
|
58
|
+
@change="toggleList(f, value, $event.target.checked)" />
|
|
59
|
+
{{ label }}
|
|
60
|
+
</label>
|
|
61
|
+
</div>
|
|
62
|
+
<Datepicker v-if="!SPECIAL_DATE_OPS.includes(f.op) && typeOf(f.field) === 'date' && !isToken(f.value)" mode="popup"
|
|
54
63
|
:model-value="f.value" @update:model-value="f.value = $event; $emit('change')" />
|
|
55
64
|
</div>
|
|
56
65
|
</template>
|
|
@@ -82,6 +91,15 @@ export default {
|
|
|
82
91
|
|
|
83
92
|
emits: [ 'change' ],
|
|
84
93
|
|
|
94
|
+
data() {
|
|
95
|
+
return {
|
|
96
|
+
SPECIAL_DATE_OPS: [ 'period', 'between', 'exceptBetween', 'weekday', 'monthOfYear' ],
|
|
97
|
+
// Stored as MySQL WEEKDAY() / MONTH() numbers: Monday = 0, January = 1.
|
|
98
|
+
WEEKDAYS: [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun' ].map((label, i) => [ `${i}`, label ]),
|
|
99
|
+
MONTHS: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ].map((label, i) => [ `${i + 1}`, label ]),
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
85
103
|
computed: {
|
|
86
104
|
filters() {
|
|
87
105
|
return Array.isArray(this.query.filters) ? this.query.filters : []
|
|
@@ -125,12 +143,27 @@ export default {
|
|
|
125
143
|
},
|
|
126
144
|
opsFor(key, current) {
|
|
127
145
|
const type = this.typeOf(key)
|
|
128
|
-
if (type === 'date') return [ 'period', ...([ '=', '!=' ].includes(current) ? [ current ] : []), '>', '>=', '<', '<=' ]
|
|
129
|
-
if (type === 'number') return [ '=', '!=', '>', '>=', '<', '<=' ]
|
|
130
|
-
return [ '=', '!=', 'contains', 'in' ]
|
|
146
|
+
if (type === 'date') return [ 'period', ...([ '=', '!=' ].includes(current) ? [ current ] : []), '>', '>=', '<', '<=', 'between', 'except', 'exceptBetween', 'weekday', 'monthOfYear' ]
|
|
147
|
+
if (type === 'number') return [ '=', '!=', '>', '>=', '<', '<=', 'in', 'notIn' ]
|
|
148
|
+
return [ '=', '!=', 'contains', 'startsWith', 'endsWith', 'in', 'notIn' ]
|
|
131
149
|
},
|
|
132
150
|
opLabel(op) {
|
|
133
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
'=': '=', '!=': '≠', '>': '>', '>=': '≥', '<': '<', '<=': '≤', contains: 'has', in: 'in', notIn: 'not in',
|
|
153
|
+
startsWith: 'starts with', endsWith: 'ends with', period: 'is', between: 'between', except: 'except',
|
|
154
|
+
exceptBetween: 'except between', weekday: 'weekday', monthOfYear: 'month',
|
|
155
|
+
}[op] ?? op
|
|
156
|
+
},
|
|
157
|
+
listOf(value) {
|
|
158
|
+
return `${value ?? ''}`.split(',').map(v => v.trim()).filter(Boolean)
|
|
159
|
+
},
|
|
160
|
+
toggleList(filter, value, checked) {
|
|
161
|
+
const order = (filter.op === 'weekday' ? this.WEEKDAYS : this.MONTHS).map(([ v ]) => v)
|
|
162
|
+
const next = new Set(this.listOf(filter.value))
|
|
163
|
+
if (checked) next.add(value)
|
|
164
|
+
else next.delete(value)
|
|
165
|
+
filter.value = order.filter(v => next.has(v)).join(',')
|
|
166
|
+
this.$emit('change')
|
|
134
167
|
},
|
|
135
168
|
addFilter() {
|
|
136
169
|
if (!Array.isArray(this.query.filters)) this.query.filters = []
|
|
@@ -142,11 +175,30 @@ export default {
|
|
|
142
175
|
if (!allowed.includes(filter.op)) filter.op = allowed[0]
|
|
143
176
|
this.onOp(filter)
|
|
144
177
|
},
|
|
178
|
+
// Keeps the value when it still fits the new op, otherwise starts it at a sensible default.
|
|
145
179
|
onOp(filter) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
180
|
+
const value = `${filter.value ?? ''}`
|
|
181
|
+
const day = (d) => `${d.getFullYear()}-${`${d.getMonth() + 1}`.padStart(2, '0')}-${`${d.getDate()}`.padStart(2, '0')}`
|
|
182
|
+
const today = new Date()
|
|
183
|
+
const range = /^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/
|
|
184
|
+
const dateLike = this.isToken(value) || /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(value)
|
|
185
|
+
const isPeriod = /^(today|yesterday|thisWeek|lastWeek|thisMonth|lastMonth)$/.test(value) || range.test(value)
|
|
186
|
+
if (filter.op === 'period') {
|
|
187
|
+
if (!isPeriod) filter.value = 'today'
|
|
188
|
+
}
|
|
189
|
+
else if ([ 'between', 'exceptBetween' ].includes(filter.op)) {
|
|
190
|
+
if (!range.test(value)) filter.value = `${day(new Date(today.getFullYear(), today.getMonth(), 1))}..${day(today)}`
|
|
191
|
+
}
|
|
192
|
+
else if (filter.op === 'weekday') {
|
|
193
|
+
if (!/^[0-6](,[0-6])*$/.test(value)) filter.value = '0,1,2,3,4'
|
|
194
|
+
}
|
|
195
|
+
else if (filter.op === 'monthOfYear') {
|
|
196
|
+
if (!/^([1-9]|1[0-2])(,([1-9]|1[0-2]))*$/.test(value)) filter.value = `${today.getMonth() + 1}`
|
|
197
|
+
}
|
|
198
|
+
else if (this.typeOf(filter.field) === 'date') {
|
|
199
|
+
if (!dateLike) filter.value = '@today'
|
|
200
|
+
}
|
|
201
|
+
else if (isPeriod || this.isToken(value)) filter.value = ''
|
|
150
202
|
this.$emit('change')
|
|
151
203
|
},
|
|
152
204
|
setPeriod(filter, value) {
|
|
@@ -83,12 +83,31 @@
|
|
|
83
83
|
</Card>
|
|
84
84
|
</div>
|
|
85
85
|
|
|
86
|
-
<div>
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
<div class="flex flex-row gap-2">
|
|
87
|
+
<div class="flex-1 min-w-0">
|
|
88
|
+
<div class="flex flex-row items-center h-5">
|
|
89
|
+
<label class="flex-1 min-w-0 truncate text-xs text-text-400">Breakdown by (first field)</label>
|
|
90
|
+
</div>
|
|
91
|
+
<Dropdown v-model="query.breakdownBy" @change="setBreakdownBy">
|
|
92
|
+
<option value="">— none —</option>
|
|
93
|
+
<option v-for="f in fields.filter(f => f.type !== 'date')" :key="f.key" :value="f.key">{{ f.label }}</option>
|
|
94
|
+
</Dropdown>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div v-if="query.breakdownBy" class="w-[100px] min-w-0">
|
|
98
|
+
<div class="flex flex-row items-center h-5">
|
|
99
|
+
<label class="flex-1 min-w-0 truncate text-xs text-text-400" title="Top values shown in the bar, plus Other">Limit</label>
|
|
100
|
+
<label class="flex flex-row items-center gap-1 text-xs text-text-300 cursor-pointer" title="Show Other">
|
|
101
|
+
<input type="checkbox" class="w-3 h-3 accent-primary cursor-pointer" :checked="query.breakdownOther !== false"
|
|
102
|
+
@change="$event.target.checked ? delete query.breakdownOther : query.breakdownOther = false; $emit('change')" />
|
|
103
|
+
Other
|
|
104
|
+
</label>
|
|
105
|
+
</div>
|
|
106
|
+
<Dropdown :model-value="query.breakdownLimit || 4"
|
|
107
|
+
@update:model-value="Number($event) === 4 ? delete query.breakdownLimit : query.breakdownLimit = Number($event); $emit('change')">
|
|
108
|
+
<option v-for="n in 20" :key="n" :value="n">{{ n }}</option>
|
|
109
|
+
</Dropdown>
|
|
110
|
+
</div>
|
|
92
111
|
</div>
|
|
93
112
|
<AIDashboardDetailSetting :owner="query" :fields="fields" :datasources="datasources" pick-field collapsible @change="$emit('change')" />
|
|
94
113
|
</template>
|
|
@@ -206,6 +225,13 @@ export default {
|
|
|
206
225
|
else delete this.query.extraFields
|
|
207
226
|
this.$emit('change')
|
|
208
227
|
},
|
|
228
|
+
setBreakdownBy() {
|
|
229
|
+
if (!this.query.breakdownBy) {
|
|
230
|
+
delete this.query.breakdownLimit
|
|
231
|
+
delete this.query.breakdownOther
|
|
232
|
+
}
|
|
233
|
+
this.$emit('change')
|
|
234
|
+
},
|
|
209
235
|
extraLabel(extra) {
|
|
210
236
|
const field = this.fields.find(f => f.key === extra.field)?.label
|
|
211
237
|
if (extra.aggregation === 'count' || !field) return this.aggLabel(extra.aggregation)
|
|
@@ -30,7 +30,7 @@ export function buildInstruction(catalog) {
|
|
|
30
30
|
'- Any widget or nested group inside a grid group may take `"colSpan": 2-8` (how many of the group\'s columns it spans, at most `cols`) and `"rowSpan": 2-4` (how many rows tall it is). Use them for a wide widget (a map, a long time series or a pivot table across a whole row: `colSpan` = the group\'s `cols`) or a tall one beside smaller ones (a chart with `rowSpan` 2 next to two stacked charts). Leave them out for one cell.',
|
|
31
31
|
'- `{ "name", "type": "metric", "query", "fieldFormats" }` — a single KPI number.',
|
|
32
32
|
' - `fieldFormats` (optional) sets how each of the metric\'s numbers is shown, one entry per field in order — the main field first, then each `extraFields` entry; `null` keeps a field as it is. An entry is `{ "size": "xs"|"sm"|"lg"|"xl", "type": "number"|"percent"|"date"|"datetime", ... }`, every key optional: `size` is the text size (left out: medium); `number` takes `decimals` (0-6), `prefix`, `suffix` and `"compact": true` for the short form (1.2M); `percent` takes `decimals`, `prefix`, `suffix` and `"fraction": true` when the value is a fraction to multiply by 100 (0.25 → 25%); `date` / `datetime` (for a `min` / `max` of a date field) take `format`, a pattern built from YYYY YY MMMM MMM MM M DD D dddd ddd HH H hh h mm ss A, with [brackets] around literal words ("D MMM YYYY HH:mm"). Left out (no `type`), the number is shown plainly with thousands separators. `prefix` and `suffix` are any text written before or after the number, units and words alike — "add the suffix claim" / "tambahkan suffix claim" is `{ "type": "number", "suffix": " claim" }` (start a word suffix with a space, end a word prefix with one); always give `type` with them. Use it for "show it in rupiah" (`prefix` "Rp "), "as a percentage", "no decimals", "short numbers", "bigger number" (`size` "lg" or "xl"), "latest order date" shown as a date. Set it only when the user asks about how a number looks, and never say a prefix, suffix, decimals, percent, short form, size or date format is impossible — answer with `update` and the metric\'s `fieldFormats`; when returning an existing metric, keep its `fieldFormats` unchanged, and when you reorder or remove its fields move or drop their entries with them.',
|
|
33
|
-
'- `{ "name", "type": "chart", "chart": "bar"|"line"|"pie"|"doughnut"|"polarArea"|"radar"|"scatter"|"bubble", "query" }` — a chart, `bar` when left out. `line` for a trend over time; `pie` or `doughnut` for the shares of a whole and `polarArea` to compare a few groups by size (count or sum over a few groups — past eight groups the rest is folded into Other); a `pie` or `doughnut` with `splitBy` draws two rings, the inner one by `groupBy` and the outer one splitting each inner slice by `splitBy` (its top `splitLimit` plus Other) — use it for shares by two fields, "chat sessions by business and sub-business" (`groupBy` business, `splitBy` subBusiness), count or sum only and never on a date `groupBy`; never split a `polarArea`; `radar` to compare a few groups (or splitBy parts) around one circle; `scatter` and `bubble` to show one dot per group, bubble sized by its value. A bar chart takes `"horizontal": true` to lay its bars sideways — use it for many groups or long labels (campaign names, customers), or when asked. Only when the user asks for one of these kinds, or the data clearly suits it.',
|
|
33
|
+
'- `{ "name", "type": "chart", "chart": "bar"|"line"|"pie"|"doughnut"|"polarArea"|"radar"|"scatter"|"bubble", "query" }` — a chart, `bar` when left out. `line` for a trend over time; `pie` or `doughnut` for the shares of a whole and `polarArea` to compare a few groups by size (count or sum over a few groups — past eight groups the rest is folded into Other; `"groupOther": false` on the query shows only the top eight with no Other slice, only when asked); a `pie` or `doughnut` with `splitBy` draws two rings, the inner one by `groupBy` and the outer one splitting each inner slice by `splitBy` (its top `splitLimit` plus Other) — use it for shares by two fields, "chat sessions by business and sub-business" (`groupBy` business, `splitBy` subBusiness), count or sum only and never on a date `groupBy`; never split a `polarArea`; `radar` to compare a few groups (or splitBy parts) around one circle; `scatter` and `bubble` to show one dot per group, bubble sized by its value. A bar chart takes `"horizontal": true` to lay its bars sideways — use it for many groups or long labels (campaign names, customers), or when asked. Only when the user asks for one of these kinds, or the data clearly suits it.',
|
|
34
34
|
'- Chart look, any chart kind including a map: `"color": { "mode": "single", "color": "#RRGGBB" }` paints the whole series one color (not for pie, doughnut, polarArea or a split chart); `{ "mode": "mixed", "colors": ["#RRGGBB", ...] }` gives every bar, slice, dot or split part its own color (`colors` optional, the palette when left out). Leave `color` out for the default look. `"axisMarks": "day"|"week"|"month"|"none"` (bar or line grouped by `hour`, `day` or `week`, a horizontal bar too) marks where each day, week or month starts on the date axis — separator lines, alternating bands and the period\'s name ("Oct week 3" — the week of the month its Monday falls in — or "Oct 2026") under the dates — while every bar stays one bucket; left out, it is automatic (days for hourly data over several days, weeks past three weeks of days, months past three months of days or eight weeks). Set it only when asked ("show the weeks on the axis"); for one bar per week use `dateModifier` `week` instead. `"legend": true|false` shows or hides the legend; left out, it shows for split and round charts, and for mixed or heatmap maps. Set `color` and `legend` only when the user asks about colors or the legend.',
|
|
35
35
|
'- `{ "name", "type": "chart", "chart": "map", "country": "ID", "color": { "mode": "single"|"mixed"|"heatmap" }, "query" }` — a map of one country (only Indonesia, `ID`, so far) with one point per place, sized by its value. Use it for "by city", "per region", "per province", "where are our customers", "map".',
|
|
36
36
|
' - Places come either from a place name field — `groupBy`, a string field holding Indonesian city, regency or province names; names match without case or punctuation, with or without "Kota" / "Kabupaten" / "Kab." / "Provinsi", in Indonesian or English ("Jakarta Selatan", "Kota Bandung", "Jawa Timur", "West Java", "Solo", "Jogja"), and a province is placed at its capital — or from `latField` + `lngField`, number fields holding latitude and longitude, with an optional `groupBy` naming each point. Pick a name field only when its values look like place names; names that cannot be placed are counted as "not on the map".',
|
|
@@ -40,14 +40,15 @@ export function buildInstruction(catalog) {
|
|
|
40
40
|
'- `{ "name", "type": "table", "query" }` — a data table: a list of rows, or a pivot table when `query.pivot` is set.',
|
|
41
41
|
'',
|
|
42
42
|
'# Query shapes (preferred — the server computes live data from them)',
|
|
43
|
-
'- metric: `{ "datasource", "aggregation": "count"|"countDistinct"|"sum"|"avg"|"min"|"max", "field", "extraFields": [{ "aggregation", "field", "label" }], "breakdownBy": <field key>, "filters" }` — `field` is required unless aggregation is `count`; `countDistinct` counts the different values of any field ("unique customers" is countDistinct of the customer field). `extraFields` (up to 4) are more fields over the same rows and filters, shown smaller after the main number as a ratio — "76 / 12,000" for a *Total Chats / Total Views* metric (main: sum of chats, extra: sum of views); name the widget after all its fields, `label` names each extra one. `breakdownBy` breaks down the main field only.',
|
|
44
|
-
'- chart: `{ "datasource", "aggregation": "count"|"countDistinct"|"sum"|"avg"|"min"|"max", "field", "groupBy": <field key>, "dateModifier": ""|"hour"|"day"|"week"|"month"|"quarter"|"year"|"hourOfDay"|"dayOfWeek"|"dayOfMonth"|"monthOfYear", "bucketSize": <number>, "limit", "sort": "labelAsc"|"labelDesc"|"valueDesc"|"valueAsc", "fillEmpty": true, "splitBy": <field key>, "splitLimit": 1-20, "splitOther": false, "breakdownBy": <field key>, "filters" }` — `aggregation` / `field` measure every bar, slice or point: left out, each one counts its rows; `sum` / `avg` need a number field, `min` / `max` a number or date field, `countDistinct` any field. Use them for "revenue per month" (sum of the amount field), "average order value by city" (avg). Set a dateModifier when grouping by a date field: `hour` for "per hour" / "hourly", `day` for "per day" / "daily", and so on. `week` with `"weekOfMonth": true` keeps one bar per week but names each by the week of the month its Monday falls in ("Oct week 2") — use it for "week of month" or "weeks named by month". The recurring ones fold every date onto one cycle: `hourOfDay` (0-23) for "busiest hour" / "by hour of day" over several days, `dayOfWeek` (Monday to Sunday) for "by weekday", `dayOfMonth`, `monthOfYear` for seasonality. `bucketSize` groups a number field into ranges that wide (order totals in steps of 100000). `sort` orders the bars (left out: dates and ranges by label, other groups largest first). `fillEmpty` shows a zero bar for every empty bucket — set it for time series and recurring buckets so gaps are visible. `splitBy` stacks every bar by another field, one colour per value (its top `splitLimit`, 1 to 20, default 7, plus "Other" — `"splitOther": false` leaves Other out, only when asked) — use it for "per day, grouped by business", "by status per month". A split bar chart stacks its parts; `"stacked": false` on the node puts them side by side instead — set it when asked to compare the parts bar by bar ("side by side", "grouped", "not stacked"). Leave it out for a single-colour chart.',
|
|
43
|
+
'- metric: `{ "datasource", "aggregation": "count"|"countDistinct"|"sum"|"avg"|"min"|"max", "field", "extraFields": [{ "aggregation", "field", "label" }], "breakdownBy": <field key>, "breakdownLimit": 1-20, "breakdownOther": false, "filters" }` — `field` is required unless aggregation is `count`; `countDistinct` counts the different values of any field ("unique customers" is countDistinct of the customer field). `extraFields` (up to 4) are more fields over the same rows and filters, shown smaller after the main number as a ratio — "76 / 12,000" for a *Total Chats / Total Views* metric (main: sum of chats, extra: sum of views); name the widget after all its fields, `label` names each extra one. `breakdownBy` breaks down the main field only; `breakdownLimit` (default 4) sets how many top values its bar shows, `"breakdownOther": false` leaves the Other part out — only when asked.',
|
|
44
|
+
'- chart: `{ "datasource", "aggregation": "count"|"countDistinct"|"sum"|"avg"|"min"|"max", "field", "groupBy": <field key>, "dateModifier": ""|"hour"|"day"|"week"|"month"|"quarter"|"year"|"hourOfDay"|"dayOfWeek"|"dayOfMonth"|"monthOfYear", "bucketSize": <number>, "limit", "sort": "labelAsc"|"labelDesc"|"valueDesc"|"valueAsc", "fillEmpty": true, "splitBy": <field key>, "splitLimit": 1-20, "splitOther": false, "groupOther": false, "breakdownBy": <field key>, "filters" }` — `aggregation` / `field` measure every bar, slice or point: left out, each one counts its rows; `sum` / `avg` need a number field, `min` / `max` a number or date field, `countDistinct` any field. Use them for "revenue per month" (sum of the amount field), "average order value by city" (avg). Set a dateModifier when grouping by a date field: `hour` for "per hour" / "hourly", `day` for "per day" / "daily", and so on. `week` with `"weekOfMonth": true` keeps one bar per week but names each by the week of the month its Monday falls in ("Oct week 2") — use it for "week of month" or "weeks named by month". The recurring ones fold every date onto one cycle: `hourOfDay` (0-23) for "busiest hour" / "by hour of day" over several days, `dayOfWeek` (Monday to Sunday) for "by weekday", `dayOfMonth`, `monthOfYear` for seasonality. `bucketSize` groups a number field into ranges that wide (order totals in steps of 100000) — not on a pie, doughnut or polarArea. `sort` orders the bars (left out: dates and ranges by label, other groups largest first). `fillEmpty` shows a zero bar for every empty bucket — set it for time series and recurring buckets so gaps are visible. `splitBy` stacks every bar by another field, one colour per value (its top `splitLimit`, 1 to 20, default 7, plus "Other" — `"splitOther": false` leaves Other out, only when asked) — use it for "per day, grouped by business", "by status per month". A split bar chart stacks its parts; `"stacked": false` on the node puts them side by side instead — set it when asked to compare the parts bar by bar ("side by side", "grouped", "not stacked"). Leave it out for a single-colour chart.',
|
|
45
45
|
'- chart `extraFields` (line and bar only, up to 4): `[{ "aggregation", "field", "label" }]` — more measures over the same rows, groups and filters, each drawn as its own line (or bar) in its own colour with a legend; `aggregation` / `field` stay the first one. Use it when one chart should compare several numbers over the same axis — "spend, impressions, chats and sales per hour" is a `line` chart grouped by the date field with `dateModifier: "hour"`, `aggregation: "sum"`, `field` the spend field and `extraFields` sum of impressions, chats and sales. Give the first one a label too as `fieldLabel`. Each line is scaled on its own, so numbers of very different sizes all read; set `"sharedAxis": true` on the node only when they share a unit and should be compared directly. It cannot be combined with `splitBy`; `breakdownBy` and a click break down the first field.',
|
|
46
46
|
'- `detail` — cards with more about one value, e.g. a creative\'s image, title, body and link: `{ "fields": [{ "key", "type": "text"|"longText"|"number"|"date"|"datetime"|"image"|"link", "label", "format" }], "groupBy": <field key> }` reads the widget\'s own rows, one card per `groupBy` value (left out: one card, the latest row). When the details live in another datasource, give `"datasource": <its key>, "match": { "field": <this datasource\'s id field>, "to": <that datasource\'s matching field> }` instead of `groupBy`; `fields` are then that datasource\'s keys, and one card is shown per matching row (e.g. results by creative → `{ "datasource": "marketing_campaign_creative", "match": { "field": "creativeId", "to": "id" } }`). It goes on a pivot row field, or on a metric, chart or pivot table query with `"field": <the field whose values open it>` — an ⓘ then sits beside each of its values in the breakdown, and a chart grouped by that field shows Detail on the rows behind a bar. A date or date & time detail field may take `format`, a date pattern as in `fieldFormats`. Set it when asked to see or open details of a value.',
|
|
47
47
|
'- `breakdownBy` (metric, chart and pivot table) — a field that is not a date; tapping the number or a bar opens a table of that value per `breakdownBy` value, every value listed. Set it when the user asks to see a widget broken down, or "per business" detail behind a number. A chart without one breaks down by its `splitBy`.',
|
|
48
48
|
'- table: `{ "datasource", "columns": [{ "key", "label", "format" }], "order": [[<field key>, "asc"|"desc"], ...], "pageSize": 1-50, "filters" }` — `order` sorts by its first entry, then the next for ties (up to 5 levels); a column\'s optional `format` sets how its values are shown, the same shape as a metric\'s `fieldFormats` entry without `size` (`{ "type": "number", "prefix": "Rp " }`, `{ "type": "date", "format": "D MMM YYYY" }`); set it only when asked how a column looks, and keep it when returning the table. A column may also take `"colorScale"` (and `"colorScaleReverse": true`), the same as a pivot value\'s: its cells are tinted by rank among the table\'s rows — set it when asked to color, highlight or heat-map a column.',
|
|
49
49
|
'- pivot table (a table with `pivot` instead of columns/order/pageSize): `{ "datasource", "pivot": { "rows": [{ "field", "label", "dateModifier", "bucketSize" }], "columns": [same], "values": [{ "aggregation", "field", "label", "showAs": "percentOfTotal"|"percentOfRow"|"percentOfColumn"|"percentOfParent" }], "sort": "labelDesc"|"valueDesc"|"valueAsc", "sortValue": <value index>, "grandTotals": "both"|"row"|"column"|"none", "subtotals": false }, "breakdownBy": <field key>, "filters" }` — like an Excel PivotTable: `rows` are nested down the side (each one a level with its own subtotal, collapsible), `columns` go across the top (usually one field, often a date with a dateModifier such as `month`), and every cell shows each of `values` for that row and column, with grand totals. `label` on a value reads like "Sum of Spend". `showAs` turns a value into a share (leave it out for the plain value). A row field may take a `"detail"` (see below): an ⓘ beside each of its rows opens its cards for that row — set it when asked to see the details behind a row. `sort` orders the rows within each level (left out: label A → Z; `valueDesc` / `valueAsc` = largest / smallest first by the value at index `sortValue` in `values`, 0 when left out). A value may also be calculated from two other values of the same pivot: `{ "aggregation": "calc", "label", "calc": { "a": { "aggregation", "field" }, "op": "/"|"*"|"+"|"-", "b": { "aggregation", "field" } }, "format": "percent" }` — `a` and `b` name values already in `values` (same aggregation and field); it is computed in each cell, so "impressions per spend %" is `a` sum of impressions, `op` "/", `b` sum of spend, `format` "percent". Calculated values cannot take `showAs`. Any value may take `"colorScale"`: a theme color (`primary`, `secondary`, `accent`) or a Tailwind color (`red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`, `slate`, `gray`) — its cells are tinted by rank among the rows of the same level in that column, strongest at the highest value, or at the lowest with `"colorScaleReverse": true` (use it for \"lower is better\", like cost per chat) — set it when asked to color, highlight or heat-map a value. When a calculated value needs a value the pivot does not have yet, add that value to `values` too. Sorting "by Meta Chats" or "by impressions per spend" is `sort` `valueDesc` (or `valueAsc` for "lowest first") with `sortValue` the index of that value. Use a pivot table for "X by Y and Z", "per campaign per month", cross-tabs or summaries with subtotals. Tapping a cell opens its breakdown by `breakdownBy`, or the rows behind it when there is none.',
|
|
50
|
-
'- filters: `[{ "field": <field key>, "op": "="|"!="|">"|">="|"<"|"<="|"contains"|"in", "value" }]`',
|
|
50
|
+
'- filters: `[{ "field": <field key>, "op": "="|"!="|">"|">="|"<"|"<="|"contains"|"startsWith"|"endsWith"|"in"|"notIn", "value" }]` — `in` / `notIn` take a comma-separated list ("1,8") on text and number fields; `contains`, `startsWith`, `endsWith` are for text fields.',
|
|
51
|
+
'- more date ops: `between` / `exceptBetween` with `"YYYY-MM-DD..YYYY-MM-DD"` (both days included) keep or leave out a date range; `except` with one day (a date or a token like `@today`) leaves that whole day out; `weekday` keeps only those days of the week, `"value": "0,1,2,3,4"` (Monday = 0 … Sunday = 6, so "5,6" is weekends); `monthOfYear` keeps only those months of any year, `"value": "6,7,8"` (January = 1). Use `weekday` for "weekdays only" / "weekends", `monthOfYear` for "every December", `exceptBetween` for "excluding the holidays from … to …".',
|
|
51
52
|
'- A field listed with `values` stores codes that each mean something ("8 = Closed"): filter on the stored code (`{ "field": "status", "op": "=", "value": 8 }` for closed sessions, `in` with "1,8" for several), never on the meaning. The dashboard shows the meanings wherever the codes appear, so use the meanings in widget names and replies.',
|
|
52
53
|
'- periods — today, yesterday, this week, last week, this month, last month or a date range on a date field is ONE filter with the op `period`: `{ "field": "createdAt", "op": "period", "value": "today" | "yesterday" | "thisWeek" | "lastWeek" | "thisMonth" | "lastMonth" | "2026-10-01..2026-10-15" }` (a range includes both days: from 00:00:00 of the first day to the end of the last). It is recomputed every time the dashboard opens, so "today" stays today. One specific day ("on 15 October", "2026-10-15") is a range of that day to itself, `"2026-10-15..2026-10-15"`. Never filter a date field with `=` or `!=` — on a date-time that matches only one exact moment, and the filter editor no longer offers them for dates (it shows `period` as **is**, with Today, Yesterday, This week, Last week, This month, Last month or Custom date — a from and a to date).',
|
|
53
54
|
'- other relative dates ("last 7 days", "last 30 days", "this year") — a `>=` filter whose value is a token, never a literal date: `@today`, `@startOfWeek`, `@startOfMonth`, `@startOfYear`, optionally shifted (`@today-7d`, `@today-30d`, `@startOfMonth-1m`; units d, w, m, y). Each is that day at 00:00.',
|
|
@@ -57,7 +58,7 @@ export function buildInstruction(catalog) {
|
|
|
57
58
|
'',
|
|
58
59
|
'# What the user can do on the dashboard (answer questions about it with the action `none`)',
|
|
59
60
|
'- Tapping a metric, a bar, a pie slice, a map point or a pivot cell opens its breakdown by `breakdownBy` (a chart without one uses `splitBy`), and tapping a breakdown row lists the records behind it. Without a breakdown field the tap lists the records straight away.',
|
|
60
|
-
'- A metric with `breakdownBy` also shows a thin bar under its number, split into its top
|
|
61
|
+
'- A metric with `breakdownBy` also shows a thin bar under its number, split into its top `breakdownLimit` values (default four) plus Other.',
|
|
61
62
|
'- A metric\'s settings (Views, with the metric selected) list its fields one per row — the first is the big number, the rest are shown after it as a ratio; drag a row to the top to make it the main field. Clicking a field (or **+ Add field**) opens a card with its Field, Aggregation, **Font size** (Extra small to Extra large) and **Data type** — Auto, Number (decimal places, prefix, suffix, short form 1.2M), Percent (decimal places, prefix, suffix, value is a fraction) or Date / Date & time (a date format, or Custom… with its own pattern) — with a preview. These are the metric\'s `fieldFormats`.',
|
|
62
63
|
'- The breakdown table and the table of records behind a widget each have a **Customize** button (sliders icon) that picks, orders, renames and hides their columns, sets a column\'s type (text, number, date, date & time) and date format; column widths dragged there are saved too. These are saved with the widget and kept when you change it, but you cannot set them — tell the user where they are.',
|
|
63
64
|
'- A pivot table\'s row levels collapse and expand one by one or all at once; its columns can be resized by dragging their edges, and the widths are saved with the dashboard (your edits keep them).',
|
|
@@ -234,8 +235,11 @@ const querySchema = {
|
|
|
234
235
|
splitBy: { type: 'string' },
|
|
235
236
|
splitLimit: { type: 'number' },
|
|
236
237
|
splitOther: { type: 'boolean' },
|
|
238
|
+
groupOther: { type: 'boolean' },
|
|
237
239
|
weekOfMonth: { type: 'boolean' },
|
|
238
240
|
breakdownBy: { type: 'string' },
|
|
241
|
+
breakdownLimit: { type: 'number' },
|
|
242
|
+
breakdownOther: { type: 'boolean' },
|
|
239
243
|
detail: detailSchema,
|
|
240
244
|
fieldLabel: { type: 'string' },
|
|
241
245
|
extraFields: {
|
|
@@ -383,8 +387,12 @@ export const RESPONSE_SCHEMA = {
|
|
|
383
387
|
|
|
384
388
|
const NODE_TYPES = [ 'group', 'metric', 'chart', 'table' ]
|
|
385
389
|
const COLOR_SCALES = [ 'primary', 'secondary', 'accent', 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', 'slate', 'gray' ]
|
|
386
|
-
const FILTER_OPS = [ '=', '!=', '>', '>=', '<', '<=', 'contains', 'in', 'period' ]
|
|
390
|
+
const FILTER_OPS = [ '=', '!=', '>', '>=', '<', '<=', 'contains', 'startsWith', 'endsWith', 'in', 'notIn', 'period', 'between', 'except', 'exceptBetween', 'weekday', 'monthOfYear' ]
|
|
387
391
|
const PERIOD = /^(today|yesterday|thisWeek|lastWeek|thisMonth|lastMonth|\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2})$/
|
|
392
|
+
const DATE_RANGE = /^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/
|
|
393
|
+
// Which values each op takes: date-only ops, their value shapes, and ops a date field cannot take.
|
|
394
|
+
const DATE_ONLY_OPS = { period: PERIOD, between: DATE_RANGE, exceptBetween: DATE_RANGE, except: /^(@[a-zA-Z]+([+-]\d+[dwmy])?|\d{4}-\d{2}-\d{2})$/, weekday: /^[0-6](,[0-6])*$/, monthOfYear: /^([1-9]|1[0-2])(,([1-9]|1[0-2]))*$/ }
|
|
395
|
+
const TEXT_ONLY_OPS = [ 'contains', 'startsWith', 'endsWith' ]
|
|
388
396
|
const DATE_MODIFIERS = [ '', 'hour', 'day', 'week', 'month', 'quarter', 'year', 'hourOfDay', 'dayOfWeek', 'dayOfMonth', 'monthOfYear' ]
|
|
389
397
|
const SORTS = [ 'labelAsc', 'labelDesc', 'valueDesc', 'valueAsc' ]
|
|
390
398
|
const DETAIL_TYPES = [ 'text', 'longText', 'number', 'date', 'datetime', 'image', 'link' ]
|
|
@@ -422,7 +430,13 @@ function sanitizeFilters(filters, ds) {
|
|
|
422
430
|
if (!Array.isArray(filters)) return []
|
|
423
431
|
return filters
|
|
424
432
|
.filter(f => f && ds.fields.has(f.field) && FILTER_OPS.includes(f.op) && f.value !== '' && f.value != null)
|
|
425
|
-
.filter(f =>
|
|
433
|
+
.filter(f => {
|
|
434
|
+
const type = ds.fields.get(f.field).type
|
|
435
|
+
if (DATE_ONLY_OPS[f.op]) return type === 'date' && DATE_ONLY_OPS[f.op].test(`${f.value}`)
|
|
436
|
+
if (TEXT_ONLY_OPS.includes(f.op)) return type !== 'date' && type !== 'number'
|
|
437
|
+
if (f.op === 'notIn') return type !== 'date'
|
|
438
|
+
return true
|
|
439
|
+
})
|
|
426
440
|
.slice(0, 10)
|
|
427
441
|
.map(f => ds.fields.get(f.field).type === 'date' && f.op === '=' && /^\d{4}-\d{2}-\d{2}$/.test(`${f.value}`)
|
|
428
442
|
? { field: f.field, op: 'period', value: `${f.value}..${f.value}` }
|
|
@@ -491,6 +505,10 @@ function sanitizeQuery(node, dsMap, notes) {
|
|
|
491
505
|
if (extras.length) clean.extraFields = extras
|
|
492
506
|
}
|
|
493
507
|
sanitizeBreakdown(query, clean, ds, node, notes, dsMap)
|
|
508
|
+
if (clean.breakdownBy) {
|
|
509
|
+
if (query.breakdownLimit != null) clean.breakdownLimit = clampInt(query.breakdownLimit, 1, 20, 4)
|
|
510
|
+
if (query.breakdownOther === false) clean.breakdownOther = false
|
|
511
|
+
}
|
|
494
512
|
return clean
|
|
495
513
|
}
|
|
496
514
|
|
|
@@ -530,7 +548,7 @@ function sanitizeQuery(node, dsMap, notes) {
|
|
|
530
548
|
const groupType = ds.fields.get(query.groupBy).type
|
|
531
549
|
clean.dateModifier = groupType === 'date' && DATE_MODIFIERS.includes(query.dateModifier) ? query.dateModifier : ''
|
|
532
550
|
if (clean.dateModifier === 'week' && query.weekOfMonth === true) clean.weekOfMonth = true
|
|
533
|
-
if (groupType === 'number' && Number(query.bucketSize) > 0 && Number.isFinite(Number(query.bucketSize))) clean.bucketSize = Number(query.bucketSize)
|
|
551
|
+
if (groupType === 'number' && ![ 'pie', 'doughnut', 'polarArea' ].includes(node.chart) && Number(query.bucketSize) > 0 && Number.isFinite(Number(query.bucketSize))) clean.bucketSize = Number(query.bucketSize)
|
|
534
552
|
if (query.limit != null) clean.limit = clampInt(query.limit, 1, 100, 30)
|
|
535
553
|
if (SORTS.includes(query.sort)) clean.sort = query.sort
|
|
536
554
|
if (query.fillEmpty === true && (clean.dateModifier || clean.bucketSize)) clean.fillEmpty = true
|
|
@@ -553,6 +571,7 @@ function sanitizeQuery(node, dsMap, notes) {
|
|
|
553
571
|
}
|
|
554
572
|
else notes.push(`invalid splitBy "${query.splitBy}" on "${node.name}" — drawn without a split`)
|
|
555
573
|
}
|
|
574
|
+
if (query.groupOther === false && [ 'pie', 'doughnut', 'polarArea' ].includes(node.chart)) clean.groupOther = false
|
|
556
575
|
if (clean.splitBy && [ 'pie', 'doughnut', 'polarArea' ].includes(node.chart)
|
|
557
576
|
&& (node.chart === 'polarArea' || groupType === 'date' || ![ 'count', 'sum' ].includes(clean.aggregation))) {
|
|
558
577
|
notes.push(`splitBy dropped on "${node.name}" — only a count or sum pie or doughnut not grouped by a date has an outer ring`)
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// ── AI dashboard query engine ────────────────────────────────────────────────
|
|
2
|
+
// Runs a dashboard node query (metric, chart, table, pivot) against a catalogue
|
|
3
|
+
// of datasources, each mapping a Sequelize model to the fields it exposes.
|
|
4
|
+
//
|
|
5
|
+
// const { createQueryEngine } = require('@mixd-id/web-scaffold/ai-dashboard-query')
|
|
6
|
+
// const { runQuery } = createQueryEngine({ models, datasources, canRead, fail })
|
|
7
|
+
// await runQuery({ datasource: 'order', type: 'chart', groupBy: 'createdAt', dateModifier: 'day' }, user)
|
|
8
|
+
//
|
|
9
|
+
// Options:
|
|
10
|
+
// models → { [name]: SequelizeModel }, looked up by datasource.model and field.subquery.model
|
|
11
|
+
// datasources → [ { key, model, aggregations, fields: [ { key, label, type, relation?, attribute?, count?, subquery?, enum? } ] } ]
|
|
12
|
+
// canRead → (datasource, user) => boolean, defaults to allowing all
|
|
13
|
+
// fail → (key, message) => never, throws the host's validation error;
|
|
14
|
+
// defaults to an Error carrying `errors: { [key]: [ message ] }`
|
|
15
|
+
// Sequelize → the host's Sequelize class; defaults to the one the models are bound to.
|
|
16
|
+
// It must be the host's copy, fn / col / literal are checked with instanceof
|
|
17
|
+
|
|
18
|
+
const dayjs = require('dayjs')
|
|
19
|
+
|
|
20
|
+
const TYPES = [ 'metric', 'chart', 'table', 'pivot' ]
|
|
21
|
+
const OPS = [ '=', '!=', '>', '>=', '<', '<=', 'contains', 'startsWith', 'endsWith', 'in', 'notIn', 'notBetween', 'empty' ]
|
|
22
|
+
const DATE_FORMATS = { hour: '%Y-%m-%d %H:00', day: '%Y-%m-%d', week: '%Y-%u', month: '%Y-%m', year: '%Y' }
|
|
23
|
+
const RECURRING = { hourOfDay: 'HOUR', dayOfWeek: 'WEEKDAY', dayOfMonth: 'DAYOFMONTH', monthOfYear: 'MONTH' }
|
|
24
|
+
const DATE_MODIFIERS = [ '', ...Object.keys(DATE_FORMATS), 'quarter', ...Object.keys(RECURRING) ]
|
|
25
|
+
const MAX_CHART_LIMIT = 2000
|
|
26
|
+
const MAX_PAGE_SIZE = 100
|
|
27
|
+
const MAX_PIVOT_GROUPS = 5000
|
|
28
|
+
const MAX_PIVOT_DIMENSIONS = 4
|
|
29
|
+
const MAX_PIVOT_VALUES = 12
|
|
30
|
+
|
|
31
|
+
const defaultFail = (key, message) => {
|
|
32
|
+
const error = new Error(message)
|
|
33
|
+
error.errors = { [key]: [ message ] }
|
|
34
|
+
throw error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createQueryEngine({ models, datasources, canRead = () => true, fail = defaultFail, Sequelize } = {}) {
|
|
38
|
+
if(!models) throw new Error('createQueryEngine needs models')
|
|
39
|
+
if(!Array.isArray(datasources)) throw new Error('createQueryEngine needs a datasources array')
|
|
40
|
+
|
|
41
|
+
Sequelize = Sequelize ?? Object.values(models).find(_ => _?.sequelize)?.sequelize.constructor
|
|
42
|
+
if(!Sequelize) throw new Error('createQueryEngine needs Sequelize, or models bound to a connection')
|
|
43
|
+
const { Op, fn, col, literal, where: whereOf } = Sequelize
|
|
44
|
+
|
|
45
|
+
const datasourceFor = (key, user) => {
|
|
46
|
+
const datasource = datasources.find(_ => _.key === key)
|
|
47
|
+
if(!datasource) fail('datasource', `Unknown datasource: ${key}`)
|
|
48
|
+
if(!canRead(datasource, user)) fail('datasource', `No access to datasource: ${key}`)
|
|
49
|
+
return datasource
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fieldOf = (datasource, key, name = 'field') => {
|
|
53
|
+
const field = datasource.fields.find(_ => _.key === key)
|
|
54
|
+
if(!field) fail(name, `Unknown field "${key}" in ${datasource.key}`)
|
|
55
|
+
return field
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const columnOf = (Model, key) => Model.rawAttributes[key]?.field ?? key
|
|
59
|
+
|
|
60
|
+
// Where a field is read from: the association path to join (empty for this table), the column,
|
|
61
|
+
// and for this table the attribute key. `relation` may be nested ('marketingCampaign.addon').
|
|
62
|
+
// The last relation's own key (addonId → addon.id) is the foreign key on the table before it, so it skips that join.
|
|
63
|
+
const sourceOf = (Model, field) => {
|
|
64
|
+
if(field.count || field.subquery) return { path: [] }
|
|
65
|
+
if(!field.relation) return { path: [], column: columnOf(Model, field.key), key: field.key }
|
|
66
|
+
|
|
67
|
+
const segments = field.relation.split('.')
|
|
68
|
+
let Source = Model
|
|
69
|
+
for(const [ i, segment ] of segments.entries()){
|
|
70
|
+
const association = Source.associations[segment]
|
|
71
|
+
if(!association) throw new Error(`Unknown relation ${field.relation} on ${Model.name}`)
|
|
72
|
+
|
|
73
|
+
if(i === segments.length - 1){
|
|
74
|
+
const path = segments.slice(0, i)
|
|
75
|
+
if(association.associationType === 'BelongsTo' && field.attribute === association.targetKey)
|
|
76
|
+
return { path, column: columnOf(Source, association.foreignKey), key: path.length ? undefined : association.foreignKey }
|
|
77
|
+
return { path: segments, column: columnOf(association.target, field.attribute) }
|
|
78
|
+
}
|
|
79
|
+
Source = association.target
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The SQL column of a field, qualified so it stays unambiguous next to joined tables
|
|
84
|
+
const refOf = (Model, field) => {
|
|
85
|
+
if(field.count) return countOf(Model, field)
|
|
86
|
+
if(field.subquery) return subqueryOf(Model, field)
|
|
87
|
+
const { path, column } = sourceOf(Model, field)
|
|
88
|
+
return col(`${path.length ? path.join('->') : Model.name}.${column}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A `count` field: the number of rows of that has-many relation per record, as a correlated subquery,
|
|
92
|
+
// so it never multiplies this table's rows the way a join would
|
|
93
|
+
const countOf = (Model, field) => {
|
|
94
|
+
const association = Model.associations[field.count]
|
|
95
|
+
if(association?.associationType !== 'HasMany') throw new Error(`${field.count} is not a has-many relation of ${Model.name}`)
|
|
96
|
+
|
|
97
|
+
const queryInterface = Model.sequelize.getQueryInterface()
|
|
98
|
+
const table = queryInterface.quoteIdentifier(association.target.getTableName())
|
|
99
|
+
const foreignKey = queryInterface.quoteIdentifier(columnOf(association.target, association.foreignKey))
|
|
100
|
+
const sourceKey = queryInterface.quoteIdentifiers(`${Model.name}.${columnOf(Model, association.sourceKey)}`)
|
|
101
|
+
return literal(`(SELECT COUNT(*) FROM ${table} WHERE ${table}.${foreignKey} = ${sourceKey})`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A `subquery` field: count or sum of another model's rows that match this record, as a correlated subquery
|
|
105
|
+
// like a `count` field, for links no association can express.
|
|
106
|
+
// { model, aggregate: 'count' | 'sum', attribute, match: { theirKey: ourKey | { key: ourKey, collate } }, range: [ theirDate, ourFrom, ourTo ], where: { theirKey: value | [ values ] } }
|
|
107
|
+
// `collate` converts our column to the collation of theirs, so their index is used when the two differ.
|
|
108
|
+
// `index` forces their index, for a match column whose statistics make the optimizer scan instead
|
|
109
|
+
const subqueryOf = (Model, field) => {
|
|
110
|
+
const { model, aggregate = 'count', attribute, match = {}, range, where = {}, index } = field.subquery
|
|
111
|
+
const Target = models[model]
|
|
112
|
+
const queryInterface = Model.sequelize.getQueryInterface()
|
|
113
|
+
const theirs = (key) => `sub.${queryInterface.quoteIdentifier(columnOf(Target, key))}`
|
|
114
|
+
const ours = (key) => queryInterface.quoteIdentifiers(`${Model.name}.${columnOf(Model, key)}`)
|
|
115
|
+
const escape = (value) => Model.sequelize.escape(value)
|
|
116
|
+
const matchOf = (our) => {
|
|
117
|
+
const { key, collate } = typeof our === 'string' ? { key: our } : our
|
|
118
|
+
if(!collate) return ours(key)
|
|
119
|
+
if(!/^[a-z0-9_]+$/.test(collate)) throw new Error(`Invalid collation ${collate}`)
|
|
120
|
+
return `CONVERT(${ours(key)} USING ${collate.split('_')[0]}) COLLATE ${collate}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const conditions = [
|
|
124
|
+
...Object.entries(match).map(([ their, our ]) => `${theirs(their)} = ${matchOf(our)}`),
|
|
125
|
+
...(range ? [ `${theirs(range[0])} >= ${ours(range[1])}`, `${theirs(range[0])} <= ${ours(range[2])}` ] : []),
|
|
126
|
+
...Object.entries(where).map(([ key, value ]) => Array.isArray(value) ?
|
|
127
|
+
`${theirs(key)} IN (${value.map(escape).join(', ')})` : `${theirs(key)} = ${escape(value)}`)
|
|
128
|
+
]
|
|
129
|
+
const expr = aggregate === 'sum' ? `COALESCE(SUM(${theirs(attribute)}), 0)` : 'COUNT(*)'
|
|
130
|
+
const table = queryInterface.quoteIdentifier(Target.getTableName())
|
|
131
|
+
const hint = index ? ` FORCE INDEX (${queryInterface.quoteIdentifier(index)})` : ''
|
|
132
|
+
return literal(`(SELECT ${expr} FROM ${table} sub${hint}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''})`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The where key of a field: its own attribute, or $relation.column$ for a joined one
|
|
136
|
+
const whereKeyOf = (Model, field) => {
|
|
137
|
+
const { path, column, key } = sourceOf(Model, field)
|
|
138
|
+
return path.length ? `$${path.join('.')}.${column}$` : key
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Nested LEFT JOINs for the relations the given field keys use.
|
|
142
|
+
// Queries pass subQuery: false, or a has-many join with a LIMIT moves this table into a subquery the joined columns can't reach
|
|
143
|
+
const joinsFor = (datasource, keys) => {
|
|
144
|
+
const Model = models[datasource.model]
|
|
145
|
+
const include = []
|
|
146
|
+
|
|
147
|
+
for(const key of new Set(keys)){
|
|
148
|
+
const field = datasource.fields.find(_ => _.key === key)
|
|
149
|
+
if(!field) continue
|
|
150
|
+
|
|
151
|
+
let level = include
|
|
152
|
+
for(const association of sourceOf(Model, field).path){
|
|
153
|
+
let node = level.find(_ => _.association === association)
|
|
154
|
+
if(!node) level.push(node = { association, attributes: [], required: false, include: [] })
|
|
155
|
+
level = node.include
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return include
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const queryKeys = ({ field, secondary, groupBy, filters, columns, order } = {}) => [
|
|
163
|
+
field, secondary?.field, groupBy,
|
|
164
|
+
...(Array.isArray(filters) ? filters.map(_ => _?.field) : []),
|
|
165
|
+
...(Array.isArray(columns) ? columns.map(_ => typeof _ === 'string' ? _ : _?.key) : []),
|
|
166
|
+
...(Array.isArray(order) ? order.map(_ => Array.isArray(_) ? _[0] : undefined) : [])
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
const RELATIVE_UNITS = { d: 'day', day: 'day', w: 'week', week: 'week', m: 'month', month: 'month', y: 'year', year: 'year' }
|
|
170
|
+
|
|
171
|
+
const toDate = (field, value) => {
|
|
172
|
+
if(value === null) return value
|
|
173
|
+
if(value === undefined || `${value}`.trim() === '') return undefined
|
|
174
|
+
|
|
175
|
+
const text = `${value}`.trim().toLowerCase()
|
|
176
|
+
let date
|
|
177
|
+
|
|
178
|
+
if(text === 'now') date = dayjs()
|
|
179
|
+
else if(text === 'today') date = dayjs().startOf('day')
|
|
180
|
+
else if(text === 'yesterday') date = dayjs().subtract(1, 'day').startOf('day')
|
|
181
|
+
else{
|
|
182
|
+
const relative = text.match(/^(now|today)?\s*([+-])\s*(\d+)\s*([a-z]+?)s?$/)
|
|
183
|
+
if(relative && RELATIVE_UNITS[relative[4]]){
|
|
184
|
+
const [ , base, sign, amount ] = relative
|
|
185
|
+
date = (base === 'today' ? dayjs().startOf('day') : dayjs()).add((sign === '-' ? -1 : 1) * amount, RELATIVE_UNITS[relative[4]])
|
|
186
|
+
}
|
|
187
|
+
else if(typeof value === 'number' || /^\d{4}-\d{2}(-\d{2})?([ t]\d{2}:\d{2}(:\d{2})?)?/.test(text))
|
|
188
|
+
date = dayjs(value)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if(!date?.isValid())
|
|
192
|
+
fail('filters', `Invalid date for ${field}: "${value}". Use YYYY-MM-DD [HH:mm:ss], today, yesterday, now or a relative value like -7d / now-1 month`)
|
|
193
|
+
|
|
194
|
+
return date.format('YYYY-MM-DD HH:mm:ss')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const isBlank = (value) => value === undefined || value === null || `${value}`.trim() === ''
|
|
198
|
+
|
|
199
|
+
const listOf = (value) => (Array.isArray(value) ? value : `${value ?? ''}`.split(',')).map(_ => `${_}`.trim()).filter(_ => _ !== '')
|
|
200
|
+
|
|
201
|
+
const COMPARE = { '=': Op.eq, '!=': Op.ne, '>': Op.gt, '>=': Op.gte, '<': Op.lt, '<=': Op.lte, 'in': Op.in, 'notIn': Op.notIn }
|
|
202
|
+
const LISTS = [ 'in', 'notIn' ]
|
|
203
|
+
|
|
204
|
+
const filtersToWhere = (datasource, filters = []) => {
|
|
205
|
+
if(!Array.isArray(filters)) fail('filters', 'filters must be an array')
|
|
206
|
+
const Model = models[datasource.model]
|
|
207
|
+
|
|
208
|
+
const and = filters.map(({ field, op = '=', value, dateModifier } = {}) => {
|
|
209
|
+
const definition = fieldOf(datasource, field, 'filters')
|
|
210
|
+
const { type } = definition
|
|
211
|
+
const key = whereKeyOf(Model, definition)
|
|
212
|
+
// A count / subquery field is an expression, not a column key
|
|
213
|
+
const where = (operator, value) => definition.count || definition.subquery ?
|
|
214
|
+
whereOf(refOf(Model, definition), { [operator]: value }) : { [key]: { [operator]: value } }
|
|
215
|
+
if(!OPS.includes(op)) fail('filters', `Unknown operator: ${op}`)
|
|
216
|
+
|
|
217
|
+
if(op === 'empty'){
|
|
218
|
+
const empty = type === 'string' ? { [Op.or]: [ where(Op.is, null), where(Op.eq, '') ] } : where(Op.is, null)
|
|
219
|
+
return value === false || value === 'false' ? { [Op.not]: empty } : empty
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if(isBlank(value) || (LISTS.includes(op) && !listOf(value).length)) return null
|
|
223
|
+
if(op === 'notBetween' && !(Array.isArray(value) && value.length === 2)) fail('filters', `notBetween needs [ from, to ] for ${field}`)
|
|
224
|
+
|
|
225
|
+
if(dateModifier){
|
|
226
|
+
if(!RECURRING[dateModifier]) fail('filters', `Unknown filter dateModifier: ${dateModifier}`)
|
|
227
|
+
if(type !== 'date') fail('filters', `dateModifier needs a date field, ${field} is ${type}`)
|
|
228
|
+
if(!COMPARE[op]) fail('filters', `${op} cannot be used with dateModifier`)
|
|
229
|
+
return whereOf(fn(RECURRING[dateModifier], refOf(Model, definition)), { [COMPARE[op]]: LISTS.includes(op) ? listOf(value) : value })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if(type === 'date' && ![ 'contains', 'startsWith', 'endsWith' ].includes(op)){
|
|
233
|
+
value = LISTS.includes(op) ? listOf(value).map(_ => toDate(field, _))
|
|
234
|
+
: op === 'notBetween' ? value.map(_ => toDate(field, _))
|
|
235
|
+
: toDate(field, value)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
switch(op){
|
|
239
|
+
case 'contains': return where(Op.substring, `${value}`)
|
|
240
|
+
case 'startsWith': return where(Op.startsWith, `${value}`)
|
|
241
|
+
case 'endsWith': return where(Op.endsWith, `${value}`)
|
|
242
|
+
case 'in': return where(Op.in, listOf(value))
|
|
243
|
+
case 'notIn': return where(Op.notIn, listOf(value))
|
|
244
|
+
case 'notBetween': return where(Op.notBetween, value)
|
|
245
|
+
default: return where(COMPARE[op], value)
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const conditions = and.filter(Boolean)
|
|
250
|
+
return conditions.length ? { [Op.and]: conditions } : {}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const aggregateExpr = (datasource, Model, aggregation = 'count', field) => {
|
|
254
|
+
if(!datasource.aggregations.includes(aggregation)) fail('aggregation', `Unknown aggregation: ${aggregation}`)
|
|
255
|
+
|
|
256
|
+
if(aggregation === 'count')
|
|
257
|
+
return field ? fn('COUNT', refOf(Model, fieldOf(datasource, field))) : fn('COUNT', literal('*'))
|
|
258
|
+
|
|
259
|
+
if(!field) fail('field', `${aggregation} needs a field`)
|
|
260
|
+
const definition = fieldOf(datasource, field)
|
|
261
|
+
if(aggregation === 'countDistinct') return fn('COUNT', fn('DISTINCT', refOf(Model, definition)))
|
|
262
|
+
const { key, type } = definition
|
|
263
|
+
if((aggregation === 'sum' || aggregation === 'avg') && type !== 'number')
|
|
264
|
+
fail('field', `${aggregation} needs a number field, ${key} is ${type}`)
|
|
265
|
+
if(type === 'string') fail('field', `${aggregation} needs a number or date field, ${key} is a string`)
|
|
266
|
+
|
|
267
|
+
return fn(aggregation.toUpperCase(), refOf(Model, definition))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const toNumber = (value) => value === null || value === undefined || value instanceof Date || isNaN(Number(value)) ? value : Number(value)
|
|
271
|
+
|
|
272
|
+
const metric = async (datasource, Model, query) => {
|
|
273
|
+
const { aggregation, field, filters, secondary } = query
|
|
274
|
+
const where = filtersToWhere(datasource, filters)
|
|
275
|
+
|
|
276
|
+
const attributes = [ [ aggregateExpr(datasource, Model, aggregation, field), 'value' ] ]
|
|
277
|
+
if(secondary)
|
|
278
|
+
attributes.push([ aggregateExpr(datasource, Model, secondary.aggregation, secondary.field), 'secondary' ])
|
|
279
|
+
|
|
280
|
+
const row = await Model.findOne({ attributes, where, include: joinsFor(datasource, queryKeys(query)), subQuery: false, raw: true })
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
value: toNumber(row?.value ?? 0),
|
|
284
|
+
...(secondary ? { secondary: { value: toNumber(row?.secondary ?? 0) } } : {})
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const groupLabel = (Model, group, dateModifier, bucketSize) => {
|
|
289
|
+
const column = refOf(Model, group)
|
|
290
|
+
|
|
291
|
+
if(dateModifier){
|
|
292
|
+
if(!DATE_MODIFIERS.includes(dateModifier)) fail('dateModifier', `Unknown dateModifier: ${dateModifier}`)
|
|
293
|
+
if(group.type !== 'date') fail('dateModifier', `dateModifier needs a date groupBy, ${group.key} is ${group.type}`)
|
|
294
|
+
if(RECURRING[dateModifier]) return fn(RECURRING[dateModifier], column)
|
|
295
|
+
if(dateModifier === 'quarter') return fn('CONCAT', fn('YEAR', column), '-Q', fn('QUARTER', column))
|
|
296
|
+
return fn('DATE_FORMAT', column, DATE_FORMATS[dateModifier])
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if(!isBlank(bucketSize)){
|
|
300
|
+
const size = Number(bucketSize)
|
|
301
|
+
if(!(size > 0)) fail('bucketSize', `bucketSize must be a positive number`)
|
|
302
|
+
if(group.type !== 'number') fail('bucketSize', `bucketSize needs a number groupBy, ${group.key} is ${group.type}`)
|
|
303
|
+
const sql = column.val ?? Model.sequelize.getQueryInterface().quoteIdentifiers(column.col)
|
|
304
|
+
return literal(`FLOOR(${sql} / ${size}) * ${size}`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return group.type === 'date' ? fn('DATE_FORMAT', column, '%Y-%m-%d %H:%i:%s') : column
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const chart = async (datasource, Model, query) => {
|
|
311
|
+
const { aggregation, field, groupBy, dateModifier = '', bucketSize, limit, filters } = query
|
|
312
|
+
if(!groupBy) fail('groupBy', 'chart needs groupBy')
|
|
313
|
+
|
|
314
|
+
const group = fieldOf(datasource, groupBy, 'groupBy')
|
|
315
|
+
const label = groupLabel(Model, group, dateModifier, bucketSize)
|
|
316
|
+
const ascending = !!dateModifier || !isBlank(bucketSize)
|
|
317
|
+
|
|
318
|
+
const rows = await Model.findAll({
|
|
319
|
+
attributes: [ [ label, 'label' ], [ aggregateExpr(datasource, Model, aggregation, field), 'value' ] ],
|
|
320
|
+
where: filtersToWhere(datasource, filters),
|
|
321
|
+
include: joinsFor(datasource, queryKeys(query)),
|
|
322
|
+
group: [ literal('label') ],
|
|
323
|
+
order: ascending ? [ [ literal('label'), 'ASC' ] ] : [ [ literal('value'), 'DESC' ] ],
|
|
324
|
+
limit: Math.min(Math.max(parseInt(limit) || MAX_CHART_LIMIT, 1), MAX_CHART_LIMIT),
|
|
325
|
+
subQuery: false,
|
|
326
|
+
raw: true
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
return { series: rows.map(row => ({ label: row.label, value: toNumber(row.value) })) }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const table = async (datasource, Model, query) => {
|
|
333
|
+
const { columns, order = [], page = 0, pageSize = 20, filters } = query
|
|
334
|
+
if(columns !== undefined && columns !== null && !Array.isArray(columns)) fail('columns', 'columns must be an array')
|
|
335
|
+
|
|
336
|
+
const cols = (columns?.length ? columns : datasource.fields).map(column => {
|
|
337
|
+
const key = typeof column === 'string' ? column : column?.key
|
|
338
|
+
const field = fieldOf(datasource, key, 'columns')
|
|
339
|
+
return { key: field.key, label: (typeof column === 'object' && column.label) || field.label, field }
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
if(!Array.isArray(order)) fail('order', 'order must be an array')
|
|
343
|
+
const sort = order.map(([ key, dir = 'asc' ] = []) => {
|
|
344
|
+
const field = fieldOf(datasource, key, 'order')
|
|
345
|
+
if(![ 'asc', 'desc' ].includes(`${dir}`.toLowerCase())) fail('order', `Unknown direction: ${dir}`)
|
|
346
|
+
return [ refOf(Model, field), `${dir}`.toUpperCase() ]
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
const size = Math.min(Math.max(parseInt(pageSize) || 20, 1), MAX_PAGE_SIZE)
|
|
350
|
+
const offset = Math.max(parseInt(page) || 0, 0) * size
|
|
351
|
+
|
|
352
|
+
const rows = await Model.findAll({
|
|
353
|
+
attributes: cols.map(_ => [ refOf(Model, _.field), _.key ]),
|
|
354
|
+
where: filtersToWhere(datasource, filters),
|
|
355
|
+
include: joinsFor(datasource, [ ...queryKeys({ filters, order }), ...cols.map(_ => _.key) ]),
|
|
356
|
+
order: sort.length ? sort : [ [ refOf(Model, fieldOf(datasource, 'id')), 'DESC' ] ],
|
|
357
|
+
limit: size + 1,
|
|
358
|
+
offset,
|
|
359
|
+
subQuery: false,
|
|
360
|
+
raw: true
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
columns: cols.map(({ key, label }) => ({ key, label })),
|
|
365
|
+
rows: rows.slice(0, size),
|
|
366
|
+
hasNext: rows.length > size
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// A pivot dimension (row or column), read like a chart's groupBy
|
|
371
|
+
const pivotDimension = (datasource, Model, name) => (dimension) => {
|
|
372
|
+
const field = fieldOf(datasource, dimension?.field, `pivot.${name}`)
|
|
373
|
+
if(sourceOf(Model, field).path.some((_, i, path) => !oneRowPer(Model, path.slice(0, i + 1))))
|
|
374
|
+
fail(`pivot.${name}`, `${field.key} cannot be used in a pivot, its relation can match several rows`)
|
|
375
|
+
return { field, expr: groupLabel(Model, field, dimension.dateModifier ?? '', dimension.bucketSize) }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Whether joining this association path keeps one row per record: belongs-to a primary or unique key, or has-one
|
|
379
|
+
const oneRowPer = (Model, path) => {
|
|
380
|
+
let Source = Model
|
|
381
|
+
for(const segment of path){
|
|
382
|
+
const association = Source.associations[segment]
|
|
383
|
+
const { targetKey, target } = association
|
|
384
|
+
const toUniqueKey = association.associationType === 'BelongsTo' &&
|
|
385
|
+
(targetKey === target.primaryKeyAttribute || !!target.rawAttributes[targetKey]?.unique)
|
|
386
|
+
if(!toUniqueKey && association.associationType !== 'HasOne') return false
|
|
387
|
+
Source = association.target
|
|
388
|
+
}
|
|
389
|
+
return true
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const pivotKey = (value) => value === null || value === undefined ? '' : `${value}`
|
|
393
|
+
|
|
394
|
+
// One grouped query per subtotal level, so every subtotal and grand total (avg / min / max included)
|
|
395
|
+
// comes from the database. Level { r, c } groups by the first r row fields and by all column fields (c > 0) or none.
|
|
396
|
+
const pivot = async (datasource, Model, query) => {
|
|
397
|
+
const { pivot: spec, filters } = query
|
|
398
|
+
if(!spec || typeof spec !== 'object' || Array.isArray(spec)) fail('pivot', 'pivot needs { rows, columns, values }')
|
|
399
|
+
|
|
400
|
+
const listIn = (name, max) => {
|
|
401
|
+
const list = spec[name] ?? []
|
|
402
|
+
if(!Array.isArray(list)) fail(`pivot.${name}`, `pivot.${name} must be an array`)
|
|
403
|
+
if(list.length > max) fail(`pivot.${name}`, `pivot.${name} allows at most ${max}`)
|
|
404
|
+
return list
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const rows = listIn('rows', MAX_PIVOT_DIMENSIONS).map(pivotDimension(datasource, Model, 'rows'))
|
|
408
|
+
const columns = listIn('columns', MAX_PIVOT_DIMENSIONS).map(pivotDimension(datasource, Model, 'columns'))
|
|
409
|
+
const values = listIn('values', MAX_PIVOT_VALUES)
|
|
410
|
+
const measures = (values.length ? values : [ { aggregation: 'count' } ])
|
|
411
|
+
.map(value => ({ field: value?.field, expr: aggregateExpr(datasource, Model, value?.aggregation ?? 'count', value?.field) }))
|
|
412
|
+
|
|
413
|
+
const where = filtersToWhere(datasource, filters)
|
|
414
|
+
const filterKeys = queryKeys({ filters })
|
|
415
|
+
|
|
416
|
+
const levels = []
|
|
417
|
+
for(let r = 0; r <= rows.length; r++)
|
|
418
|
+
for(const c of columns.length ? [ 0, columns.length ] : [ 0 ])
|
|
419
|
+
levels.push({ r, c, dimensions: [ ...rows.slice(0, r), ...columns.slice(0, c) ] })
|
|
420
|
+
|
|
421
|
+
const results = await Promise.all(levels.map(({ dimensions }) => Model.findAll({
|
|
422
|
+
attributes: [
|
|
423
|
+
...dimensions.map((_, i) => [ _.expr, `k${i}` ]),
|
|
424
|
+
...measures.map((_, i) => [ _.expr, `v${i}` ])
|
|
425
|
+
],
|
|
426
|
+
where,
|
|
427
|
+
include: joinsFor(datasource, [ ...dimensions.map(_ => _.field.key), ...measures.map(_ => _.field), ...filterKeys ]),
|
|
428
|
+
...(dimensions.length ? { group: dimensions.map((_, i) => literal(`k${i}`)) } : {}),
|
|
429
|
+
limit: MAX_PIVOT_GROUPS + 1,
|
|
430
|
+
subQuery: false,
|
|
431
|
+
raw: true
|
|
432
|
+
})))
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
pivot: {
|
|
436
|
+
levels: levels.map(({ r, c, dimensions }, i) => ({
|
|
437
|
+
r, c,
|
|
438
|
+
rows: results[i].slice(0, MAX_PIVOT_GROUPS).map(row => [
|
|
439
|
+
...dimensions.map((_, k) => pivotKey(row[`k${k}`])),
|
|
440
|
+
...measures.map((_, v) => toNumber(row[`v${v}`] ?? null))
|
|
441
|
+
])
|
|
442
|
+
})),
|
|
443
|
+
truncated: results.some(_ => _.length > MAX_PIVOT_GROUPS)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const runQuery = async ({ datasource: key, type, ...query } = {}, user) => {
|
|
449
|
+
if(!TYPES.includes(type)) fail('type', `type must be one of ${TYPES.join(', ')}`)
|
|
450
|
+
|
|
451
|
+
const datasource = datasourceFor(key, user)
|
|
452
|
+
const Model = models[datasource.model]
|
|
453
|
+
|
|
454
|
+
switch(type){
|
|
455
|
+
case 'metric': return metric(datasource, Model, query)
|
|
456
|
+
case 'chart': return chart(datasource, Model, query)
|
|
457
|
+
case 'table': return table(datasource, Model, query)
|
|
458
|
+
case 'pivot': return pivot(datasource, Model, query)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return { runQuery, filtersToWhere }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
module.exports = { createQueryEngine, TYPES, OPS, DATE_MODIFIERS }
|
|
@@ -52,10 +52,33 @@ function resolveDateToken(value, now = new Date()) {
|
|
|
52
52
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// The filter editor's date ops, lowered to the plain ops every query engine runs:
|
|
56
|
+
// between / exceptBetween "YYYY-MM-DD..YYYY-MM-DD", both days included
|
|
57
|
+
// except one day (a date or a token), the whole day left out
|
|
58
|
+
// weekday / monthOfYear "0,4" — WEEKDAY() numbers (Monday = 0) / MONTH() numbers (January = 1)
|
|
55
59
|
function resolveFilters(filters, now = new Date()) {
|
|
56
60
|
if (!Array.isArray(filters)) return filters
|
|
61
|
+
const day = (value) => `${resolveDateToken(value, now) ?? ''}`.slice(0, 10)
|
|
62
|
+
const nextDay = (date) => resolveDateToken('@today+1d', new Date(`${date}T00:00:00`))
|
|
57
63
|
return filters.flatMap(filter => {
|
|
58
|
-
|
|
64
|
+
const { field, op, value } = filter ?? {}
|
|
65
|
+
if (op === 'weekday' || op === 'monthOfYear') {
|
|
66
|
+
const list = `${value ?? ''}`.split(',').map(v => v.trim()).filter(Boolean)
|
|
67
|
+
return list.length ? [ { field, op: 'in', value: list.join(','), dateModifier: op === 'weekday' ? 'dayOfWeek' : 'monthOfYear' } ] : []
|
|
68
|
+
}
|
|
69
|
+
if (op === 'except') {
|
|
70
|
+
const date = day(value)
|
|
71
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? [ { field, op: 'notBetween', value: [ `${date} 00:00:00`, `${date} 23:59:59` ] } ] : []
|
|
72
|
+
}
|
|
73
|
+
if (op === 'between' || op === 'exceptBetween') {
|
|
74
|
+
const range = `${value ?? ''}`.split('..').map(day)
|
|
75
|
+
if (range.length !== 2 || !range.every(date => /^\d{4}-\d{2}-\d{2}$/.test(date))) return []
|
|
76
|
+
const [ from, to ] = range[0] <= range[1] ? range : [ range[1], range[0] ]
|
|
77
|
+
return op === 'between'
|
|
78
|
+
? [ { field, op: '>=', value: `${from} 00:00:00` }, { field, op: '<', value: nextDay(to) } ]
|
|
79
|
+
: [ { field, op: 'notBetween', value: [ `${from} 00:00:00`, `${to} 23:59:59` ] } ]
|
|
80
|
+
}
|
|
81
|
+
if (op !== 'period') return [ { ...filter, value: resolveDateToken(value, now) } ]
|
|
59
82
|
const named = { today: [ '@today', '@tomorrow' ], yesterday: [ '@yesterday', '@today' ], thisWeek: [ '@startOfWeek', '@startOfWeek+1w' ], lastWeek: [ '@startOfWeek-1w', '@startOfWeek' ], thisMonth: [ '@startOfMonth', '@startOfMonth+1m' ], lastMonth: [ '@startOfMonth-1m', '@startOfMonth' ] }[filter.value]
|
|
60
83
|
const custom = `${filter.value ?? ''}`.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/)
|
|
61
84
|
if (!named && !custom) return []
|
|
@@ -66,6 +89,9 @@ function resolveFilters(filters, now = new Date()) {
|
|
|
66
89
|
})
|
|
67
90
|
}
|
|
68
91
|
|
|
92
|
+
// Pie, doughnut and polar area slice by exact value: a saved `bucketSize` is ignored on them.
|
|
93
|
+
const ROUND_CHARTS = [ 'pie', 'doughnut', 'polarArea' ]
|
|
94
|
+
|
|
69
95
|
function bucketFilters(query, bucket) {
|
|
70
96
|
if (bucket == null || bucket === '' || !query?.groupBy) return []
|
|
71
97
|
const label = `${bucket}`
|
|
@@ -127,7 +153,7 @@ const AIDashboardMixin = {
|
|
|
127
153
|
try{
|
|
128
154
|
const filters = resolveFilters(copy.query.filters, now)
|
|
129
155
|
const { extraFields, fieldLabel, weekOfMonth, detail, ...base } = copy.query
|
|
130
|
-
const query = { ...base, filters }
|
|
156
|
+
const query = { ...base, filters, ...(ROUND_CHARTS.includes(copy.chart) ? { bucketSize: undefined } : {}) }
|
|
131
157
|
let data = copy.type === 'chart' && query.latField && query.lngField
|
|
132
158
|
? await this.analyzeMapNode(query)
|
|
133
159
|
: await this.analyzeNode({ node: { ...copy, query } })
|
|
@@ -148,11 +174,14 @@ const AIDashboardMixin = {
|
|
|
148
174
|
.map(row => ({ label: row.label == null || `${row.label}` === 'null' ? '' : `${row.label}`, value: Number(row.value) || 0 }))
|
|
149
175
|
.sort((a, b) => b.value - a.value)
|
|
150
176
|
const additive = [ 'count', 'sum', undefined, '' ].includes(query.aggregation)
|
|
151
|
-
const
|
|
177
|
+
const limit = Math.min(20, Math.max(1, parseInt(query.breakdownLimit) || 4))
|
|
178
|
+
const showOther = additive && query.breakdownOther !== false
|
|
179
|
+
// With Other on, one leftover value is shown itself rather than as an "Other" of one.
|
|
180
|
+
const top = showOther && rows.length === limit + 1 ? rows : rows.slice(0, limit)
|
|
152
181
|
data.breakdown = {
|
|
153
182
|
field: query.breakdownBy,
|
|
154
183
|
rows: top,
|
|
155
|
-
other:
|
|
184
|
+
other: showOther && rows.length > top.length ? rows.slice(top.length).reduce((sum, row) => sum + row.value, 0) : null,
|
|
156
185
|
total: additive ? rows.reduce((sum, row) => sum + row.value, 0) : null,
|
|
157
186
|
}
|
|
158
187
|
}
|
|
@@ -173,11 +202,11 @@ const AIDashboardMixin = {
|
|
|
173
202
|
},
|
|
174
203
|
|
|
175
204
|
async loadBreakdown({ node, bucket, cell, value, page = 0 } = {}) {
|
|
176
|
-
const { splitBy, splitLimit, splitOther, weekOfMonth, breakdownBy, extraFields, fieldLabel, pivot, detail, ...query } = node?.query ?? {}
|
|
205
|
+
const { splitBy, splitLimit, splitOther, groupOther, weekOfMonth, breakdownBy, breakdownLimit, breakdownOther, extraFields, fieldLabel, pivot, detail, ...query } = node?.query ?? {}
|
|
177
206
|
const field = breakdownBy || splitBy || null
|
|
178
207
|
const filters = [
|
|
179
208
|
...(resolveFilters(query.filters, new Date()) ?? []),
|
|
180
|
-
...(node.type === 'chart' ? bucketFilters(query, bucket) : []),
|
|
209
|
+
...(node.type === 'chart' ? bucketFilters(ROUND_CHARTS.includes(node.chart) ? { ...query, bucketSize: undefined } : query, bucket) : []),
|
|
181
210
|
...(Array.isArray(cell) ? cell : []).flatMap(dim => `${dim?.value ?? ''}` === ''
|
|
182
211
|
? [ { field: dim.field, op: 'empty', value: true } ]
|
|
183
212
|
: bucketFilters({ groupBy: dim.field, dateModifier: dim.dateModifier, bucketSize: dim.bucketSize }, dim.value)),
|
|
@@ -308,8 +337,9 @@ const AIDashboardMixin = {
|
|
|
308
337
|
},
|
|
309
338
|
|
|
310
339
|
// ── Filters → Sequelize `where` ─────────────────────────────────────────────
|
|
311
|
-
// A filter row is { field, op, value, dateModifier? }; op ∈ = != > >= < <= contains
|
|
312
|
-
// Rows with no field or no value are dropped, `in`
|
|
340
|
+
// A filter row is { field, op, value, dateModifier? }; op ∈ = != > >= < <= contains startsWith
|
|
341
|
+
// endsWith in notIn notBetween empty. Rows with no field or no value are dropped, `in` / `notIn`
|
|
342
|
+
// are comma-split, `notBetween` takes [ from, to ] (resolveFilters writes it for the date ops).
|
|
313
343
|
|
|
314
344
|
filtersToWhere(scope, filters) {
|
|
315
345
|
const { Op } = this.Sequelize
|
|
@@ -326,7 +356,13 @@ const AIDashboardMixin = {
|
|
|
326
356
|
? { [Op.or]: [ { [Op.is]: null }, { [Op.eq]: '' } ] }
|
|
327
357
|
: { [Op.is]: null }
|
|
328
358
|
else if (filter.op === 'contains') condition = { [Op.like]: `%${filter.value}%` }
|
|
329
|
-
else if (filter.op === '
|
|
359
|
+
else if (filter.op === 'startsWith') condition = { [Op.startsWith]: `${filter.value}` }
|
|
360
|
+
else if (filter.op === 'endsWith') condition = { [Op.endsWith]: `${filter.value}` }
|
|
361
|
+
else if (filter.op === 'in' || filter.op === 'notIn') {
|
|
362
|
+
const list = `${filter.value}`.split(',').map(v => v.trim()).filter(Boolean)
|
|
363
|
+
if (list.length) condition = { [filter.op === 'in' ? Op.in : Op.notIn]: list }
|
|
364
|
+
}
|
|
365
|
+
else if (filter.op === 'notBetween' && Array.isArray(filter.value) && filter.value.length === 2) condition = { [Op.notBetween]: filter.value }
|
|
330
366
|
else if (ops[filter.op]) condition = { [ops[filter.op]]: filter.value }
|
|
331
367
|
const target = (filter.dateModifier && bucketExpr(this.conn.fn, field.expr, filter.dateModifier)) || (field.sql ? field.expr : null)
|
|
332
368
|
if (condition) and.push(target ? this.Sequelize.where(target, condition) : { [field.where]: condition })
|
|
@@ -446,7 +482,7 @@ const AIDashboardMixin = {
|
|
|
446
482
|
},
|
|
447
483
|
|
|
448
484
|
async nestChart(node, run) {
|
|
449
|
-
const { splitBy, splitLimit, splitOther, ...query } = node.query ?? {}
|
|
485
|
+
const { splitBy, splitLimit, splitOther, groupOther, ...query } = node.query ?? {}
|
|
450
486
|
const plain = { ...node, query: { ...query, dateModifier: '', bucketSize: undefined } }
|
|
451
487
|
if (![ 'count', 'sum', undefined, '' ].includes(query.aggregation)) return run(plain)
|
|
452
488
|
const limit = Math.min(20, Math.max(1, parseInt(splitLimit) || 7))
|
|
@@ -455,9 +491,9 @@ const AIDashboardMixin = {
|
|
|
455
491
|
const rows = ((await run(plain))?.series ?? [])
|
|
456
492
|
.map(row => ({ label: named(row.label) ? `${row.label}` : '', value: Number(row.value) || 0 }))
|
|
457
493
|
.sort((a, b) => b.value - a.value)
|
|
458
|
-
const top = rows.length > 8 ? rows.slice(0, 7) : rows
|
|
494
|
+
const top = rows.length > 8 ? rows.slice(0, groupOther === false ? 8 : 7) : rows
|
|
459
495
|
const rest = rows.slice(top.length)
|
|
460
|
-
const series = [ ...top, ...(rest.length ? [ { label: 'Other', value: rest.reduce((sum, row) => sum + row.value, 0), other: true } ] : []) ]
|
|
496
|
+
const series = [ ...top, ...(rest.length && groupOther !== false ? [ { label: 'Other', value: rest.reduce((sum, row) => sum + row.value, 0), other: true } ] : []) ]
|
|
461
497
|
|
|
462
498
|
const nested = {}
|
|
463
499
|
await Promise.all(top.map(async row => {
|