@antelopejs/dms-database 0.0.1
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/LICENSE +190 -0
- package/README.md +53 -0
- package/dist/automation/index.d.ts +2 -0
- package/dist/automation/index.js +13 -0
- package/dist/automation/index.js.map +1 -0
- package/dist/automation/tableWatch.d.ts +17 -0
- package/dist/automation/tableWatch.js +189 -0
- package/dist/automation/tableWatch.js.map +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -0
- package/dist/db/index.d.ts +2 -0
- package/dist/db/index.js +19 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/models/diagram_layouts.model.d.ts +25 -0
- package/dist/db/models/diagram_layouts.model.js +29 -0
- package/dist/db/models/diagram_layouts.model.js.map +1 -0
- package/dist/db/models/diagram_notes.model.d.ts +23 -0
- package/dist/db/models/diagram_notes.model.js +15 -0
- package/dist/db/models/diagram_notes.model.js.map +1 -0
- package/dist/db/models/index.d.ts +4 -0
- package/dist/db/models/index.js +21 -0
- package/dist/db/models/index.js.map +1 -0
- package/dist/db/models/query_history.model.d.ts +25 -0
- package/dist/db/models/query_history.model.js +33 -0
- package/dist/db/models/query_history.model.js.map +1 -0
- package/dist/db/models/saved_queries.model.d.ts +24 -0
- package/dist/db/models/saved_queries.model.js +21 -0
- package/dist/db/models/saved_queries.model.js.map +1 -0
- package/dist/db/tables/diagram_layouts.table.d.ts +7 -0
- package/dist/db/tables/diagram_layouts.table.js +35 -0
- package/dist/db/tables/diagram_layouts.table.js.map +1 -0
- package/dist/db/tables/diagram_notes.table.d.ts +13 -0
- package/dist/db/tables/diagram_notes.table.js +59 -0
- package/dist/db/tables/diagram_notes.table.js.map +1 -0
- package/dist/db/tables/index.d.ts +4 -0
- package/dist/db/tables/index.js +21 -0
- package/dist/db/tables/index.js.map +1 -0
- package/dist/db/tables/query_history.table.d.ts +11 -0
- package/dist/db/tables/query_history.table.js +54 -0
- package/dist/db/tables/query_history.table.js.map +1 -0
- package/dist/db/tables/saved_queries.table.d.ts +12 -0
- package/dist/db/tables/saved_queries.table.js +58 -0
- package/dist/db/tables/saved_queries.table.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/data.d.ts +9 -0
- package/dist/pages/data.js +29 -0
- package/dist/pages/data.js.map +1 -0
- package/dist/pages/index.d.ts +4 -0
- package/dist/pages/index.js +21 -0
- package/dist/pages/index.js.map +1 -0
- package/dist/pages/overview.d.ts +9 -0
- package/dist/pages/overview.js +29 -0
- package/dist/pages/overview.js.map +1 -0
- package/dist/pages/query.d.ts +9 -0
- package/dist/pages/query.js +29 -0
- package/dist/pages/query.js.map +1 -0
- package/dist/pages/schemas.d.ts +9 -0
- package/dist/pages/schemas.js +29 -0
- package/dist/pages/schemas.js.map +1 -0
- package/dist/routes/data.d.ts +25 -0
- package/dist/routes/data.js +324 -0
- package/dist/routes/data.js.map +1 -0
- package/dist/routes/diagram.d.ts +47 -0
- package/dist/routes/diagram.js +200 -0
- package/dist/routes/diagram.js.map +1 -0
- package/dist/routes/health.d.ts +6 -0
- package/dist/routes/health.js +35 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/index.js +23 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/query.d.ts +26 -0
- package/dist/routes/query.js +245 -0
- package/dist/routes/query.js.map +1 -0
- package/dist/routes/schemas.d.ts +8 -0
- package/dist/routes/schemas.js +35 -0
- package/dist/routes/schemas.js.map +1 -0
- package/dist/routes/tables.d.ts +23 -0
- package/dist/routes/tables.js +94 -0
- package/dist/routes/tables.js.map +1 -0
- package/dist/service/fieldDescriptor.d.ts +66 -0
- package/dist/service/fieldDescriptor.js +156 -0
- package/dist/service/fieldDescriptor.js.map +1 -0
- package/dist/service/health.d.ts +20 -0
- package/dist/service/health.js +58 -0
- package/dist/service/health.js.map +1 -0
- package/dist/service/introspect.d.ts +6 -0
- package/dist/service/introspect.js +312 -0
- package/dist/service/introspect.js.map +1 -0
- package/dist/service/schemaLabels.d.ts +7 -0
- package/dist/service/schemaLabels.js +39 -0
- package/dist/service/schemaLabels.js.map +1 -0
- package/dist/service/schemaRegistry.d.ts +2 -0
- package/dist/service/schemaRegistry.js +8 -0
- package/dist/service/schemaRegistry.js.map +1 -0
- package/dist/service/stagedSerialization.d.ts +6 -0
- package/dist/service/stagedSerialization.js +54 -0
- package/dist/service/stagedSerialization.js.map +1 -0
- package/dist/service/types.d.ts +67 -0
- package/dist/service/types.js +3 -0
- package/dist/service/types.js.map +1 -0
- package/dist/types/constants.d.ts +3 -0
- package/dist/types/constants.js +7 -0
- package/dist/types/constants.js.map +1 -0
- package/dist/types/responses.d.ts +14 -0
- package/dist/types/responses.js +3 -0
- package/dist/types/responses.js.map +1 -0
- package/dist/utils/query.d.ts +17 -0
- package/dist/utils/query.js +116 -0
- package/dist/utils/query.js.map +1 -0
- package/dist/utils/requestValidation.d.ts +2 -0
- package/dist/utils/requestValidation.js +14 -0
- package/dist/utils/requestValidation.js.map +1 -0
- package/frontend-vue/.editorconfig +12 -0
- package/frontend-vue/.prettierrc +11 -0
- package/frontend-vue/app/components/DataBrowserSidebar.vue +254 -0
- package/frontend-vue/app/components/DataBrowserTabBar.vue +166 -0
- package/frontend-vue/app/components/DataGrid.vue +735 -0
- package/frontend-vue/app/components/DataGridCell.vue +147 -0
- package/frontend-vue/app/components/DataGridHeaderCell.vue +175 -0
- package/frontend-vue/app/components/DiagramNoteNode.vue +100 -0
- package/frontend-vue/app/components/DiagramRelationEdge.vue +62 -0
- package/frontend-vue/app/components/DiagramStubNode.vue +37 -0
- package/frontend-vue/app/components/DiagramTableNode.vue +94 -0
- package/frontend-vue/app/components/JsonCellPanel.vue +93 -0
- package/frontend-vue/app/components/QueryBuilderPanel.vue +501 -0
- package/frontend-vue/app/components/QueryFavoritesPanel.vue +127 -0
- package/frontend-vue/app/components/QueryHistoryPanel.vue +134 -0
- package/frontend-vue/app/components/QueryResultPanel.vue +204 -0
- package/frontend-vue/app/components/SchemaDiagram.vue +1150 -0
- package/frontend-vue/app/components/SchemaTableInspector.vue +278 -0
- package/frontend-vue/app/composables/useDataBrowserGrid.ts +162 -0
- package/frontend-vue/app/composables/useDataBrowserTabs.ts +255 -0
- package/frontend-vue/app/composables/useDatabaseHealth.ts +50 -0
- package/frontend-vue/app/composables/useDatabaseSchemas.ts +92 -0
- package/frontend-vue/app/composables/useDiagramPersistence.ts +99 -0
- package/frontend-vue/app/composables/useQueryRuntime.ts +118 -0
- package/frontend-vue/app/composables/useQueryStore.ts +117 -0
- package/frontend-vue/app/composables/useTableViewRowClick.ts +22 -0
- package/frontend-vue/app/custom-pages/database/data.vue +42 -0
- package/frontend-vue/app/custom-pages/database/overview.vue +367 -0
- package/frontend-vue/app/custom-pages/database/query.vue +432 -0
- package/frontend-vue/app/custom-pages/database/schemas.vue +216 -0
- package/frontend-vue/app/utils/databaseFieldModifiers.ts +33 -0
- package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +224 -0
- package/frontend-vue/app/utils/dmsTableView.ts +83 -0
- package/frontend-vue/app/utils/relativeTime.ts +15 -0
- package/frontend-vue/app/utils/stagedSerialization.ts +91 -0
- package/frontend-vue/dms.frontend.ts +38 -0
- package/frontend-vue/eslint.config.mjs +16 -0
- package/frontend-vue/i18n/locales/database-en-GB.json +239 -0
- package/frontend-vue/i18n/locales/database-fr-FR.json +239 -0
- package/frontend-vue/package.json +42 -0
- package/frontend-vue/pnpm-lock.yaml +5819 -0
- package/frontend-vue/pnpm-workspace.yaml +2 -0
- package/frontend-vue/tests/persistence.test.ts +70 -0
- package/frontend-vue/tests/queryRuntime.test.ts +53 -0
- package/frontend-vue/tests/registration.test.ts +33 -0
- package/frontend-vue/tsconfig.json +13 -0
- package/frontend-vue/vitest.config.ts +5 -0
- package/package.json +78 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
FieldDescriptor,
|
|
4
|
+
TableSummary,
|
|
5
|
+
} from "../composables/useDatabaseSchemas";
|
|
6
|
+
import { inferFieldTypeIcon, isPrimaryKey } from "../utils/databaseFieldTypeIcons";
|
|
7
|
+
|
|
8
|
+
// Body panel for the shared dms drawer (useDrawer). The drawer owns the chrome
|
|
9
|
+
// (themed surface, header with the table name + subtitle, close button); this
|
|
10
|
+
// component only renders the inspector content: columns / indexes / preview
|
|
11
|
+
// tabs and the footer. It is opened from schemas.vue via `useDrawer().open()`.
|
|
12
|
+
|
|
13
|
+
// Descriptor kinds that admit null/undefined on their own: an explicit
|
|
14
|
+
// null/undefined, or an untyped any/unknown.
|
|
15
|
+
const NULLISH_KINDS = new Set<FieldDescriptor["kind"]>([
|
|
16
|
+
"null",
|
|
17
|
+
"undefined",
|
|
18
|
+
"any",
|
|
19
|
+
"unknown",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// A field is nullable when its descriptor admits null/undefined — directly, as
|
|
23
|
+
// an untyped any/unknown, or as a member of a union. Anything else is required.
|
|
24
|
+
function isNullable(descriptor: FieldDescriptor): boolean {
|
|
25
|
+
if (NULLISH_KINDS.has(descriptor.kind)) return true;
|
|
26
|
+
if (descriptor.kind === "union") {
|
|
27
|
+
return descriptor.members.some(
|
|
28
|
+
(member) => member.kind === "null" || member.kind === "undefined",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
schemaId: string | null;
|
|
36
|
+
table: TableSummary | null;
|
|
37
|
+
// Injected by the shared DynamicDrawer; unused here (read-only panel).
|
|
38
|
+
containerId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const props = defineProps<Props>();
|
|
42
|
+
|
|
43
|
+
// The shared DynamicDrawer dismisses the drawer when the body emits `success`.
|
|
44
|
+
const emit = defineEmits<{ success: [] }>();
|
|
45
|
+
|
|
46
|
+
const { t } = useI18n();
|
|
47
|
+
const router = useDmsRouter();
|
|
48
|
+
const { $authFetch } = useAuthFetch();
|
|
49
|
+
|
|
50
|
+
const DATA_PATH = "/modules/database/data";
|
|
51
|
+
const BROWSE_LIST = "/api/database/browse/list";
|
|
52
|
+
const FILTER_MODE_IS = "is";
|
|
53
|
+
|
|
54
|
+
type Tab = "columns" | "indexes" | "preview";
|
|
55
|
+
const tab = ref<Tab>("columns");
|
|
56
|
+
|
|
57
|
+
const tabItems = computed(() => [
|
|
58
|
+
{ label: t("dms_database.schemas.inspector.columns"), value: "columns" as const, icon: "i-ph-layout" },
|
|
59
|
+
{ label: t("dms_database.schemas.inspector.indexes"), value: "indexes" as const, icon: "i-ph-key" },
|
|
60
|
+
{ label: t("dms_database.schemas.inspector.preview"), value: "preview" as const, icon: "i-ph-table" },
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const fieldEntries = computed(() =>
|
|
64
|
+
Object.entries(props.table?.fields ?? {}).map(([name, descriptor]) => {
|
|
65
|
+
const meta = inferFieldTypeIcon(descriptor, name);
|
|
66
|
+
const pk = isPrimaryKey(name, props.table?.indexes ?? {});
|
|
67
|
+
// Primary keys are never nullable; otherwise read it off the descriptor.
|
|
68
|
+
const nullable = pk ? false : isNullable(descriptor);
|
|
69
|
+
return { name, meta, pk, nullable };
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const indexEntries = computed(() =>
|
|
74
|
+
Object.entries(props.table?.indexes ?? {}).map(([name, def]) => ({
|
|
75
|
+
name,
|
|
76
|
+
fields: def.fields ?? [],
|
|
77
|
+
multi: Boolean(def.multi),
|
|
78
|
+
})),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// --- preview (single sample row) ---
|
|
82
|
+
const previewRow = ref<Record<string, unknown> | null>(null);
|
|
83
|
+
const previewLoading = ref(false);
|
|
84
|
+
const previewError = ref(false);
|
|
85
|
+
const previewLoadedFor = ref<string | null>(null);
|
|
86
|
+
|
|
87
|
+
async function loadPreview() {
|
|
88
|
+
if (!props.schemaId || !props.table) return;
|
|
89
|
+
const key = `${props.schemaId}::${props.table.name}`;
|
|
90
|
+
if (previewLoadedFor.value === key) return;
|
|
91
|
+
previewLoading.value = true;
|
|
92
|
+
previewError.value = false;
|
|
93
|
+
try {
|
|
94
|
+
const res = await $authFetch<{ results: Record<string, unknown>[] }>(
|
|
95
|
+
BROWSE_LIST,
|
|
96
|
+
{
|
|
97
|
+
query: {
|
|
98
|
+
filter_schema: `${FILTER_MODE_IS}:${props.schemaId}`,
|
|
99
|
+
filter_table: `${FILTER_MODE_IS}:${props.table.name}`,
|
|
100
|
+
limit: 1,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
previewRow.value = res.results?.[0] ?? null;
|
|
105
|
+
previewLoadedFor.value = key;
|
|
106
|
+
} catch {
|
|
107
|
+
previewError.value = true;
|
|
108
|
+
previewRow.value = null;
|
|
109
|
+
} finally {
|
|
110
|
+
previewLoading.value = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const previewJson = computed(() =>
|
|
115
|
+
previewRow.value ? JSON.stringify(previewRow.value, null, 2) : "",
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// The panel is mounted only while the drawer is open, so the preview loads as
|
|
119
|
+
// soon as its tab is selected (no `open` prop to gate on anymore).
|
|
120
|
+
watch(
|
|
121
|
+
() => [tab.value, props.table?.name] as const,
|
|
122
|
+
() => {
|
|
123
|
+
if (tab.value === "preview") loadPreview();
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const browseTo = computed(() => {
|
|
128
|
+
if (!props.schemaId || !props.table) return DATA_PATH;
|
|
129
|
+
return `${DATA_PATH}?schema=${encodeURIComponent(props.schemaId)}&table=${encodeURIComponent(props.table.name)}`;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// "Browse data" navigates to the data browser and closes the drawer so the user
|
|
133
|
+
// lands on the table without the panel lingering over it.
|
|
134
|
+
function browseData() {
|
|
135
|
+
router.push(browseTo.value);
|
|
136
|
+
emit("success");
|
|
137
|
+
}
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<template>
|
|
141
|
+
<!-- Fixed width so the right-side drawer keeps a stable, comfortable size
|
|
142
|
+
instead of shrinking/growing with the active tab's content (the shared
|
|
143
|
+
DynamicDrawer sizes a right drawer to its content and exposes no width). -->
|
|
144
|
+
<div class="flex h-full w-[36rem] max-w-[calc(100vw-3rem)] flex-col">
|
|
145
|
+
<!-- tabs -->
|
|
146
|
+
<div class="border-b border-default pb-3">
|
|
147
|
+
<DmsSegmented v-model="tab" :items="tabItems" />
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<!-- body -->
|
|
151
|
+
<div class="flex-1 overflow-y-auto py-4">
|
|
152
|
+
<!-- columns -->
|
|
153
|
+
<table v-if="tab === 'columns'" class="w-full text-sm">
|
|
154
|
+
<thead>
|
|
155
|
+
<tr class="border-b border-default text-left">
|
|
156
|
+
<th class="text-dimmed py-2 pr-3 text-xs font-semibold">
|
|
157
|
+
{{ $t("dms_database.schemas.inspector.colName") }}
|
|
158
|
+
</th>
|
|
159
|
+
<th class="text-dimmed py-2 px-3 text-xs font-semibold">
|
|
160
|
+
{{ $t("dms_database.schemas.inspector.colType") }}
|
|
161
|
+
</th>
|
|
162
|
+
<th class="text-dimmed py-2 pl-3 text-xs font-semibold">
|
|
163
|
+
{{ $t("dms_database.schemas.inspector.colNullable") }}
|
|
164
|
+
</th>
|
|
165
|
+
</tr>
|
|
166
|
+
</thead>
|
|
167
|
+
<tbody>
|
|
168
|
+
<tr
|
|
169
|
+
v-for="field in fieldEntries"
|
|
170
|
+
:key="field.name"
|
|
171
|
+
class="border-b border-default/60 last:border-0"
|
|
172
|
+
>
|
|
173
|
+
<td class="py-2 pr-3">
|
|
174
|
+
<span class="flex items-center gap-2 font-mono text-toned">
|
|
175
|
+
<UIcon
|
|
176
|
+
:name="field.pk ? 'i-ph-key' : field.meta.icon"
|
|
177
|
+
class="size-3.5 shrink-0"
|
|
178
|
+
:class="field.pk ? 'text-warning' : field.meta.color"
|
|
179
|
+
/>
|
|
180
|
+
{{ field.name }}
|
|
181
|
+
</span>
|
|
182
|
+
</td>
|
|
183
|
+
<td class="py-2 px-3">
|
|
184
|
+
<span
|
|
185
|
+
class="font-mono text-[11px]"
|
|
186
|
+
:class="field.meta.color"
|
|
187
|
+
>
|
|
188
|
+
{{ field.pk ? "pk" : field.meta.label }}
|
|
189
|
+
</span>
|
|
190
|
+
</td>
|
|
191
|
+
<td class="py-2 pl-3 text-dimmed">
|
|
192
|
+
{{
|
|
193
|
+
field.nullable
|
|
194
|
+
? $t("dms_database.schemas.inspector.yes")
|
|
195
|
+
: $t("dms_database.schemas.inspector.no")
|
|
196
|
+
}}
|
|
197
|
+
</td>
|
|
198
|
+
</tr>
|
|
199
|
+
</tbody>
|
|
200
|
+
</table>
|
|
201
|
+
|
|
202
|
+
<!-- indexes -->
|
|
203
|
+
<div v-else-if="tab === 'indexes'" class="space-y-2.5">
|
|
204
|
+
<div
|
|
205
|
+
v-for="idx in indexEntries"
|
|
206
|
+
:key="idx.name"
|
|
207
|
+
class="flex items-center gap-3 rounded-md border border-default bg-default px-3 py-2.5"
|
|
208
|
+
>
|
|
209
|
+
<UBadge
|
|
210
|
+
:color="idx.multi ? 'info' : 'neutral'"
|
|
211
|
+
variant="soft"
|
|
212
|
+
size="sm"
|
|
213
|
+
>
|
|
214
|
+
{{
|
|
215
|
+
idx.multi
|
|
216
|
+
? $t("dms_database.schemas.inspector.multi")
|
|
217
|
+
: $t("dms_database.schemas.inspector.single")
|
|
218
|
+
}}
|
|
219
|
+
</UBadge>
|
|
220
|
+
<span class="font-mono text-[12.5px] text-toned">{{ idx.name }}</span>
|
|
221
|
+
<span
|
|
222
|
+
v-if="idx.fields.length"
|
|
223
|
+
class="font-mono text-[11px] text-dimmed ml-auto truncate"
|
|
224
|
+
>
|
|
225
|
+
{{ idx.fields.join(", ") }}
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
<p
|
|
229
|
+
v-if="indexEntries.length === 0"
|
|
230
|
+
class="text-dimmed text-sm py-6 text-center"
|
|
231
|
+
>
|
|
232
|
+
{{ $t("dms_database.schemas.inspector.noIndexes") }}
|
|
233
|
+
</p>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<!-- preview -->
|
|
237
|
+
<div v-else>
|
|
238
|
+
<div v-if="previewLoading" class="py-10 text-center">
|
|
239
|
+
<UProgress size="sm" />
|
|
240
|
+
</div>
|
|
241
|
+
<div
|
|
242
|
+
v-else-if="previewError"
|
|
243
|
+
class="text-dimmed text-sm py-10 text-center"
|
|
244
|
+
>
|
|
245
|
+
{{ $t("dms_database.schemas.inspector.previewError") }}
|
|
246
|
+
</div>
|
|
247
|
+
<div
|
|
248
|
+
v-else-if="!previewRow"
|
|
249
|
+
class="text-dimmed text-sm py-10 text-center"
|
|
250
|
+
>
|
|
251
|
+
{{ $t("dms_database.schemas.inspector.previewEmpty") }}
|
|
252
|
+
</div>
|
|
253
|
+
<pre
|
|
254
|
+
v-else
|
|
255
|
+
class="text-xs rounded-lg border border-default bg-default p-4 overflow-auto whitespace-pre text-toned"
|
|
256
|
+
>{{ previewJson }}</pre
|
|
257
|
+
>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<!-- footer -->
|
|
262
|
+
<div class="flex items-center gap-2 border-t border-default pt-4">
|
|
263
|
+
<span class="flex items-center gap-1.5 text-xs text-success">
|
|
264
|
+
<UIcon name="i-ph-check-circle" class="size-3.5" />
|
|
265
|
+
{{ $t("dms_database.schemas.inspector.healthy") }}
|
|
266
|
+
</span>
|
|
267
|
+
<UButton
|
|
268
|
+
color="neutral"
|
|
269
|
+
variant="outline"
|
|
270
|
+
size="sm"
|
|
271
|
+
icon="i-ph-rows"
|
|
272
|
+
:label="$t('dms_database.schemas.browseData')"
|
|
273
|
+
class="ml-auto"
|
|
274
|
+
@click="browseData"
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
</template>
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Per-tab grid state (paging, sort, filters, column widths) and a data cache
|
|
2
|
+
// so switching tabs restores instantly without a refetch. Keyed by
|
|
3
|
+
// BrowserTab.id.
|
|
4
|
+
|
|
5
|
+
export interface ColumnFilterState {
|
|
6
|
+
mode: "is" | "contains";
|
|
7
|
+
value: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface GridState {
|
|
11
|
+
page: number;
|
|
12
|
+
pageSize: number;
|
|
13
|
+
sortKey: string | null;
|
|
14
|
+
sortDirection: "asc" | "desc";
|
|
15
|
+
search: string;
|
|
16
|
+
filters: Record<string, ColumnFilterState>;
|
|
17
|
+
columnWidths: Record<string, number>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GridColumn {
|
|
21
|
+
name: string;
|
|
22
|
+
// Coarse runtime type sampled by the backend: string | number | boolean |
|
|
23
|
+
// object | array | null | undefined.
|
|
24
|
+
type: string;
|
|
25
|
+
isPrimaryKey: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GridData {
|
|
29
|
+
columns: GridColumn[];
|
|
30
|
+
rows: Record<string, unknown>[];
|
|
31
|
+
total: number;
|
|
32
|
+
primaryKey: string | null;
|
|
33
|
+
// Serialised query parameters that produced this payload; lets the grid
|
|
34
|
+
// skip refetching when nothing changed.
|
|
35
|
+
paramsKey: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const GRID_PAGE_SIZES = [25, 50, 100, 200];
|
|
39
|
+
|
|
40
|
+
function defaultGridState(): GridState {
|
|
41
|
+
return {
|
|
42
|
+
page: 0,
|
|
43
|
+
pageSize: 25,
|
|
44
|
+
sortKey: null,
|
|
45
|
+
sortDirection: "asc",
|
|
46
|
+
search: "",
|
|
47
|
+
filters: {},
|
|
48
|
+
columnWidths: {},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useDataBrowserGrid() {
|
|
53
|
+
const states = useDmsState<Record<string, GridState>>(
|
|
54
|
+
"dms-database-grid-states",
|
|
55
|
+
() => ({}),
|
|
56
|
+
);
|
|
57
|
+
const cache = useDmsState<Record<string, GridData>>(
|
|
58
|
+
"dms-database-grid-cache",
|
|
59
|
+
() => ({}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
function getState(tabId: string): GridState {
|
|
63
|
+
if (!states.value[tabId]) states.value[tabId] = defaultGridState();
|
|
64
|
+
return states.value[tabId];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getCache(tabId: string): GridData | null {
|
|
68
|
+
return cache.value[tabId] ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setCache(tabId: string, data: GridData) {
|
|
72
|
+
cache.value[tabId] = data;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function invalidate(tabId: string) {
|
|
76
|
+
Reflect.deleteProperty(cache.value, tabId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Forget everything about a closed tab.
|
|
80
|
+
function drop(tabId: string) {
|
|
81
|
+
Reflect.deleteProperty(states.value, tabId);
|
|
82
|
+
Reflect.deleteProperty(cache.value, tabId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { getState, getCache, setCache, invalidate, drop };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- query helpers shared by the grid ---
|
|
89
|
+
|
|
90
|
+
// Reserved by the wire protocol for table selection (the backend's
|
|
91
|
+
// SELECTION_FILTER_KEYS): a data filter on a column literally named `schema`,
|
|
92
|
+
// `table` or `instance` would clobber the selection parameters, so it is never
|
|
93
|
+
// emitted and the filter UI is disabled for these columns.
|
|
94
|
+
const RESERVED_FILTER_FIELDS = new Set(["schema", "table", "instance"]);
|
|
95
|
+
|
|
96
|
+
export function isReservedFilterField(field: string): boolean {
|
|
97
|
+
return RESERVED_FILTER_FIELDS.has(field);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Normalized (empty-pruned, reserved-pruned, name-sorted) filter entries: the
|
|
101
|
+
// single source for both the wire query and the cache key, so key-insertion
|
|
102
|
+
// order or an empty draft can't cause spurious cache misses.
|
|
103
|
+
function activeFilterEntries(
|
|
104
|
+
state: GridState,
|
|
105
|
+
): Array<[string, ColumnFilterState]> {
|
|
106
|
+
return Object.entries(state.filters)
|
|
107
|
+
.filter(([field, filter]) => filter.value && !isReservedFilterField(field))
|
|
108
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Single encoder for the `<mode>:<value>` wire tokens parseFilter decodes
|
|
112
|
+
// server-side (src/utils/query.ts) — the grammar is defined here once instead
|
|
113
|
+
// of scattered string literals.
|
|
114
|
+
export function filterToken(mode: "is" | "contains", value: string): string {
|
|
115
|
+
return `${mode}:${value}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isFilterToken(value: string): string {
|
|
119
|
+
return filterToken("is", value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Selection filters identifying the table, in the wire format the browse
|
|
123
|
+
// backend decodes (see resolveSelection in src/routes/data.ts).
|
|
124
|
+
export function browseSelectionQuery(tab: BrowserTab): Record<string, string> {
|
|
125
|
+
const query: Record<string, string> = {
|
|
126
|
+
filter_schema: isFilterToken(tab.schema),
|
|
127
|
+
filter_table: isFilterToken(tab.table),
|
|
128
|
+
};
|
|
129
|
+
if (tab.instance !== DEFAULT_INSTANCE_VALUE) {
|
|
130
|
+
query.filter_instance = isFilterToken(tab.instance);
|
|
131
|
+
}
|
|
132
|
+
return query;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function browseListQuery(
|
|
136
|
+
state: GridState,
|
|
137
|
+
): Record<string, string | number> {
|
|
138
|
+
const query: Record<string, string | number> = {
|
|
139
|
+
offset: state.page * state.pageSize,
|
|
140
|
+
limit: state.pageSize,
|
|
141
|
+
sortDirection: state.sortDirection,
|
|
142
|
+
};
|
|
143
|
+
if (state.sortKey) query.sortKey = state.sortKey;
|
|
144
|
+
if (state.search) query.search = state.search;
|
|
145
|
+
for (const [field, filter] of activeFilterEntries(state)) {
|
|
146
|
+
query[`filter_${field}`] = filterToken(filter.mode, filter.value);
|
|
147
|
+
}
|
|
148
|
+
return query;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Everything that changes the server payload (column widths deliberately not).
|
|
152
|
+
export function browseParamsKey(tab: BrowserTab, state: GridState): string {
|
|
153
|
+
return JSON.stringify([
|
|
154
|
+
tab.id,
|
|
155
|
+
state.page,
|
|
156
|
+
state.pageSize,
|
|
157
|
+
state.sortKey,
|
|
158
|
+
state.sortDirection,
|
|
159
|
+
state.search,
|
|
160
|
+
activeFilterEntries(state),
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// Shared state for the data-browser tabs: each tab pins a (schema, instance,
|
|
2
|
+
// table) triplet. The active tab is mirrored to the URL query (deep-links) and
|
|
3
|
+
// the whole tab set is persisted to sessionStorage (survives a page refresh,
|
|
4
|
+
// stays per browser tab).
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_INSTANCE_VALUE = "__DEFAULT__";
|
|
7
|
+
export const CROSS_INSTANCE_VALUE = "__CROSS_INSTANCE__";
|
|
8
|
+
|
|
9
|
+
export interface BrowserTab {
|
|
10
|
+
id: string;
|
|
11
|
+
schema: string;
|
|
12
|
+
// DEFAULT_INSTANCE_VALUE, CROSS_INSTANCE_VALUE or a named instance id.
|
|
13
|
+
instance: string;
|
|
14
|
+
table: string;
|
|
15
|
+
// VS Code-style preview tab: the next openTab reuses it instead of adding
|
|
16
|
+
// a new tab. At most one per session; pinning (double-click, pin icon,
|
|
17
|
+
// drag-reorder, editing a cell) clears the flag.
|
|
18
|
+
preview?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STORAGE_KEY = "dms-database:data-browser:tabs";
|
|
22
|
+
|
|
23
|
+
function browserTabId(
|
|
24
|
+
schema: string,
|
|
25
|
+
instance: string,
|
|
26
|
+
table: string,
|
|
27
|
+
): string {
|
|
28
|
+
return `${schema}::${instance}::${table}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PersistedTabs {
|
|
32
|
+
tabs: BrowserTab[];
|
|
33
|
+
activeId: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readPersisted(): PersistedTabs | null {
|
|
37
|
+
if (typeof window === "undefined") return null;
|
|
38
|
+
try {
|
|
39
|
+
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
const parsed = JSON.parse(raw) as PersistedTabs;
|
|
42
|
+
if (!Array.isArray(parsed.tabs)) return null;
|
|
43
|
+
const tabs = parsed.tabs
|
|
44
|
+
.filter(
|
|
45
|
+
(tab) =>
|
|
46
|
+
tab &&
|
|
47
|
+
typeof tab.schema === "string" &&
|
|
48
|
+
typeof tab.instance === "string" &&
|
|
49
|
+
typeof tab.table === "string",
|
|
50
|
+
)
|
|
51
|
+
.map((tab) => ({
|
|
52
|
+
...tab,
|
|
53
|
+
id: browserTabId(tab.schema, tab.instance, tab.table),
|
|
54
|
+
preview: tab.preview === true,
|
|
55
|
+
}))
|
|
56
|
+
// Recomputed ids can collide on a legacy/hand-edited payload; duplicate
|
|
57
|
+
// ids would break the tab bar's v-for keys and closeTab.
|
|
58
|
+
.filter(
|
|
59
|
+
(tab, index, all) => all.findIndex((t) => t.id === tab.id) === index,
|
|
60
|
+
);
|
|
61
|
+
// The preview slot is a singleton; a hand-edited payload could carry
|
|
62
|
+
// several — honour only the first.
|
|
63
|
+
const firstPreview = tabs.findIndex((tab) => tab.preview);
|
|
64
|
+
for (const [index, tab] of tabs.entries()) {
|
|
65
|
+
if (index !== firstPreview) tab.preview = false;
|
|
66
|
+
}
|
|
67
|
+
const activeId = tabs.some((tab) => tab.id === parsed.activeId)
|
|
68
|
+
? parsed.activeId
|
|
69
|
+
: (tabs[0]?.id ?? null);
|
|
70
|
+
return { tabs, activeId };
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function useDataBrowserTabs() {
|
|
77
|
+
const tabs = useDmsState<BrowserTab[]>("dms-database-browser-tabs", () => []);
|
|
78
|
+
const activeId = useDmsState<string | null>(
|
|
79
|
+
"dms-database-browser-active",
|
|
80
|
+
() => null,
|
|
81
|
+
);
|
|
82
|
+
const route = useDmsRoute();
|
|
83
|
+
const router = useDmsRouter();
|
|
84
|
+
// Captured at setup so closeTab can drop the closed tab's
|
|
85
|
+
// grid state itself instead of relying on every caller to remember to.
|
|
86
|
+
const grid = useDataBrowserGrid();
|
|
87
|
+
|
|
88
|
+
const activeTab = computed(
|
|
89
|
+
() => tabs.value.find((tab) => tab.id === activeId.value) ?? null,
|
|
90
|
+
);
|
|
91
|
+
// When tabs span several schemas the tab bar disambiguates its labels.
|
|
92
|
+
const hasMultipleSchemas = computed(
|
|
93
|
+
() => new Set(tabs.value.map((tab) => tab.schema)).size > 1,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
function persist() {
|
|
97
|
+
if (typeof window === "undefined") return;
|
|
98
|
+
try {
|
|
99
|
+
window.sessionStorage.setItem(
|
|
100
|
+
STORAGE_KEY,
|
|
101
|
+
JSON.stringify({ tabs: tabs.value, activeId: activeId.value }),
|
|
102
|
+
);
|
|
103
|
+
} catch {
|
|
104
|
+
// Quota/private-mode failures only cost tab restoration.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The URL carries the ACTIVE tab only (deep-links); per-tab grid state
|
|
109
|
+
// deliberately stays out of it.
|
|
110
|
+
function syncUrl() {
|
|
111
|
+
const tab = activeTab.value;
|
|
112
|
+
const query = { ...route.query };
|
|
113
|
+
delete query.schema;
|
|
114
|
+
delete query.instance;
|
|
115
|
+
delete query.table;
|
|
116
|
+
if (tab) {
|
|
117
|
+
query.schema = tab.schema;
|
|
118
|
+
query.table = tab.table;
|
|
119
|
+
if (tab.instance !== DEFAULT_INSTANCE_VALUE) query.instance = tab.instance;
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
Object.keys(query).length === Object.keys(route.query).length &&
|
|
123
|
+
Object.entries(query).every(([key, value]) => route.query[key] === value)
|
|
124
|
+
) return;
|
|
125
|
+
router.replace({ query });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function activateTab(id: string) {
|
|
129
|
+
if (!tabs.value.some((tab) => tab.id === id)) return;
|
|
130
|
+
activeId.value = id;
|
|
131
|
+
syncUrl();
|
|
132
|
+
persist();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Promote a preview tab to a permanent one (double-click, pin icon, drag).
|
|
136
|
+
function pinTab(id: string) {
|
|
137
|
+
const tab = tabs.value.find((t) => t.id === id);
|
|
138
|
+
if (!tab?.preview) return;
|
|
139
|
+
tabs.value = tabs.value.map((t) =>
|
|
140
|
+
t.id === id ? { ...t, preview: false } : t,
|
|
141
|
+
);
|
|
142
|
+
persist();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Opens as a preview by default (VS Code-style: single-click reuses the
|
|
146
|
+
// preview slot). Pass preview = false for an explicitly permanent open
|
|
147
|
+
// (sidebar double-click, deep-links) — it also pins the tab if already
|
|
148
|
+
// open as preview.
|
|
149
|
+
function openTab(
|
|
150
|
+
schema: string,
|
|
151
|
+
instance: string,
|
|
152
|
+
table: string,
|
|
153
|
+
preview = true,
|
|
154
|
+
) {
|
|
155
|
+
const id = browserTabId(schema, instance, table);
|
|
156
|
+
const existing = tabs.value.find((tab) => tab.id === id);
|
|
157
|
+
if (existing) {
|
|
158
|
+
if (!preview) pinTab(id);
|
|
159
|
+
activateTab(id);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const tab: BrowserTab = { id, schema, instance, table, preview };
|
|
163
|
+
const previewIndex = tabs.value.findIndex((t) => t.preview);
|
|
164
|
+
if (preview && previewIndex >= 0) {
|
|
165
|
+
// Reuse the preview slot in place: swap its content, drop the replaced
|
|
166
|
+
// tab's grid state (its id dies with it).
|
|
167
|
+
const replaced = tabs.value[previewIndex]!;
|
|
168
|
+
const next = [...tabs.value];
|
|
169
|
+
next[previewIndex] = tab;
|
|
170
|
+
tabs.value = next;
|
|
171
|
+
grid.drop(replaced.id);
|
|
172
|
+
} else {
|
|
173
|
+
tabs.value = [...tabs.value, tab];
|
|
174
|
+
}
|
|
175
|
+
activateTab(id);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function closeTab(id: string) {
|
|
179
|
+
const index = tabs.value.findIndex((tab) => tab.id === id);
|
|
180
|
+
if (index < 0) return;
|
|
181
|
+
const next = tabs.value.filter((tab) => tab.id !== id);
|
|
182
|
+
tabs.value = next;
|
|
183
|
+
if (activeId.value === id) {
|
|
184
|
+
// Right neighbour first, then left — mirrors browser tab behaviour.
|
|
185
|
+
activeId.value = (next[index] ?? next[index - 1])?.id ?? null;
|
|
186
|
+
}
|
|
187
|
+
// The closed tab's grid state and cached rows go with it — otherwise they
|
|
188
|
+
// leak for the rest of the session.
|
|
189
|
+
grid.drop(id);
|
|
190
|
+
syncUrl();
|
|
191
|
+
persist();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Reorder (drag & drop): move the dragged tab to the target tab's position.
|
|
195
|
+
// Pure reorder — pin-on-drag is a gesture policy and lives at the drop site
|
|
196
|
+
// (the tab bar's onDrop), not in this primitive.
|
|
197
|
+
function moveTab(draggedId: string, targetId: string) {
|
|
198
|
+
if (draggedId === targetId) return;
|
|
199
|
+
const next = [...tabs.value];
|
|
200
|
+
const from = next.findIndex((tab) => tab.id === draggedId);
|
|
201
|
+
const to = next.findIndex((tab) => tab.id === targetId);
|
|
202
|
+
if (from < 0 || to < 0) return;
|
|
203
|
+
const [moved] = next.splice(from, 1);
|
|
204
|
+
if (!moved) return;
|
|
205
|
+
next.splice(to, 0, moved);
|
|
206
|
+
tabs.value = next;
|
|
207
|
+
persist();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Restore persisted tabs, then let the URL win for the active selection so
|
|
211
|
+
// existing deep-links keep working. Call exactly once, from the page's
|
|
212
|
+
// onMounted.
|
|
213
|
+
function restore() {
|
|
214
|
+
const persisted = readPersisted();
|
|
215
|
+
if (persisted) {
|
|
216
|
+
tabs.value = persisted.tabs;
|
|
217
|
+
activeId.value = persisted.activeId;
|
|
218
|
+
}
|
|
219
|
+
const schema = route.query.schema;
|
|
220
|
+
const table = route.query.table;
|
|
221
|
+
if (
|
|
222
|
+
typeof schema === "string" &&
|
|
223
|
+
schema &&
|
|
224
|
+
typeof table === "string" &&
|
|
225
|
+
table
|
|
226
|
+
) {
|
|
227
|
+
const instance =
|
|
228
|
+
typeof route.query.instance === "string" && route.query.instance
|
|
229
|
+
? route.query.instance
|
|
230
|
+
: DEFAULT_INSTANCE_VALUE;
|
|
231
|
+
// Deep-links carry explicit intent (overview row double-click, the
|
|
232
|
+
// inspector's "browse data" button, shared URLs): open permanently, the
|
|
233
|
+
// preview slot is for casual sidebar browsing only.
|
|
234
|
+
openTab(schema, instance, table, false);
|
|
235
|
+
} else if (!schema && activeTab.value) {
|
|
236
|
+
// Only reclaim the URL when it carries no selection at all: a
|
|
237
|
+
// schema-only deep-link (?schema=X) is the sidebar's to honour and
|
|
238
|
+
// must not be clobbered by the restored tab.
|
|
239
|
+
syncUrl();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
tabs,
|
|
245
|
+
activeId,
|
|
246
|
+
activeTab,
|
|
247
|
+
hasMultipleSchemas,
|
|
248
|
+
openTab,
|
|
249
|
+
closeTab,
|
|
250
|
+
activateTab,
|
|
251
|
+
pinTab,
|
|
252
|
+
moveTab,
|
|
253
|
+
restore,
|
|
254
|
+
};
|
|
255
|
+
}
|