@mixd-id/web-scaffold 0.1.240411124 → 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/AIDashboard.vue +77 -32
- 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/components/AIDashboardViews.vue +1 -1
- 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"
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
<!-- Host controls left of the title, e.g. a "show dashboards" toggle -->
|
|
12
12
|
<slot name="before-title" />
|
|
13
13
|
<div class="flex-1 min-w-0">
|
|
14
|
-
<
|
|
14
|
+
<div class="flex flex-row items-center gap-2 min-w-0">
|
|
15
|
+
<h3 class="truncate" @click="log(activeItem)">{{ activeItem ? activeItem.name : 'Dashboard' }}</h3>
|
|
16
|
+
<span v-if="readonly && activeItem" class="shrink-0 text-[11px] px-2 py-0.5 rounded-full bg-text-50 text-text-400">View only</span>
|
|
17
|
+
</div>
|
|
15
18
|
<p v-if="activeItem?.props?.description" class="mt-1 text-sm text-text-300 line-clamp-2 max-w-[420px]">{{ activeItem.props.description }}</p>
|
|
16
19
|
</div>
|
|
17
20
|
</div>
|
|
@@ -81,14 +84,7 @@
|
|
|
81
84
|
|
|
82
85
|
<div class="border-b-[1px] border-text-50 flex flex-row gap-4 items-start">
|
|
83
86
|
<div class="flex-1 min-w-0 overflow-x-auto px-3 pt-3" :class="mobile ? 'px-6' : 'pt-6'">
|
|
84
|
-
<Tabs :items="
|
|
85
|
-
{ text: 'Views', value: 'views' },
|
|
86
|
-
{ text: 'Filters', value: 'filters' },
|
|
87
|
-
{ text: 'Datasource', value: 'datasource' },
|
|
88
|
-
{ text: 'AI Assistant', value: 'chat' },
|
|
89
|
-
...(connected ? [ { text: 'Sharing', value: 'sharing' } ] : []),
|
|
90
|
-
{ text: 'Settings', value: 'settings' },
|
|
91
|
-
]" v-model="config.params.rightTab" />
|
|
87
|
+
<Tabs :items="tabs" v-model="config.params.rightTab" />
|
|
92
88
|
</div>
|
|
93
89
|
<div v-if="mobile" class="shrink-0 flex flex-row items-center">
|
|
94
90
|
<button type="button" class="py-5 px-2 text-text-300 hover:text-primary" :title="mobilePanelTall ? 'Shrink panel' : 'Expand panel'"
|
|
@@ -280,19 +276,19 @@
|
|
|
280
276
|
<div v-else-if="sharing.error && !sharing.owner" class="text-red-600 text-sm text-center py-6">{{ sharing.error }}</div>
|
|
281
277
|
<template v-else>
|
|
282
278
|
<div v-if="sharing.canManage" class="flex flex-col gap-1">
|
|
283
|
-
<Textbox v-model="sharing.search" placeholder="Add people by name or
|
|
279
|
+
<Textbox v-model="sharing.search" placeholder="Add people by name or code" />
|
|
284
280
|
<div v-if="sharing.search.trim()" class="rounded-lg border-[1px] border-text-50 bg-base-400 p-1 flex flex-col">
|
|
281
|
+
<div v-if="sharing.search.trim().length < SEARCH_MIN_LENGTH" class="text-text-400 text-xs p-2">Type at least {{ SEARCH_MIN_LENGTH }} characters.</div>
|
|
282
|
+
<template v-else>
|
|
285
283
|
<button v-for="user in sharing.results.filter(u => u.id !== sharing.owner?.id && !sharing.users.some(s => s.id === u.id))" :key="user.id"
|
|
286
|
-
type="button" class="flex flex-row items-center gap-2
|
|
284
|
+
type="button" class="flex flex-row items-center gap-2 px-2 py-1.5 rounded-md text-left hover:bg-base-500"
|
|
287
285
|
@click="shareWith(user)">
|
|
288
|
-
<div class="w-[
|
|
289
|
-
<div class="flex-1 min-w-0">
|
|
290
|
-
<div class="text-sm truncate">{{ user.name }}</div>
|
|
291
|
-
<div class="text-[11px] text-text-400 truncate">{{ [ user.type, user.email ].filter(Boolean).join(' · ') }}</div>
|
|
292
|
-
</div>
|
|
286
|
+
<div class="w-[22px] h-[22px] rounded-full bg-base-500 text-[11px] flex items-center justify-center shrink-0">{{ (user.name || '?').slice(0, 1).toUpperCase() }}</div>
|
|
287
|
+
<div class="flex-1 min-w-0 text-sm truncate">{{ user.name }}</div>
|
|
293
288
|
<span class="text-xs text-primary shrink-0">Share</span>
|
|
294
289
|
</button>
|
|
295
290
|
<div v-if="!sharing.results.some(u => u.id !== sharing.owner?.id && !sharing.users.some(s => s.id === u.id))" class="text-text-400 text-xs p-2">No one found.</div>
|
|
291
|
+
</template>
|
|
296
292
|
</div>
|
|
297
293
|
<div v-if="sharing.error" class="text-red-600 text-xs">{{ sharing.error }}</div>
|
|
298
294
|
</div>
|
|
@@ -300,16 +296,21 @@
|
|
|
300
296
|
<div class="flex flex-col gap-1">
|
|
301
297
|
<label class="text-xs text-text-400">Who has access</label>
|
|
302
298
|
<div v-for="user in [ ...(sharing.owner ? [ sharing.owner ] : []), ...sharing.users ]" :key="user.id"
|
|
303
|
-
class="flex flex-row items-center gap-2
|
|
304
|
-
<div class="w-[
|
|
305
|
-
<div class="flex-1 min-w-0">
|
|
306
|
-
<div class="text-sm truncate">{{ user.name }}</div>
|
|
307
|
-
<div class="text-[11px] text-text-400 truncate">{{ [ user.type, user.email ].filter(Boolean).join(' · ') }}</div>
|
|
308
|
-
</div>
|
|
299
|
+
class="flex flex-row items-center gap-2 px-2 py-1 rounded-lg bg-base-400">
|
|
300
|
+
<div class="w-[22px] h-[22px] rounded-full bg-base-500 text-[11px] flex items-center justify-center shrink-0">{{ (user.name || '?').slice(0, 1).toUpperCase() }}</div>
|
|
301
|
+
<div class="flex-1 min-w-0 text-sm truncate">{{ user.name }}</div>
|
|
309
302
|
<span v-if="user.id === sharing.owner?.id" class="text-[11px] text-text-400 shrink-0">Owner</span>
|
|
310
|
-
|
|
311
|
-
|
|
303
|
+
<!-- Access as an icon; the owner clicks it for a menu to change or remove it -->
|
|
304
|
+
<button v-else-if="sharing.canManage" type="button"
|
|
305
|
+
class="shrink-0 flex flex-row items-center gap-0.5 p-1.5 rounded-md text-text-400 hover:bg-base-500 hover:text-text-600"
|
|
306
|
+
v-tooltip.bottom="shareAccess(user.access).text"
|
|
307
|
+
@click="accessMenu.open($event.currentTarget, { user })">
|
|
308
|
+
<svg width="14" height="14" class="fill-current" xmlns="http://www.w3.org/2000/svg" :viewBox="shareAccess(user.access).box"><path :d="shareAccess(user.access).path"/></svg>
|
|
309
|
+
<svg width="8" height="8" class="fill-current opacity-70" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"/></svg>
|
|
312
310
|
</button>
|
|
311
|
+
<span v-else class="shrink-0 p-1.5 text-text-400" v-tooltip.bottom="shareAccess(user.access).text">
|
|
312
|
+
<svg width="14" height="14" class="fill-current" xmlns="http://www.w3.org/2000/svg" :viewBox="shareAccess(user.access).box"><path :d="shareAccess(user.access).path"/></svg>
|
|
313
|
+
</span>
|
|
313
314
|
</div>
|
|
314
315
|
<div v-if="!sharing.users.length" class="text-text-400 text-xs px-1 pt-1">Not shared with anyone yet.</div>
|
|
315
316
|
</div>
|
|
@@ -415,6 +416,22 @@
|
|
|
415
416
|
</template>
|
|
416
417
|
</LLMChat>
|
|
417
418
|
|
|
419
|
+
<ContextMenu ref="accessMenu" position="bottom-right">
|
|
420
|
+
<template #default="{ context }">
|
|
421
|
+
<div v-if="context?.user" class="flex flex-col min-w-[160px] p-1 py-2">
|
|
422
|
+
<button v-for="opt in SHARE_ACCESS" :key="opt.value" type="button"
|
|
423
|
+
class="w-full p-2 rounded-md text-left px-3 menu-item flex flex-row items-center gap-2"
|
|
424
|
+
@click="accessMenu.close(); opt.value !== shareAccess(context.user.access).value && shareWith(context.user, opt.value)">
|
|
425
|
+
<svg width="13" height="13" class="fill-text-400 shrink-0" xmlns="http://www.w3.org/2000/svg" :viewBox="opt.box"><path :d="opt.path"/></svg>
|
|
426
|
+
<span class="flex-1">{{ opt.text }}</span>
|
|
427
|
+
<svg v-if="opt.value === shareAccess(context.user.access).value" width="11" height="11" class="fill-primary shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/></svg>
|
|
428
|
+
</button>
|
|
429
|
+
<div class="my-1 border-t-[1px] border-text-50" />
|
|
430
|
+
<button type="button" class="w-full p-2 rounded-md text-left px-3 menu-item text-red-600" @click="accessMenu.close(); unshareWith(context.user)">Remove access</button>
|
|
431
|
+
</div>
|
|
432
|
+
</template>
|
|
433
|
+
</ContextMenu>
|
|
434
|
+
|
|
418
435
|
<ContextMenu ref="viewMenu" position="bottom-right">
|
|
419
436
|
<template #default="{ context }">
|
|
420
437
|
<div class="flex flex-col min-w-[160px] p-1 py-2">
|
|
@@ -770,15 +787,20 @@ import AIDashboardFilterSetting from './AIDashboardFilterSetting.vue'
|
|
|
770
787
|
// `${controller}.load-dashboard` hydrates node.data from datasources and
|
|
771
788
|
// `${controller}.ai-chat` drives the assistant. `datasources` describes the
|
|
772
789
|
// query-builder options: { [key]: { label, fields: [{ key, label }] } }.
|
|
773
|
-
const { controller, socket, config, datasources } = defineProps({
|
|
790
|
+
const { controller, socket, config, datasources, readonly } = defineProps({
|
|
774
791
|
controller: { type: String, default: '' },
|
|
775
792
|
socket: { type: Object, default: null },
|
|
776
793
|
config: { type: Object, required: true },
|
|
777
794
|
datasources: { type: [ Object, Array ], default: () => ({}) },
|
|
795
|
+
// View-only: the dashboard can be looked at, filtered and reloaded, but its
|
|
796
|
+
// components, datasource, assistant and settings are hidden. The host is
|
|
797
|
+
// expected not to save the item while this is set.
|
|
798
|
+
readonly: { type: Boolean, default: false },
|
|
778
799
|
})
|
|
779
800
|
|
|
780
801
|
const addMenu = useTemplateRef('addMenu')
|
|
781
802
|
const viewMenu = useTemplateRef('viewMenu')
|
|
803
|
+
const accessMenu = useTemplateRef('accessMenu')
|
|
782
804
|
const renameCard = useTemplateRef('renameCard')
|
|
783
805
|
const dsCard = useTemplateRef('dsCard')
|
|
784
806
|
const dashboardFilters = useTemplateRef('dashboardFilters')
|
|
@@ -900,6 +922,18 @@ const panelOpen = computed({
|
|
|
900
922
|
if (config.params.inspectorHeight == null) config.params.inspectorHeight = 300
|
|
901
923
|
if (config.params.rightTab === 'data') config.params.rightTab = 'datasource'
|
|
902
924
|
if (![ 'views', 'filters', 'datasource', 'chat', 'sharing', 'settings' ].includes(config.params.rightTab)) config.params.rightTab = 'views'
|
|
925
|
+
|
|
926
|
+
const tabs = computed(() => [
|
|
927
|
+
...(readonly ? [] : [ { text: 'Views', value: 'views' } ]),
|
|
928
|
+
{ text: 'Filters', value: 'filters' },
|
|
929
|
+
...(readonly ? [] : [ { text: 'Datasource', value: 'datasource' }, { text: 'AI Assistant', value: 'chat' } ]),
|
|
930
|
+
...(connected.value ? [ { text: 'Sharing', value: 'sharing' } ] : []),
|
|
931
|
+
...(readonly ? [] : [ { text: 'Settings', value: 'settings' } ]),
|
|
932
|
+
])
|
|
933
|
+
// A view-only dashboard keeps the panel on a tab it still shows.
|
|
934
|
+
watch(() => [ readonly, config.params.rightTab ], () => {
|
|
935
|
+
if (readonly && !tabs.value.some(tab => tab.value === config.params.rightTab)) config.params.rightTab = 'filters'
|
|
936
|
+
}, { immediate: true })
|
|
903
937
|
if (!Array.isArray(config.items)) config.items = []
|
|
904
938
|
config.items.forEach(normalizeItem)
|
|
905
939
|
|
|
@@ -1746,7 +1780,7 @@ function getChatContext() {
|
|
|
1746
1780
|
|
|
1747
1781
|
const chatFocus = computed(() => selectedNode.value)
|
|
1748
1782
|
|
|
1749
|
-
const focusable = computed(() => panelOpen.value && [ 'views', 'chat' ].includes(config.params.rightTab))
|
|
1783
|
+
const focusable = computed(() => !readonly && panelOpen.value && [ 'views', 'chat' ].includes(config.params.rightTab))
|
|
1750
1784
|
|
|
1751
1785
|
function pickFocus(e, node) {
|
|
1752
1786
|
if (!focusable.value || e.target.closest('button, canvas, [data-breakdown] td, [data-map] [data-point], [data-map][data-dragging]')) return
|
|
@@ -1756,7 +1790,7 @@ function pickFocus(e, node) {
|
|
|
1756
1790
|
}
|
|
1757
1791
|
|
|
1758
1792
|
function focusClass(node) {
|
|
1759
|
-
if (!panelOpen.value || ![ 'views', 'chat' ].includes(config.params.rightTab)) return ''
|
|
1793
|
+
if (readonly || !panelOpen.value || ![ 'views', 'chat' ].includes(config.params.rightTab)) return ''
|
|
1760
1794
|
return selectedNode.value === node ? 'cursor-pointer ring-2 ring-primary' : 'cursor-pointer hover:ring-1 hover:ring-text-200'
|
|
1761
1795
|
}
|
|
1762
1796
|
|
|
@@ -1917,12 +1951,12 @@ watch(() => [ activeItem.value?.uid, JSON.stringify((activeItem.value?.props?.fi
|
|
|
1917
1951
|
})
|
|
1918
1952
|
watch(activeUid, () => { selectedNode.value = null; updatedAt.value = null; if (connected.value) hydrateActive() })
|
|
1919
1953
|
|
|
1920
|
-
const sharing = ref({ uid: null, loaded: false, error: null, owner: null, canManage: false, users: [], search: '', results: [] })
|
|
1954
|
+
const sharing = ref({ uid: null, loaded: false, error: null, owner: null, access: null, canManage: false, users: [], search: '', results: [] })
|
|
1921
1955
|
|
|
1922
1956
|
async function loadSharing() {
|
|
1923
1957
|
const uid = activeItem.value?.uid
|
|
1924
1958
|
if (!connected.value || !uid) return
|
|
1925
|
-
if (sharing.value.uid !== uid) sharing.value = { uid, loaded: false, error: null, owner: null, canManage: false, users: [], search: '', results: [] }
|
|
1959
|
+
if (sharing.value.uid !== uid) sharing.value = { uid, loaded: false, error: null, owner: null, access: null, canManage: false, users: [], search: '', results: [] }
|
|
1926
1960
|
try {
|
|
1927
1961
|
const res = await socket.send(`${controller}.load-sharing`, { uid })
|
|
1928
1962
|
if (sharing.value.uid === uid) sharing.value = { ...sharing.value, ...res, loaded: true, error: null }
|
|
@@ -1936,9 +1970,11 @@ watch(() => [ config.params.rightTab, activeItem.value?.uid ], () => {
|
|
|
1936
1970
|
}, { immediate: true })
|
|
1937
1971
|
|
|
1938
1972
|
let searchTimer = null
|
|
1973
|
+
// Matches the server's minimum; shorter input clears the results.
|
|
1974
|
+
const SEARCH_MIN_LENGTH = 3
|
|
1939
1975
|
watch(() => sharing.value.search, (search) => {
|
|
1940
1976
|
clearTimeout(searchTimer)
|
|
1941
|
-
if (
|
|
1977
|
+
if ((search?.trim().length ?? 0) < SEARCH_MIN_LENGTH) { sharing.value.results = []; return }
|
|
1942
1978
|
searchTimer = setTimeout(async () => {
|
|
1943
1979
|
try {
|
|
1944
1980
|
const res = await socket.send(`${controller}.search-users`, { search })
|
|
@@ -1949,10 +1985,19 @@ watch(() => sharing.value.search, (search) => {
|
|
|
1949
1985
|
}, 250)
|
|
1950
1986
|
})
|
|
1951
1987
|
|
|
1952
|
-
|
|
1988
|
+
const SHARE_ACCESS = [
|
|
1989
|
+
{ value: 'view', text: 'Can view', box: '0 0 576 512', path: 'M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z' },
|
|
1990
|
+
{ value: 'edit', text: 'Can edit', box: '0 0 512 512', path: 'M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z' },
|
|
1991
|
+
]
|
|
1992
|
+
// Anything but 'edit' reads as view, as on the server.
|
|
1993
|
+
const shareAccess = (access) => SHARE_ACCESS.find(opt => opt.value === access) ?? SHARE_ACCESS[0]
|
|
1994
|
+
|
|
1995
|
+
// Shares as view-only by default; sharing an existing person again with
|
|
1996
|
+
// another `access` changes theirs.
|
|
1997
|
+
async function shareWith(user, access = 'view') {
|
|
1953
1998
|
const uid = sharing.value.uid
|
|
1954
1999
|
try {
|
|
1955
|
-
const res = await socket.send(`${controller}.share`, { uid, userId: user.id })
|
|
2000
|
+
const res = await socket.send(`${controller}.share`, { uid, userId: user.id, access })
|
|
1956
2001
|
if (sharing.value.uid === uid) sharing.value = { ...sharing.value, ...res, error: null, search: '', results: [] }
|
|
1957
2002
|
} catch (e) {
|
|
1958
2003
|
sharing.value.error = e?.message ?? `${e}`
|
|
@@ -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)
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
<input v-if="editing?.uid === view.uid" :ref="el => el && el.focus()" v-model="editing.name" maxlength="100"
|
|
22
22
|
:class="$style.renameInput" @click.stop @keydown.enter.prevent="commitRename" @keydown.esc.prevent="editing = null" @blur="commitRename" />
|
|
23
23
|
<span v-else class="flex-1 truncate" :title="view.name" @dblclick.stop="!view.shared && startRename(view)">{{ view.name }}</span>
|
|
24
|
-
<svg v-if="view.shared && !grouped" width="12" height="12" class="fill-text-400 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><title>Shared with you</title><path :d="SHARED_PATH"/></svg>
|
|
24
|
+
<svg v-if="view.shared && !grouped" width="12" height="12" class="fill-text-400 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><title>{{ view.access === 'view' ? 'Shared with you · view only' : 'Shared with you' }}</title><path :d="SHARED_PATH"/></svg>
|
|
25
25
|
<button type="button" :class="$style.rowMenu"
|
|
26
26
|
@click.stop="viewMenu.open($event.currentTarget, { view })">
|
|
27
27
|
<svg width="14" height="14" class="fill-text-400 hover:fill-text" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path :d="ELLIPSIS_PATH"/></svg>
|