Skip to content

Commit 8007183

Browse files
committed
feat(prototype): implement UI TODOs — per-source drafts, source info panel, modal filters
- query drafts persist in localStorage per data source; switching sources restores (or resets) the draft, and a query arriving via the URL seeds the draft for its source so shared links win - SkeletonPanel replaced by DataSourceInfoPanel: source title/description plus a one-level data shape in a plain code block (no discovery); clicking a prop sets it as the query and runs - the save-query modal always shows all three filter options with their on/off state
1 parent ce4234d commit 8007183

5 files changed

Lines changed: 185 additions & 66 deletions

File tree

examples/prototype-data-inspector/src/spa/App.vue

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ import LayoutSplitPane from '@antfu/design/components/Layout/LayoutSplitPane.vue
88
import LayoutToolbar from '@antfu/design/components/Layout/LayoutToolbar.vue'
99
import { Pane } from 'splitpanes'
1010
import { computed, onMounted } from 'vue'
11+
import DataSourceInfoPanel from './components/DataSourceInfoPanel.vue'
1112
import QueryEditor from './components/QueryEditor.vue'
1213
import QuerySettings from './components/QuerySettings.vue'
1314
import ResultViewer from './components/ResultViewer.vue'
1415
import SavedQueriesPanel from './components/SavedQueriesPanel.vue'
15-
import SkeletonPanel from './components/SkeletonPanel.vue'
1616
import { connect, connection } from './composables/rpc'
1717
import { useSavedQueries } from './composables/saved'
1818
import { colorScheme } from './composables/scheme'
@@ -51,6 +51,12 @@ function saveCurrent(input: { title?: string, description?: string, scope: Saved
5151
excludeDollarProps: wb.settings.excludeDollarProps || undefined,
5252
})
5353
}
54+
55+
/** A prop clicked in the data-shape panel becomes the query. */
56+
function queryProp(key: string): void {
57+
wb.query.value = /^[a-z_$][\w$]*$/i.test(key) ? key : `$["${key.replaceAll('"', '\\"')}"]`
58+
void wb.runNow()
59+
}
5460
</script>
5561

