@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mixd-id/web-scaffold",
3
3
  "private": false,
4
- "version": "0.1.240411124",
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
- <h3 class="truncate" @click="log(activeItem)">{{ activeItem ? activeItem.name : 'Dashboard' }}</h3>
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 email" />
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 p-2 rounded-md text-left hover:bg-base-500"
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-[26px] h-[26px] rounded-full bg-base-500 text-xs flex items-center justify-center shrink-0">{{ (user.name || '?').slice(0, 1).toUpperCase() }}</div>
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 p-2 rounded-lg bg-base-400">
304
- <div class="w-[26px] h-[26px] rounded-full bg-base-500 text-xs flex items-center justify-center shrink-0">{{ (user.name || '?').slice(0, 1).toUpperCase() }}</div>
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
- <button v-else-if="sharing.canManage" type="button" class="p-1 text-text-300 hover:text-red-600 shrink-0" title="Stop sharing" @click="unshareWith(user)">
311
- <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>
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 (!search?.trim()) return
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
- async function shareWith(user) {
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 top = [ ...rows ].sort((a, b) => b.value - a.value).slice(0, 7)
170
- rows = [ ...top, { label: 'Other', value: rows.reduce((sum, r) => sum + r.value, 0) - top.reduce((sum, r) => sum + r.value, 0), other: true } ]
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="node.chart = $event; $emit('change')">
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
- <div v-if="!measurable">
139
- <label class="text-xs text-text-400">Aggregation</label>
140
- <Dropdown v-model="query.aggregation" @change="$emit('change')">
141
- <option v-for="a in aggregations" :key="a" :value="a">{{ aggLabel(a) }}</option>
142
- </Dropdown>
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
- <template v-if="node.chart === 'map'">
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="flex-1 min-w-0">
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="Exact values" @change="$emit('change')" />
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
- <Datepicker v-if="f.op !== 'period' && typeOf(f.field) === 'date' && !isToken(f.value)" mode="popup"
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 { '=': '=', '!=': '≠', '>': '>', '>=': '≥', '<': '<', '<=': '≤', contains: 'has', in: 'in', period: 'is' }[op] ?? op
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 isPeriod = /^(today|yesterday|thisWeek|lastWeek|thisMonth|lastMonth|\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2})$/.test(`${filter.value}`)
147
- if (filter.op === 'period' && !isPeriod) filter.value = 'today'
148
- if (filter.op !== 'period' && isPeriod) filter.value = ''
149
- if (filter.op !== 'period' && this.typeOf(filter.field) === 'date' && !this.isToken(filter.value) && !/^\d{4}-\d{2}-\d{2}/.test(`${filter.value}`)) filter.value = '@today'
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
- <label class="text-xs text-text-400 mb-1">Breakdown by (first field)</label>
88
- <Dropdown v-model="query.breakdownBy" @change="$emit('change')">
89
- <option value="">— none —</option>
90
- <option v-for="f in fields.filter(f => f.type !== 'date')" :key="f.key" :value="f.key">{{ f.label }}</option>
91
- </Dropdown>
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>