5662
<template>
@@ -120,7 +126,6 @@ function saveCurrent(input: { title?: string, description?: string, scope: Saved
120126
<span>Run</span>
121127
</Button>
122128
</div>
123-
<!-- TODO: Query draft should be persistent in local storage per data source, on switching data sources, it should be restored/reset -->
124129
<QueryEditor
125130
v-model="wb.query.value"
126131
:syntax="wb.syntax.value"
@@ -146,12 +151,13 @@ function saveCurrent(input: { title?: string, description?: string, scope: Saved
146151
/>
147152
</Pane>
148153
<Pane :size="33" min-size="12" class="p-3 py-1.5 min-h-0">
149-
<!-- TODO: this should be replaced with "DataSourceInfoPanel", that shows the data source title/description and simple data shape with one-level depth with normal code block (not discovery), on click the prop it would set the query -->
150-
<SkeletonPanel
154+
<DataSourceInfoPanel
155+
:source="wb.activeSource.value"
151156
:skeleton="wb.skeleton.value"
152157
:error="wb.skeletonError.value"
153158
:loading="wb.skeletonLoading.value"
154159
@refresh="wb.loadSkeleton()"
160+
@select="queryProp"
155161
/>
156162
</Pane>
157163
</LayoutSplitPane>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<script setup lang="ts">
2+
import type { DataSourceMeta } from '../../rpc-contract'
3+
import ActionIconButton from '@antfu/design/components/Action/ActionIconButton.vue'
4+
import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue'
5+
import { computed } from 'vue'
6+
7+
const props = defineProps<{
8+
source: DataSourceMeta | undefined
9+
/** The source's type skeleton (from the skeleton RPC); only depth 1 is shown. */
10+
skeleton: unknown
11+
error: string | null
12+
loading: boolean
13+
}>()
14+
15+
const emit = defineEmits<{
16+
refresh: []
17+
/** A property was clicked: set it as the query. */
18+
select: [key: string]
19+
}>()
20+
21+
interface OverviewEntry {
22+
key: string
23+
label: string
24+
}
25+
26+
/** Compact one-level type label for a skeleton value. */
27+
function labelOf(value: unknown): string {
28+
if (typeof value === 'string') {
29+
if (value === '...' || value === '[circular]')
30+
return 'object'
31+
return value // 'string' | 'number' | 'function' | 'Date' | 'Map(0)' | ...
32+
}
33+
if (Array.isArray(value))
34+
return 'array'
35+
if (value && typeof value === 'object') {
36+
const record = value as Record<string, unknown>
37+
if (typeof record.$class === 'string')
38+
return `class ${record.$class}`
39+
const keys = Object.keys(record)
40+
const collection = keys.find(k => /^(?:Map|Set)\(\d+\)$/.test(k))
41+
if (collection)
42+
return collection
43+
return 'object'
44+
}
45+
return String(value)
46+
}
47+
48+
const entries = computed<OverviewEntry[]>(() => {
49+
const { skeleton } = props
50+
if (!skeleton || typeof skeleton !== 'object' || Array.isArray(skeleton))
51+
return []
52+
return Object.entries(skeleton as Record<string, unknown>)
53+
.filter(([key]) => key !== '$class' && key !== '...')
54+
.map(([key, value]) => ({ key, label: labelOf(value) }))
55+
})
56+
57+
/** The skeleton root when it is not a plain object (array, primitive, ...). */
58+
const rootLabel = computed(() => {
59+
const { skeleton } = props
60+
if (skeleton === undefined || entries.value.length)
61+
return null
62+
return labelOf(skeleton)
63+
})
64+
</script>
65+
66+
<template>
67+
<div class="flex flex-col h-full min-h-0">
68+
<div class="flex items-center gap-2 px-1 pb-1.5">
69+
<span class="text-sm font-semibold truncate select-none">{{ source?.title ?? 'Data source' }}</span>
70+
<DisplayBadge v-if="source?.static" text="static" :color="false" />
71+
<div class="flex-1" />
72+
<ActionIconButton
73+
size="sm"
74+
:icon="loading ? 'i-ph:arrows-clockwise animate-spin' : 'i-ph:arrows-clockwise'"
75+
label="Refresh data shape"
76+
tooltip="Refresh"
77+
:disabled="loading"
78+
@click="emit('refresh')"
79+
/>
80+
</div>
81+
<p v-if="source?.description" class="m-0 px-1 pb-2 text-xs color-muted">
82+
{{ source.description }}
83+
</p>
84+
85+
<div
86+
v-if="error"
87+
class="mx-1 mb-1 px-2.5 py-1.5 font-mono text-11px rounded-lg border border-red-600/40 bg-red-500:8 color-red-700 dark:(border-red-400/40 color-red-300)"
88+
>
89+
{{ error }}
90+
</div>
91+
92+
<!-- One-level data shape as a plain code block; click a prop to query it. -->
93+
<div class="flex-1 min-h-0 overflow-auto font-mono text-xs leading-relaxed bg-secondary border border-base rounded-lg px-3 py-2">
94+
<template v-if="entries.length">
95+
<div class="op50 select-none">
96+
{
97+
</div>
98+
<div v-for="entry in entries" :key="entry.key" class="pl-4 whitespace-nowrap">
99+
<button
100+
type="button"
101+
class="color-active hover:underline cursor-pointer"
102+
:title="`Query ${entry.key}`"
103+
@click="emit('select', entry.key)"
104+
>
105+
{{ entry.key }}
106+
</button><span class="op50">: {{ entry.label }},</span>
107+
</div>
108+
<div class="op50 select-none">
109+
}
110+
</div>
111+
</template>
112+
<span v-else-if="rootLabel" class="op50">{{ rootLabel }}</span>
113+
<span v-else class="op50">loading shape...</span>
114+
</div>
115+
</div>
116+
</template>

examples/prototype-data-inspector/src/spa/components/SavedQueriesPanel.vue

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,12 @@ const scopeOptions = [
3333
{ value: 'project', label: 'Project (.devframe, shared)' },
3434
]
3535
36-
const activeFilterLabels = computed(() => {
37-
const labels: string[] = []
38-
if (props.currentFilters.excludeFunctions)
39-
labels.push('no functions')
40-
if (props.currentFilters.excludeUnderscoreProps)
41-
labels.push('no _ props')
42-
if (props.currentFilters.excludeDollarProps)
43-
labels.push('no $ props')
44-
return labels
45-
})
36+
/** All filter options with their current state — the modal shows every one. */
37+
const filterStates = computed(() => [
38+
{ label: 'Exclude functions', on: props.currentFilters.excludeFunctions },
39+
{ label: 'Exclude _ props', on: props.currentFilters.excludeUnderscoreProps },
40+
{ label: 'Exclude $ props', on: props.currentFilters.excludeDollarProps },
41+
])
4642
4743
function openDialog(): void {
4844
title.value = ''
@@ -133,7 +129,6 @@ function filterBadges(entry: Query): string[] {
133129
No queries yet. Compose one and hit "Save query".
134130
</div>
135131

136-
<!-- TODO: in this save query modal, we should also show the filters -->
137132
<OverlayModal
138133
v-model:open="dialogOpen"
139134
title="Save query"
@@ -143,9 +138,17 @@ function filterBadges(entry: Query): string[] {
143138
<div class="px-3 py-2 rounded-lg bg-secondary border border-base font-mono text-xs whitespace-pre-wrap break-all max-h-24 overflow-auto">
144139
{{ currentQuery.trim() || '$' }}
145140
</div>
146-
<div v-if="activeFilterLabels.length" class="flex items-center gap-1.5 flex-wrap">
147-
<span class="text-xs color-muted">Filters:</span>
148-
<DisplayBadge v-for="label in activeFilterLabels" :key="label" :text="label" :color="false" />
141+
<div class="flex items-center gap-3 flex-wrap text-xs">
142+
<span class="color-muted">Filters:</span>
143+
<span
144+
v-for="state in filterStates"
145+
:key="state.label"
146+
class="flex items-center gap-1"
147+
:class="state.on ? 'color-base' : 'color-faint'"
148+
>
149+
<span :class="state.on ? 'i-ph:check-circle-duotone color-active' : 'i-ph:circle-duotone'" />
150+
{{ state.label }}
151+
</span>
149152
</div>
150153
<FormTextInput v-model="title" placeholder="Title (optional, becomes the storage id)" />
151154
<FormTextInput v-model="description" placeholder="Description (optional)" />

examples/prototype-data-inspector/src/spa/components/SkeletonPanel.vue

Lines changed: 0 additions & 48 deletions
This file was deleted.

examples/prototype-data-inspector/src/spa/composables/workbench.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,21 @@ export type SyntaxState
1919
const AUTO_RUN_DEBOUNCE = 400
2020
const SUGGEST_DEBOUNCE = 150
2121
const URL_SYNC_DEBOUNCE = 300
22+
const DRAFTS_KEY = 'data-inspector:drafts'
2223

2324
const FILTER_KEYS = ['excludeFunctions', 'excludeUnderscoreProps', 'excludeDollarProps'] as const
2425

26+
/** Per-source query drafts, persisted in localStorage. */
27+
function loadDrafts(): Record<string, string> {
28+
try {
29+
const parsed = JSON.parse(localStorage.getItem(DRAFTS_KEY) ?? '{}')
30+
return parsed && typeof parsed === 'object' ? parsed : {}
31+
}
32+
catch {
33+
return {}
34+
}
35+
}
36+
2537
function checkSyntax(query: string): SyntaxState {
2638
try {
2739
jora.syntax.parse(query)
@@ -60,6 +72,25 @@ export function useWorkbench() {
6072
const sourceId = ref(initial.sourceId)
6173
const query = ref(initial.query)
6274

75+
// ── per-source query drafts (restored/reset on source switch) ───────
76+
const drafts = loadDrafts()
77+
let restoringDraft = false
78+
79+
function saveDraft(): void {
80+
if (!sourceId.value)
81+
return
82+
if (query.value)
83+
drafts[sourceId.value] = query.value
84+
else
85+
delete drafts[sourceId.value]
86+
localStorage.setItem(DRAFTS_KEY, JSON.stringify(drafts))
87+
}
88+
89+
function restoreDraft(): void {
90+
restoringDraft = true
91+
query.value = drafts[sourceId.value] ?? ''
92+
}
93+
6394
const settings = reactive<Required<FilterOptions>>({
6495
excludeFunctions: false,
6596
excludeUnderscoreProps: false,
@@ -106,6 +137,10 @@ export function useWorkbench() {
106137
sources.value = await call<DataSourceMeta[]>('data-inspector:sources')
107138
if (!sourceId.value || !sources.value.some(s => s.id === sourceId.value))
108139
sourceId.value = sources.value[0]?.id ?? ''
140+
// A query arriving via the URL becomes the draft for its source, so the
141+
// source-switch restore below can never clobber a shared link.
142+
if (initial.query)
143+
saveDraft()
109144
}
110145

111146
// ── auto-run with syntax gate + stale-drop ─────────────────────────
@@ -239,11 +274,18 @@ export function useWorkbench() {
239274
}
240275

241276
watch(query, () => {
277+
saveDraft()
242278
syncUrl()
279+
if (restoringDraft) {
280+
// Draft restores ride the source-switch runNow; skip the debounce run.
281+
restoringDraft = false
282+
return
283+
}
243284
scheduleRun()
244285
})
245286
watch(sourceId, () => {
246287
suggestions.value = []
288+
restoreDraft()
247289
syncUrl()
248290
void runNow()
249291
void loadSkeleton()

0 commit comments

Comments
 (0)