@antelopejs/dms-database 0.0.14 → 0.1.0
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/README.md +30 -5
- package/dist/db/models/query_history.model.d.ts +1 -0
- package/dist/db/models/query_history.model.js +6 -0
- package/dist/db/models/query_history.model.js.map +1 -1
- package/dist/db/tables/query_history.table.d.ts +3 -0
- package/dist/db/tables/query_history.table.js +12 -0
- package/dist/db/tables/query_history.table.js.map +1 -1
- package/dist/db/tables/saved_queries.table.d.ts +1 -0
- package/dist/db/tables/saved_queries.table.js +4 -0
- package/dist/db/tables/saved_queries.table.js.map +1 -1
- package/dist/i18n/messages.d.ts +39 -0
- package/dist/i18n/messages.js +64 -0
- package/dist/i18n/messages.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -7
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +11 -0
- package/dist/module.js +55 -0
- package/dist/module.js.map +1 -0
- package/dist/pages/data.d.ts +1 -1
- package/dist/pages/data.js +9 -6
- package/dist/pages/data.js.map +1 -1
- package/dist/pages/diagram.d.ts +9 -0
- package/dist/pages/diagram.js +45 -0
- package/dist/pages/diagram.js.map +1 -0
- package/dist/pages/index.d.ts +1 -0
- package/dist/pages/index.js +1 -0
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/overview.d.ts +4 -1
- package/dist/pages/overview.js +113 -6
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/query.d.ts +1 -1
- package/dist/pages/query.js +22 -6
- package/dist/pages/query.js.map +1 -1
- package/dist/pages/schemas.d.ts +2 -1
- package/dist/pages/schemas.js +143 -6
- package/dist/pages/schemas.js.map +1 -1
- package/dist/routes/data.d.ts +8 -0
- package/dist/routes/data.js +84 -21
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/overview.d.ts +12 -0
- package/dist/routes/overview.js +55 -0
- package/dist/routes/overview.js.map +1 -0
- package/dist/routes/query.d.ts +5 -1
- package/dist/routes/query.js +162 -27
- package/dist/routes/query.js.map +1 -1
- package/dist/routes/tables.d.ts +15 -12
- package/dist/routes/tables.js +95 -55
- package/dist/routes/tables.js.map +1 -1
- package/dist/service/catalog.d.ts +3 -0
- package/dist/service/catalog.js +26 -0
- package/dist/service/catalog.js.map +1 -0
- package/dist/service/health.d.ts +3 -3
- package/dist/service/health.js +17 -14
- package/dist/service/health.js.map +1 -1
- package/dist/service/introspect.d.ts +3 -1
- package/dist/service/introspect.js +131 -43
- package/dist/service/introspect.js.map +1 -1
- package/dist/service/overview.d.ts +9 -0
- package/dist/service/overview.js +113 -0
- package/dist/service/overview.js.map +1 -0
- package/dist/service/queryInspection.d.ts +15 -0
- package/dist/service/queryInspection.js +107 -0
- package/dist/service/queryInspection.js.map +1 -0
- package/dist/service/references.d.ts +9 -0
- package/dist/service/references.js +52 -0
- package/dist/service/references.js.map +1 -0
- package/dist/service/schemaFilters.d.ts +26 -0
- package/dist/service/schemaFilters.js +42 -0
- package/dist/service/schemaFilters.js.map +1 -0
- package/dist/service/stagedSerialization.d.ts +2 -0
- package/dist/service/stagedSerialization.js +38 -10
- package/dist/service/stagedSerialization.js.map +1 -1
- package/dist/service/types.d.ts +11 -1
- package/dist/types/constants.d.ts +2 -0
- package/dist/types/constants.js +3 -1
- package/dist/types/constants.js.map +1 -1
- package/dist/utils/query.d.ts +0 -2
- package/dist/utils/query.js +0 -20
- package/dist/utils/query.js.map +1 -1
- package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
- package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
- package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
- package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
- package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
- package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
- package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
- package/frontend-vue/app/build/data/cellValues.ts +132 -0
- package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
- package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
- package/frontend-vue/app/build/data/recentTables.ts +48 -0
- package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
- package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
- package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
- package/frontend-vue/app/build/diagram/autosave.ts +72 -0
- package/frontend-vue/app/build/diagram/graph.ts +585 -0
- package/frontend-vue/app/build/diagram/history.ts +178 -0
- package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
- package/frontend-vue/app/build/diagram/sync.ts +137 -0
- package/frontend-vue/app/build/query/completion.ts +100 -0
- package/frontend-vue/app/build/query/csv.ts +32 -0
- package/frontend-vue/app/build/query/examples.ts +38 -0
- package/frontend-vue/app/build/query/runs.ts +168 -0
- package/frontend-vue/app/build/query/tableColumns.ts +35 -0
- package/frontend-vue/app/build/query/target.ts +143 -0
- package/frontend-vue/app/build/schemas/filterState.ts +128 -0
- package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
- package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
- package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
- package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
- package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
- package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
- package/frontend-vue/app/components/DataBrowser.vue +126 -0
- package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
- package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
- package/frontend-vue/app/components/DataFilterBar.vue +163 -0
- package/frontend-vue/app/components/DataGrid.vue +737 -499
- package/frontend-vue/app/components/DataGridCell.vue +125 -77
- package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
- package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
- package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
- package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
- package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
- package/frontend-vue/app/components/InstancePicker.vue +195 -0
- package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
- package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
- package/frontend-vue/app/components/QueryConsole.vue +726 -0
- package/frontend-vue/app/components/QueryLibrary.vue +314 -0
- package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
- package/frontend-vue/app/components/RowDrawer.vue +520 -0
- package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
- package/frontend-vue/app/components/SchemaPicker.vue +86 -0
- package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
- package/frontend-vue/app/components/TableInspector.vue +741 -0
- package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
- package/frontend-vue/dms.frontend.ts +5 -14
- package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
- package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
- package/frontend-vue/package.json +2 -1
- package/frontend-vue/pnpm-lock.yaml +2601 -858
- package/frontend-vue/pnpm-workspace.yaml +1 -1
- package/frontend-vue/tests/dataBrowser.test.ts +206 -0
- package/frontend-vue/tests/diagram.test.ts +516 -0
- package/frontend-vue/tests/helpers.test.ts +205 -0
- package/frontend-vue/tests/instanceOptions.test.ts +53 -0
- package/frontend-vue/tests/locales.test.ts +56 -0
- package/frontend-vue/tests/persistence.test.ts +106 -5
- package/frontend-vue/tests/pickerMenus.test.ts +254 -0
- package/frontend-vue/tests/queryConsole.test.ts +282 -0
- package/frontend-vue/tests/queryRuntime.test.ts +2 -2
- package/frontend-vue/tests/registration.test.ts +36 -25
- package/frontend-vue/tests/schemasFilters.test.ts +115 -0
- package/package.json +7 -7
- package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
- package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
- package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
- package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
- package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
- package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
- package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
- package/frontend-vue/app/composables/useQueryStore.ts +0 -117
- package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
- package/frontend-vue/app/custom-pages/database/data.vue +0 -41
- package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
- package/frontend-vue/app/custom-pages/database/query.vue +0 -436
- package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
- package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
- package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
- package/frontend-vue/app/utils/dmsTableView.ts +0 -83
- package/frontend-vue/app/utils/relativeTime.ts +0 -18
|
@@ -1,133 +1,163 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
CROSS_INSTANCE_VALUE,
|
|
4
|
+
DEFAULT_INSTANCE_VALUE,
|
|
5
|
+
useDataBrowserTabs,
|
|
6
|
+
} from '../build/composables/useDataBrowserTabs'
|
|
7
|
+
import { useDatabaseSchemas } from '../build/composables/useDatabaseSchemas'
|
|
8
|
+
import {
|
|
9
|
+
GRID_PAGE_SIZES,
|
|
10
|
+
browseListQuery,
|
|
11
|
+
browseParamsKey,
|
|
12
|
+
browseSelectionQuery,
|
|
13
|
+
useDataBrowserGrid,
|
|
14
|
+
} from '../build/composables/useDataBrowserGrid'
|
|
15
|
+
import { onKeyStroke, useEventListener } from '@vueuse/core'
|
|
16
|
+
import { parseDraft } from '../build/data/cellValues'
|
|
17
|
+
import { useStagedEdits } from '../build/data/stagedEdits'
|
|
18
|
+
import { useStagedSave } from '../build/data/useStagedSave'
|
|
19
|
+
import type { TableSummary } from '../build/composables/useDatabaseSchemas'
|
|
20
|
+
import type { BrowserTab } from '../build/composables/useDataBrowserTabs'
|
|
3
21
|
import type {
|
|
4
22
|
ColumnFilterState,
|
|
5
23
|
GridColumn,
|
|
6
24
|
GridData,
|
|
7
|
-
} from
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
25
|
+
} from '../build/composables/useDataBrowserGrid'
|
|
26
|
+
import { DATABASE_PAGES, tableAccess } from '../build/utils/databaseLinks'
|
|
27
|
+
import { type ColumnRole, isPrimaryKey } from '../build/utils/fieldTypes'
|
|
28
|
+
import DataFilterBar from './DataFilterBar.vue'
|
|
29
|
+
import DataGridCell from './DataGridCell.vue'
|
|
30
|
+
import DataGridHeaderCell from './DataGridHeaderCell.vue'
|
|
31
|
+
import JsonCellPanel from './JsonCellPanel.vue'
|
|
32
|
+
import RowDrawer from './RowDrawer.vue'
|
|
33
|
+
|
|
34
|
+
// The grid of one open table. Rows are read a page at a time; a committed
|
|
35
|
+
// cell is staged, not written (D-02): the save bar offers a review of every
|
|
36
|
+
// change before it is written, then the save and its undo for a few seconds.
|
|
37
|
+
|
|
38
|
+
const BROWSE_ENDPOINT = '/api/database/browse'
|
|
39
|
+
// Browse-only columns: the source instance of a cross-instance read.
|
|
40
|
+
const SYNTHETIC_FIELDS = new Set(['_instance'])
|
|
41
|
+
const DEFAULT_COLUMN_WIDTH = 180
|
|
42
|
+
// Primary key assumed when the backend reports none (MongoDB's implicit id).
|
|
43
|
+
const DEFAULT_PRIMARY_KEY = '_id'
|
|
44
|
+
const SEARCH_DEBOUNCE_MS = 300
|
|
45
|
+
const PAGE_WINDOW = 2
|
|
18
46
|
|
|
19
47
|
interface ColumnsResponse {
|
|
20
|
-
columns: string[]
|
|
21
|
-
types: Record<string, string
|
|
22
|
-
primaryKey: string | null
|
|
48
|
+
columns: string[]
|
|
49
|
+
types: Record<string, string>
|
|
50
|
+
primaryKey: string | null
|
|
23
51
|
}
|
|
24
52
|
|
|
25
53
|
interface ListResponse {
|
|
26
|
-
results: Record<string, unknown>[]
|
|
27
|
-
total: number
|
|
28
|
-
|
|
29
|
-
limit: number;
|
|
30
|
-
primaryKey: string | null;
|
|
54
|
+
results: Record<string, unknown>[]
|
|
55
|
+
total: number
|
|
56
|
+
primaryKey: string | null
|
|
31
57
|
}
|
|
32
58
|
|
|
33
59
|
const props = defineProps<{
|
|
34
|
-
tab: BrowserTab
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
60
|
+
tab: BrowserTab
|
|
61
|
+
/** The table as introspected: its declared columns and relations. */
|
|
62
|
+
table?: TableSummary
|
|
63
|
+
}>()
|
|
64
|
+
|
|
65
|
+
const { t, n } = useI18n()
|
|
66
|
+
const { $authFetch } = useAuthFetch()
|
|
67
|
+
const router = useDmsRouter()
|
|
68
|
+
const grid = useDataBrowserGrid()
|
|
69
|
+
const staged = useStagedEdits()
|
|
70
|
+
const { tabs: browserTabs, pinTab, openTab, replaceTab } = useDataBrowserTabs()
|
|
71
|
+
const { open: openDrawer } = useDrawer()
|
|
72
|
+
const { schemas } = useDatabaseSchemas()
|
|
73
|
+
|
|
74
|
+
const state = grid.getState(props.tab.id)
|
|
75
|
+
const readOnly = computed(() => props.tab.instance === CROSS_INSTANCE_VALUE)
|
|
49
76
|
|
|
50
77
|
// --- data loading ---
|
|
51
|
-
const data = ref<GridData | null>(grid.getCache(props.tab.id))
|
|
52
|
-
const loading = ref(false)
|
|
53
|
-
const loadError = ref<string | null>(null)
|
|
54
|
-
let requestSeq = 0
|
|
55
|
-
// Column metadata only depends on the tab
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
const data = ref<GridData | null>(grid.getCache(props.tab.id))
|
|
79
|
+
const loading = ref(false)
|
|
80
|
+
const loadError = ref<string | null>(null)
|
|
81
|
+
let requestSeq = 0
|
|
82
|
+
// Column metadata only depends on the tab, so it is read once per mount.
|
|
83
|
+
const columnsMeta = ref<ColumnsResponse | null>(null)
|
|
84
|
+
|
|
85
|
+
function columnsOf(
|
|
86
|
+
meta: ColumnsResponse | null,
|
|
87
|
+
primaryKey: string | null,
|
|
88
|
+
): GridColumn[] {
|
|
89
|
+
const sampled = meta?.columns ?? []
|
|
90
|
+
const names =
|
|
91
|
+
sampled.length > 0 ? sampled : Object.keys(props.table?.fields ?? {})
|
|
92
|
+
return names.map((name) => ({
|
|
93
|
+
name,
|
|
94
|
+
type: meta?.types[name] ?? 'string',
|
|
95
|
+
isPrimaryKey: name === (primaryKey ?? DEFAULT_PRIMARY_KEY),
|
|
96
|
+
}))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fetchPage(force: boolean) {
|
|
100
|
+
const selection = browseSelectionQuery(props.tab)
|
|
101
|
+
const needColumns = force || columnsMeta.value === null
|
|
102
|
+
return Promise.all([
|
|
103
|
+
needColumns
|
|
104
|
+
? // A columns failure must not discard the rows: the grid falls back
|
|
105
|
+
// to the introspected fields.
|
|
106
|
+
$authFetch<ColumnsResponse>(`${BROWSE_ENDPOINT}/columns`, {
|
|
107
|
+
query: selection,
|
|
108
|
+
}).catch(() => null)
|
|
109
|
+
: Promise.resolve(columnsMeta.value),
|
|
110
|
+
$authFetch<ListResponse>(`${BROWSE_ENDPOINT}/list`, {
|
|
111
|
+
query: { ...selection, ...browseListQuery(state) },
|
|
112
|
+
}),
|
|
113
|
+
])
|
|
114
|
+
}
|
|
59
115
|
|
|
60
116
|
async function load(force = false) {
|
|
61
|
-
const key = browseParamsKey(props.tab, state)
|
|
62
|
-
const cached = grid.getCache(props.tab.id)
|
|
117
|
+
const key = browseParamsKey(props.tab, state)
|
|
118
|
+
const cached = grid.getCache(props.tab.id)
|
|
63
119
|
if (!force && cached && cached.paramsKey === key) {
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
loading.value = false;
|
|
72
|
-
return;
|
|
120
|
+
// Retire a request in flight: its late answer would overwrite the cache
|
|
121
|
+
// with rows for parameters the user has left.
|
|
122
|
+
requestSeq += 1
|
|
123
|
+
data.value = cached
|
|
124
|
+
loadError.value = null
|
|
125
|
+
loading.value = false
|
|
126
|
+
return
|
|
73
127
|
}
|
|
74
|
-
const seq = ++requestSeq
|
|
75
|
-
loading.value = true
|
|
76
|
-
loadError.value = null
|
|
128
|
+
const seq = ++requestSeq
|
|
129
|
+
loading.value = true
|
|
130
|
+
loadError.value = null
|
|
77
131
|
try {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// grid falls back to schema-introspected field names instead.
|
|
84
|
-
$authFetch<ColumnsResponse>(`${BROWSE_ENDPOINT}/columns`, {
|
|
85
|
-
query: selection,
|
|
86
|
-
}).catch(() => null)
|
|
87
|
-
: Promise.resolve(columnsMeta.value),
|
|
88
|
-
$authFetch<ListResponse>(`${BROWSE_ENDPOINT}/list`, {
|
|
89
|
-
query: { ...selection, ...browseListQuery(state) },
|
|
90
|
-
}),
|
|
91
|
-
]);
|
|
92
|
-
// Ignore a response that arrived after the params changed again.
|
|
93
|
-
if (seq !== requestSeq) return;
|
|
94
|
-
if (columnsRes) columnsMeta.value = columnsRes;
|
|
95
|
-
const meta = columnsMeta.value;
|
|
96
|
-
const primaryKey = listRes.primaryKey ?? meta?.primaryKey ?? null;
|
|
97
|
-
const sampled = meta?.columns ?? [];
|
|
98
|
-
const names = sampled.length > 0 ? sampled : props.fallbackFields;
|
|
132
|
+
const [columnsRes, listRes] = await fetchPage(force)
|
|
133
|
+
if (seq !== requestSeq) return
|
|
134
|
+
if (columnsRes) columnsMeta.value = columnsRes
|
|
135
|
+
const primaryKey =
|
|
136
|
+
listRes.primaryKey ?? columnsMeta.value?.primaryKey ?? null
|
|
99
137
|
const next: GridData = {
|
|
100
|
-
columns:
|
|
101
|
-
name,
|
|
102
|
-
type: meta?.types[name] ?? "string",
|
|
103
|
-
isPrimaryKey: name === (primaryKey ?? DEFAULT_PRIMARY_KEY),
|
|
104
|
-
})),
|
|
138
|
+
columns: columnsOf(columnsMeta.value, primaryKey),
|
|
105
139
|
rows: listRes.results ?? [],
|
|
106
140
|
total: listRes.total ?? 0,
|
|
107
141
|
primaryKey,
|
|
108
142
|
paramsKey: key,
|
|
109
|
-
}
|
|
110
|
-
grid.setCache(props.tab.id, next)
|
|
111
|
-
data.value = next
|
|
112
|
-
// An open positional editor whose row no longer sits at the same index
|
|
113
|
-
// must not survive a rows replacement (commit would target another row).
|
|
114
|
-
const editingCell = editing.value;
|
|
143
|
+
}
|
|
144
|
+
grid.setCache(props.tab.id, next)
|
|
145
|
+
data.value = next
|
|
115
146
|
if (
|
|
116
|
-
|
|
117
|
-
rowIdOf(next.rows[
|
|
147
|
+
editing.value &&
|
|
148
|
+
rowIdOf(next.rows[editing.value.r] ?? {}) !== editing.value.rowId
|
|
118
149
|
) {
|
|
119
|
-
cancelEdit()
|
|
150
|
+
cancelEdit()
|
|
120
151
|
}
|
|
121
|
-
//
|
|
122
|
-
// deleted elsewhere): correcting state.page re-triggers the watcher.
|
|
152
|
+
// Rows deleted elsewhere can leave the page past the end.
|
|
123
153
|
if (next.total > 0 && state.page * state.pageSize >= next.total) {
|
|
124
|
-
state.page = Math.max(0, Math.ceil(next.total / state.pageSize) - 1)
|
|
154
|
+
state.page = Math.max(0, Math.ceil(next.total / state.pageSize) - 1)
|
|
125
155
|
}
|
|
126
156
|
} catch (error) {
|
|
127
|
-
if (seq !== requestSeq) return
|
|
128
|
-
loadError.value = error instanceof Error ? error.message : String(error)
|
|
157
|
+
if (seq !== requestSeq) return
|
|
158
|
+
loadError.value = error instanceof Error ? error.message : String(error)
|
|
129
159
|
} finally {
|
|
130
|
-
if (seq === requestSeq) loading.value = false
|
|
160
|
+
if (seq === requestSeq) loading.value = false
|
|
131
161
|
}
|
|
132
162
|
}
|
|
133
163
|
|
|
@@ -135,500 +165,550 @@ watch(
|
|
|
135
165
|
() => browseParamsKey(props.tab, state),
|
|
136
166
|
() => load(),
|
|
137
167
|
{ immediate: true },
|
|
138
|
-
)
|
|
168
|
+
)
|
|
139
169
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// else re-triggers the load (fallbackFields is not part of the params key).
|
|
170
|
+
// A table cached empty while the schemas were still loading recovers once
|
|
171
|
+
// its introspected fields arrive.
|
|
143
172
|
watch(
|
|
144
|
-
() => props.
|
|
145
|
-
(
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
(data.value?.columns.length ?? 0) === 0 &&
|
|
149
|
-
!loading.value
|
|
150
|
-
) {
|
|
151
|
-
load(true);
|
|
152
|
-
}
|
|
173
|
+
() => props.table,
|
|
174
|
+
(table) => {
|
|
175
|
+
if (table && (data.value?.columns.length ?? 0) === 0 && !loading.value)
|
|
176
|
+
load(true)
|
|
153
177
|
},
|
|
154
|
-
)
|
|
178
|
+
)
|
|
155
179
|
|
|
156
180
|
function reload() {
|
|
157
|
-
grid.invalidate(props.tab.id)
|
|
158
|
-
load(true)
|
|
181
|
+
grid.invalidate(props.tab.id)
|
|
182
|
+
load(true)
|
|
159
183
|
}
|
|
160
184
|
|
|
161
|
-
const columns = computed(() => data.value?.columns ?? [])
|
|
162
|
-
const rows = computed(() => data.value?.rows ?? [])
|
|
163
|
-
const total = computed(() => data.value?.total ?? 0)
|
|
164
|
-
const rowIdKey = computed(() => data.value?.primaryKey ?? DEFAULT_PRIMARY_KEY)
|
|
185
|
+
const columns = computed(() => data.value?.columns ?? [])
|
|
186
|
+
const rows = computed(() => data.value?.rows ?? [])
|
|
187
|
+
const total = computed(() => data.value?.total ?? 0)
|
|
188
|
+
const rowIdKey = computed(() => data.value?.primaryKey ?? DEFAULT_PRIMARY_KEY)
|
|
189
|
+
const visibleColumns = computed(() =>
|
|
190
|
+
readOnly.value || !columns.value.some((column) => column.name === '_instance')
|
|
191
|
+
? columns.value
|
|
192
|
+
: columns.value.filter((column) => column.name !== '_instance'),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const relationFields = computed(
|
|
196
|
+
() =>
|
|
197
|
+
new Set(
|
|
198
|
+
(props.table?.relations ?? []).map((relation) => relation.fromField),
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
function roleOf(column: GridColumn): ColumnRole {
|
|
203
|
+
if (
|
|
204
|
+
column.isPrimaryKey ||
|
|
205
|
+
isPrimaryKey(column.name, props.table?.indexes ?? {})
|
|
206
|
+
)
|
|
207
|
+
return 'key'
|
|
208
|
+
return relationFields.value.has(column.name) ? 'relation' : 'plain'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function rowIdOf(row: Record<string, unknown>): string | null {
|
|
212
|
+
const id = row[rowIdKey.value]
|
|
213
|
+
return id === null || id === undefined ? null : String(id)
|
|
214
|
+
}
|
|
165
215
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
216
|
+
function isCellEditable(field: string, row: Record<string, unknown>): boolean {
|
|
217
|
+
if (readOnly.value || rowIdOf(row) === null) return false
|
|
218
|
+
return (
|
|
219
|
+
field !== DEFAULT_PRIMARY_KEY &&
|
|
220
|
+
field !== rowIdKey.value &&
|
|
221
|
+
!SYNTHETIC_FIELDS.has(field)
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- toolbar: debounced search ---
|
|
226
|
+
const searchDraft = ref(state.search)
|
|
227
|
+
const searchInput = useTemplateRef<{ inputRef?: HTMLInputElement }>(
|
|
228
|
+
'searchInput',
|
|
229
|
+
)
|
|
230
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
169
231
|
watch(searchDraft, (value) => {
|
|
170
|
-
clearTimeout(searchTimer)
|
|
232
|
+
clearTimeout(searchTimer)
|
|
171
233
|
searchTimer = setTimeout(() => {
|
|
172
|
-
state.search = value.trim()
|
|
173
|
-
state.page = 0
|
|
174
|
-
}, SEARCH_DEBOUNCE_MS)
|
|
175
|
-
})
|
|
234
|
+
state.search = value.trim()
|
|
235
|
+
state.page = 0
|
|
236
|
+
}, SEARCH_DEBOUNCE_MS)
|
|
237
|
+
})
|
|
176
238
|
onBeforeUnmount(() => {
|
|
177
|
-
clearTimeout(searchTimer)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// grid.drop() ran — resurrecting the entry, whose paramsKey then matches a
|
|
181
|
-
// fresh default state on re-open and serves stale rows with no refetch.
|
|
182
|
-
requestSeq += 1;
|
|
183
|
-
// Flush (not drop) a pending draft so a search typed just before switching
|
|
184
|
-
// tabs survives into this tab's state.
|
|
185
|
-
const value = searchDraft.value.trim();
|
|
239
|
+
clearTimeout(searchTimer)
|
|
240
|
+
requestSeq += 1
|
|
241
|
+
const value = searchDraft.value.trim()
|
|
186
242
|
if (value !== state.search) {
|
|
187
|
-
state.search = value
|
|
188
|
-
state.page = 0
|
|
243
|
+
state.search = value
|
|
244
|
+
state.page = 0
|
|
189
245
|
}
|
|
190
|
-
})
|
|
246
|
+
})
|
|
191
247
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
248
|
+
// --- filters, sort, widths ---
|
|
249
|
+
function setFilter(name: string, filter: ColumnFilterState | null) {
|
|
250
|
+
const next = { ...state.filters }
|
|
251
|
+
if (filter) next[name] = filter
|
|
252
|
+
else Reflect.deleteProperty(next, name)
|
|
253
|
+
state.filters = next
|
|
254
|
+
state.page = 0
|
|
255
|
+
}
|
|
195
256
|
|
|
196
257
|
function clearFilters() {
|
|
197
|
-
state.filters = {}
|
|
198
|
-
state.page = 0
|
|
258
|
+
state.filters = {}
|
|
259
|
+
state.page = 0
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function clearRefinements() {
|
|
263
|
+
state.filters = {}
|
|
264
|
+
state.search = ''
|
|
265
|
+
searchDraft.value = ''
|
|
266
|
+
state.page = 0
|
|
199
267
|
}
|
|
200
268
|
|
|
201
|
-
// --- sorting / filtering / resize ---
|
|
202
269
|
function toggleSort(name: string) {
|
|
203
270
|
if (state.sortKey !== name) {
|
|
204
|
-
state.sortKey = name
|
|
205
|
-
state.sortDirection =
|
|
206
|
-
} else if (state.sortDirection ===
|
|
207
|
-
state.sortDirection =
|
|
271
|
+
state.sortKey = name
|
|
272
|
+
state.sortDirection = 'asc'
|
|
273
|
+
} else if (state.sortDirection === 'asc') {
|
|
274
|
+
state.sortDirection = 'desc'
|
|
208
275
|
} else {
|
|
209
|
-
state.sortKey = null
|
|
210
|
-
state.sortDirection =
|
|
276
|
+
state.sortKey = null
|
|
277
|
+
state.sortDirection = 'asc'
|
|
211
278
|
}
|
|
212
|
-
state.page = 0
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function setFilter(name: string, filter: ColumnFilterState | null) {
|
|
216
|
-
const next = { ...state.filters };
|
|
217
|
-
if (filter) next[name] = filter;
|
|
218
|
-
else Reflect.deleteProperty(next, name);
|
|
219
|
-
state.filters = next;
|
|
220
|
-
state.page = 0;
|
|
279
|
+
state.page = 0
|
|
221
280
|
}
|
|
222
281
|
|
|
223
282
|
function widthFor(name: string): number {
|
|
224
|
-
return state.columnWidths[name] ?? DEFAULT_COLUMN_WIDTH
|
|
283
|
+
return state.columnWidths[name] ?? DEFAULT_COLUMN_WIDTH
|
|
225
284
|
}
|
|
285
|
+
|
|
226
286
|
function resizeColumn(name: string, width: number) {
|
|
227
|
-
state.columnWidths = { ...state.columnWidths, [name]: width }
|
|
287
|
+
state.columnWidths = { ...state.columnWidths, [name]: width }
|
|
228
288
|
}
|
|
229
289
|
|
|
230
290
|
// --- pagination ---
|
|
231
291
|
const pageSizeOptions = GRID_PAGE_SIZES.map((size) => ({
|
|
232
292
|
label: String(size),
|
|
233
293
|
value: size,
|
|
234
|
-
}))
|
|
294
|
+
}))
|
|
295
|
+
const pageCount = computed(() =>
|
|
296
|
+
Math.max(1, Math.ceil(total.value / state.pageSize)),
|
|
297
|
+
)
|
|
235
298
|
const pageStart = computed(() =>
|
|
236
299
|
total.value === 0 ? 0 : state.page * state.pageSize + 1,
|
|
237
|
-
)
|
|
300
|
+
)
|
|
238
301
|
const pageEnd = computed(() =>
|
|
239
302
|
Math.min(total.value, (state.page + 1) * state.pageSize),
|
|
240
|
-
)
|
|
241
|
-
const
|
|
303
|
+
)
|
|
304
|
+
const pages = computed(() => {
|
|
305
|
+
const last = pageCount.value - 1
|
|
306
|
+
const around = new Set([0, last])
|
|
307
|
+
for (
|
|
308
|
+
let page = state.page - PAGE_WINDOW;
|
|
309
|
+
page <= state.page + PAGE_WINDOW;
|
|
310
|
+
page += 1
|
|
311
|
+
) {
|
|
312
|
+
if (page >= 0 && page <= last) around.add(page)
|
|
313
|
+
}
|
|
314
|
+
return [...around].sort((a, b) => a - b)
|
|
315
|
+
})
|
|
316
|
+
|
|
242
317
|
function setPageSize(size: number) {
|
|
243
|
-
state.pageSize = size
|
|
244
|
-
state.page = 0
|
|
318
|
+
state.pageSize = size
|
|
319
|
+
state.page = 0
|
|
245
320
|
}
|
|
246
321
|
|
|
247
|
-
// ---
|
|
248
|
-
function schemaAccessExpr(schemaId: string): string {
|
|
249
|
-
// Dotted access for valid identifiers, bracket access otherwise — mirrors
|
|
250
|
-
// the query builder's own schema resolution.
|
|
251
|
-
return /^[A-Z_$][\w$]*$/i.test(schemaId)
|
|
252
|
-
? `schemas.${schemaId}`
|
|
253
|
-
: `schemas[${JSON.stringify(schemaId)}]`;
|
|
254
|
-
}
|
|
255
|
-
function instanceArgExpr(): string {
|
|
256
|
-
if (props.tab.instance === DEFAULT_INSTANCE_VALUE) return "";
|
|
257
|
-
if (props.tab.instance === CROSS_INSTANCE_VALUE) return "CROSS_INSTANCE";
|
|
258
|
-
return JSON.stringify(props.tab.instance);
|
|
259
|
-
}
|
|
322
|
+
// --- links out ---
|
|
260
323
|
function openInQueryConsole() {
|
|
261
|
-
const
|
|
262
|
-
|
|
324
|
+
const instance =
|
|
325
|
+
props.tab.instance === DEFAULT_INSTANCE_VALUE || readOnly.value
|
|
326
|
+
? undefined
|
|
327
|
+
: props.tab.instance
|
|
328
|
+
const source = `${tableAccess({ schema: props.tab.schema, table: props.tab.table, instance })}.slice(0, 100)`
|
|
329
|
+
router.push({ path: DATABASE_PAGES.query, query: { source } })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const namedInstances = computed(
|
|
333
|
+
() =>
|
|
334
|
+
schemas.value.find((schema) => schema.id === props.tab.schema)?.instances ??
|
|
335
|
+
[],
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
// The tab moves to the other instance in place; a tab holding staged edits
|
|
339
|
+
// keeps them, and the instance opens next to it instead.
|
|
340
|
+
function switchInstance(instance: string) {
|
|
341
|
+
if (staged.count(props.tab.id) > 0) {
|
|
342
|
+
openTab(props.tab.schema, instance, props.tab.table, false)
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
replaceTab(props.tab.id, props.tab.schema, instance, props.tab.table)
|
|
263
346
|
}
|
|
264
347
|
|
|
265
|
-
// --- cell focus
|
|
266
|
-
const scroller = useTemplateRef<HTMLDivElement>(
|
|
267
|
-
const focused = ref<{ r: number; c: number } | null>(null)
|
|
348
|
+
// --- cell focus and keyboard ---
|
|
349
|
+
const scroller = useTemplateRef<HTMLDivElement>('scroller')
|
|
350
|
+
const focused = ref<{ r: number; c: number } | null>(null)
|
|
268
351
|
|
|
269
352
|
function focusCell(r: number, c: number) {
|
|
270
|
-
focused.value = { r, c }
|
|
353
|
+
focused.value = { r, c }
|
|
271
354
|
nextTick(() => {
|
|
272
355
|
scroller.value
|
|
273
356
|
?.querySelector(`[data-cell="${r}:${c}"]`)
|
|
274
|
-
?.scrollIntoView({ block:
|
|
275
|
-
})
|
|
357
|
+
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const MOVES: Record<
|
|
362
|
+
string,
|
|
363
|
+
(cell: { r: number; c: number }) => { r: number; c: number }
|
|
364
|
+
> = {
|
|
365
|
+
ArrowUp: ({ r, c }) => ({ r: r - 1, c }),
|
|
366
|
+
ArrowDown: ({ r, c }) => ({ r: r + 1, c }),
|
|
367
|
+
ArrowLeft: ({ r, c }) => ({ r, c: c - 1 }),
|
|
368
|
+
ArrowRight: ({ r, c }) => ({ r, c: c + 1 }),
|
|
276
369
|
}
|
|
277
370
|
|
|
278
371
|
function onKeydown(event: KeyboardEvent) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
r += 1;
|
|
291
|
-
break;
|
|
292
|
-
case "ArrowLeft":
|
|
293
|
-
c -= 1;
|
|
294
|
-
break;
|
|
295
|
-
case "ArrowRight":
|
|
296
|
-
c += 1;
|
|
297
|
-
break;
|
|
298
|
-
case "Tab": {
|
|
299
|
-
// Tab past either edge releases focus to the browser (no trap): the
|
|
300
|
-
// user can reach the toolbar/footer instead of being clamped inside.
|
|
301
|
-
const nextColumn = c + (event.shiftKey ? -1 : 1);
|
|
302
|
-
if (nextColumn < 0 || nextColumn > maxC) {
|
|
303
|
-
focused.value = null;
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
c = nextColumn;
|
|
307
|
-
break;
|
|
308
|
-
}
|
|
309
|
-
case "Enter":
|
|
310
|
-
event.preventDefault();
|
|
311
|
-
openEditorAt(current.r, current.c);
|
|
312
|
-
return;
|
|
313
|
-
case "Escape":
|
|
314
|
-
focused.value = null;
|
|
315
|
-
return;
|
|
316
|
-
default:
|
|
317
|
-
return;
|
|
372
|
+
const current = focused.value
|
|
373
|
+
if (editing.value || !current) return
|
|
374
|
+
if (event.key === 'Enter') {
|
|
375
|
+
event.preventDefault()
|
|
376
|
+
openEditorAt(current.r, current.c)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (event.key === ' ') {
|
|
380
|
+
event.preventDefault()
|
|
381
|
+
openRow(current.r)
|
|
382
|
+
return
|
|
318
383
|
}
|
|
319
|
-
event.
|
|
320
|
-
|
|
384
|
+
if (event.key === 'Escape') {
|
|
385
|
+
focused.value = null
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
const move = MOVES[event.key]
|
|
389
|
+
if (!move) return
|
|
390
|
+
event.preventDefault()
|
|
391
|
+
const next = move(current)
|
|
392
|
+
focusCell(
|
|
393
|
+
Math.max(0, Math.min(rows.value.length - 1, next.r)),
|
|
394
|
+
Math.max(0, Math.min(visibleColumns.value.length - 1, next.c)),
|
|
395
|
+
)
|
|
321
396
|
}
|
|
322
397
|
|
|
323
|
-
// --- inline editing ---
|
|
324
|
-
// Both editors carry the target rowId captured at open time: a mid-edit reload
|
|
325
|
-
// replaces the rows array, and a stale positional index would silently write
|
|
326
|
-
// into whichever row now sits at that position. r/c only locate the visual
|
|
327
|
-
// cell; every write resolves the row by id at commit time.
|
|
398
|
+
// --- inline editing: a commit stages the value ---
|
|
328
399
|
const editing = ref<{
|
|
329
|
-
r: number
|
|
330
|
-
c: number
|
|
331
|
-
rowId: string
|
|
332
|
-
field: string
|
|
333
|
-
} | null>(null)
|
|
334
|
-
const
|
|
335
|
-
const pendingCell = ref<{ rowId: string; field: string } | null>(null);
|
|
336
|
-
const savingJson = ref(false);
|
|
400
|
+
r: number
|
|
401
|
+
c: number
|
|
402
|
+
rowId: string
|
|
403
|
+
field: string
|
|
404
|
+
} | null>(null)
|
|
405
|
+
const invalidReason = ref<string | null>(null)
|
|
337
406
|
const jsonPanel = ref<{
|
|
338
|
-
rowId: string | null
|
|
339
|
-
field: string
|
|
340
|
-
value: unknown
|
|
341
|
-
readOnly: boolean
|
|
342
|
-
} | null>(null)
|
|
343
|
-
|
|
344
|
-
function
|
|
345
|
-
|
|
407
|
+
rowId: string | null
|
|
408
|
+
field: string
|
|
409
|
+
value: unknown
|
|
410
|
+
readOnly: boolean
|
|
411
|
+
} | null>(null)
|
|
412
|
+
|
|
413
|
+
function stagedCell(row: Record<string, unknown>, field: string) {
|
|
414
|
+
const rowId = rowIdOf(row)
|
|
415
|
+
return rowId === null ? undefined : staged.get(props.tab.id, rowId, field)
|
|
346
416
|
}
|
|
347
417
|
|
|
348
|
-
function
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (SYNTHETIC_FIELDS.has(field)) return false;
|
|
352
|
-
return true;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function rowIdOf(row: Record<string, unknown>): string | null {
|
|
356
|
-
const id = row[rowIdKey.value];
|
|
357
|
-
if (id === null || id === undefined) return null;
|
|
358
|
-
return String(id);
|
|
418
|
+
function currentValue(row: Record<string, unknown>, field: string): unknown {
|
|
419
|
+
const cell = stagedCell(row, field)
|
|
420
|
+
return cell ? cell.after : row[field]
|
|
359
421
|
}
|
|
360
422
|
|
|
361
423
|
function openEditorAt(r: number, c: number) {
|
|
362
|
-
const column =
|
|
363
|
-
const row = rows.value[r]
|
|
364
|
-
if (!column || !row) return
|
|
365
|
-
const value = row
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
// when the field itself is writable and the row is addressable — the
|
|
369
|
-
// backend silently drops writes to protected fields, so offering a Save
|
|
370
|
-
// there would fake success.
|
|
424
|
+
const column = visibleColumns.value[c]
|
|
425
|
+
const row = rows.value[r]
|
|
426
|
+
if (!column || !row) return
|
|
427
|
+
const value = currentValue(row, column.name)
|
|
428
|
+
const rowId = rowIdOf(row)
|
|
429
|
+
if (value !== null && typeof value === 'object') {
|
|
371
430
|
jsonPanel.value = {
|
|
372
|
-
rowId
|
|
431
|
+
rowId,
|
|
373
432
|
field: column.name,
|
|
374
433
|
value,
|
|
375
|
-
readOnly: !isCellEditable(column.name
|
|
376
|
-
}
|
|
377
|
-
return
|
|
434
|
+
readOnly: !isCellEditable(column.name, row),
|
|
435
|
+
}
|
|
436
|
+
return
|
|
378
437
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
invalidDraft.value = false;
|
|
385
|
-
editing.value = { r, c, rowId, field: column.name };
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Refocusing the grid after an editor closes must not steal focus from a
|
|
389
|
-
// control the user has since clicked (toolbar search, another cell's editor):
|
|
390
|
-
// only reclaim it when the blur left focus on the document body.
|
|
438
|
+
if (!isCellEditable(column.name, row) || rowId === null) return
|
|
439
|
+
invalidReason.value = null
|
|
440
|
+
editing.value = { r, c, rowId, field: column.name }
|
|
441
|
+
}
|
|
442
|
+
|
|
391
443
|
function refocusGrid() {
|
|
392
444
|
nextTick(() => {
|
|
393
|
-
if (document.activeElement === document.body) scroller.value?.focus()
|
|
394
|
-
})
|
|
445
|
+
if (document.activeElement === document.body) scroller.value?.focus()
|
|
446
|
+
})
|
|
395
447
|
}
|
|
396
448
|
|
|
397
449
|
function cancelEdit() {
|
|
398
|
-
editing.value = null
|
|
399
|
-
|
|
400
|
-
refocusGrid()
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Shared persistence for both editors: PUT one field, then merge the echoed
|
|
404
|
-
// row into the row the grid currently renders (resolved by id AFTER the await
|
|
405
|
-
// — a concurrent reload may have replaced the rows array, and assigning into
|
|
406
|
-
// the pre-await capture would update a detached object).
|
|
407
|
-
async function persistField(rowId: string, field: string, value: unknown) {
|
|
408
|
-
const updated = await $authFetch<Record<string, unknown>>(
|
|
409
|
-
`${BROWSE_ENDPOINT}/edit`,
|
|
410
|
-
{
|
|
411
|
-
method: "PUT",
|
|
412
|
-
query: { ...browseSelectionQuery(props.tab), id: rowId },
|
|
413
|
-
body: { [field]: value },
|
|
414
|
-
},
|
|
415
|
-
);
|
|
416
|
-
const live = rows.value.find((candidate) => rowIdOf(candidate) === rowId);
|
|
417
|
-
if (live) Object.assign(live, updated);
|
|
418
|
-
// A table the user edited is "modified" in the VS Code sense: pin its
|
|
419
|
-
// preview tab so the next sidebar click doesn't silently discard it. Gate
|
|
420
|
-
// on the tab OBJECT still being open — the id is content-derived, so a
|
|
421
|
-
// slow edit whose tab was replaced then reopened would otherwise pin the
|
|
422
|
-
// fresh preview without any pin gesture.
|
|
423
|
-
if (browserTabs.value.includes(props.tab)) pinTab(props.tab.id);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
async function commitCell(draftValue: string | boolean, source: "enter" | "blur") {
|
|
427
|
-
const cell = editing.value;
|
|
428
|
-
if (!cell) return;
|
|
429
|
-
const column = columnAt(cell.c);
|
|
430
|
-
// Resolve by the rowId captured at open time — never by position.
|
|
431
|
-
const row = cell.rowId
|
|
432
|
-
? rows.value.find((candidate) => rowIdOf(candidate) === cell.rowId)
|
|
433
|
-
: undefined;
|
|
434
|
-
if (!column || !row) {
|
|
435
|
-
cancelEdit();
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
const before = row[column.name];
|
|
439
|
-
|
|
440
|
-
// Preserve the stored type; a NULL cell falls back to the column's sampled
|
|
441
|
-
// type so filling it can't silently flip a number/boolean column to string.
|
|
442
|
-
const targetType =
|
|
443
|
-
before === null || before === undefined ? column.type : typeof before;
|
|
444
|
-
let next: string | number | boolean;
|
|
445
|
-
if (targetType === "number") {
|
|
446
|
-
const text = typeof draftValue === "string" ? draftValue.trim() : "";
|
|
447
|
-
const parsed = Number(text);
|
|
448
|
-
if (text === "" || !Number.isFinite(parsed)) {
|
|
449
|
-
// Enter keeps the editor open to fix the draft; a blur-commit must not
|
|
450
|
-
// leave an unfocused editor behind (it would deaden grid keyboard nav),
|
|
451
|
-
// so click-away discards the invalid draft.
|
|
452
|
-
if (source === "blur") cancelEdit();
|
|
453
|
-
else invalidDraft.value = true;
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
next = parsed;
|
|
457
|
-
} else if (targetType === "boolean") {
|
|
458
|
-
const text =
|
|
459
|
-
typeof draftValue === "string" ? draftValue.trim().toLowerCase() : draftValue;
|
|
460
|
-
if (text === true || text === "true") next = true;
|
|
461
|
-
else if (text === false || text === "false") next = false;
|
|
462
|
-
else {
|
|
463
|
-
if (source === "blur") cancelEdit();
|
|
464
|
-
else invalidDraft.value = true;
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
} else {
|
|
468
|
-
next = String(draftValue);
|
|
469
|
-
}
|
|
450
|
+
editing.value = null
|
|
451
|
+
invalidReason.value = null
|
|
452
|
+
refocusGrid()
|
|
453
|
+
}
|
|
470
454
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if (
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
)
|
|
478
|
-
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
455
|
+
function stage(rowId: string, field: string, value: unknown) {
|
|
456
|
+
const row = rows.value.find((candidate) => rowIdOf(candidate) === rowId)
|
|
457
|
+
if (!row) return
|
|
458
|
+
staged.stage(props.tab.id, { rowId, field, before: row[field], after: value })
|
|
459
|
+
// A table with staged edits is kept: the next table opened from the list
|
|
460
|
+
// must not replace it.
|
|
461
|
+
if (browserTabs.value.includes(props.tab)) pinTab(props.tab.id)
|
|
462
|
+
}
|
|
481
463
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
464
|
+
function commitCell(draft: string | boolean, source: 'enter' | 'blur') {
|
|
465
|
+
const cell = editing.value
|
|
466
|
+
if (!cell) return
|
|
467
|
+
const column = visibleColumns.value[cell.c]
|
|
468
|
+
const row = rows.value.find((candidate) => rowIdOf(candidate) === cell.rowId)
|
|
469
|
+
if (!column || !row) return cancelEdit()
|
|
470
|
+
const result = parseDraft(draft, currentValue(row, column.name), column.type)
|
|
471
|
+
if (!result.ok) {
|
|
472
|
+
// Enter keeps the editor open to fix the draft; a click elsewhere drops it.
|
|
473
|
+
if (source === 'blur') return cancelEdit()
|
|
474
|
+
invalidReason.value = t(`dms_database.data.grid.invalid_${result.reason}`)
|
|
475
|
+
return
|
|
486
476
|
}
|
|
477
|
+
if (!result.unchanged) stage(cell.rowId, column.name, result.value)
|
|
478
|
+
editing.value = null
|
|
479
|
+
invalidReason.value = null
|
|
480
|
+
refocusGrid()
|
|
481
|
+
}
|
|
487
482
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
483
|
+
function stageJson(value: unknown) {
|
|
484
|
+
const panel = jsonPanel.value
|
|
485
|
+
if (!panel || panel.readOnly || !panel.rowId) return
|
|
486
|
+
stage(panel.rowId, panel.field, value)
|
|
487
|
+
jsonPanel.value = null
|
|
488
|
+
}
|
|
494
489
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
490
|
+
// --- staged changes: review, save, undo ---
|
|
491
|
+
const pendingCount = computed(() => staged.count(props.tab.id))
|
|
492
|
+
const changedFields = computed(() => [
|
|
493
|
+
...new Set(staged.list(props.tab.id).map((change) => change.field)),
|
|
494
|
+
])
|
|
495
|
+
const {
|
|
496
|
+
saving,
|
|
497
|
+
review,
|
|
498
|
+
notice: saveNotice,
|
|
499
|
+
undo,
|
|
500
|
+
} = useStagedSave({
|
|
501
|
+
tab: () => props.tab,
|
|
502
|
+
onSaved: reload,
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
onKeyStroke('s', (event) => {
|
|
506
|
+
if (!(event.metaKey || event.ctrlKey) || pendingCount.value === 0) return
|
|
507
|
+
event.preventDefault()
|
|
508
|
+
review()
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
// --- the row drawer ---
|
|
512
|
+
function rowNavigation(index: number) {
|
|
513
|
+
return {
|
|
514
|
+
index,
|
|
515
|
+
total: rows.value.length,
|
|
516
|
+
hasPrev: index > 0,
|
|
517
|
+
hasNext: index < rows.value.length - 1,
|
|
518
|
+
prev: () => showRow(index - 1),
|
|
519
|
+
next: () => showRow(index + 1),
|
|
505
520
|
}
|
|
506
521
|
}
|
|
507
522
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
523
|
+
let drawer: ReturnType<typeof openDrawer> | null = null
|
|
524
|
+
|
|
525
|
+
function rowDrawerOptions(index: number) {
|
|
526
|
+
const row = rows.value[index] ?? {}
|
|
527
|
+
return {
|
|
528
|
+
componentKey: `${props.tab.id}:${rowIdOf(row) ?? index}`,
|
|
529
|
+
title: t('dms_database.data.row.title', { table: props.tab.table }),
|
|
530
|
+
componentOptions: {
|
|
531
|
+
tab: props.tab,
|
|
532
|
+
row,
|
|
533
|
+
rowId: rowIdOf(row),
|
|
534
|
+
columns: visibleColumns.value,
|
|
535
|
+
table: props.table,
|
|
536
|
+
readOnly: readOnly.value,
|
|
537
|
+
navigation: rowNavigation(index),
|
|
538
|
+
onStage: stage,
|
|
539
|
+
onReview: review,
|
|
540
|
+
},
|
|
519
541
|
}
|
|
520
542
|
}
|
|
521
543
|
|
|
522
|
-
function
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
(error as { status?: number } | null)?.status;
|
|
526
|
-
if (status === 404) {
|
|
527
|
-
// The row vanished under us: tell the user, drop any open editor that
|
|
528
|
-
// still points at it, and resync the page.
|
|
529
|
-
toast.add({ title: t("dms_database.data.rowVanished"), color: "warning" });
|
|
530
|
-
jsonPanel.value = null;
|
|
531
|
-
reload();
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
const description = error instanceof Error ? error.message : undefined;
|
|
535
|
-
toast.add({
|
|
536
|
-
title: t("dms_database.data.editError"),
|
|
537
|
-
description,
|
|
538
|
-
color: "error",
|
|
539
|
-
});
|
|
544
|
+
function showRow(index: number) {
|
|
545
|
+
if (index < 0 || index >= rows.value.length) return
|
|
546
|
+
drawer?.patch(rowDrawerOptions(index))
|
|
540
547
|
}
|
|
541
548
|
|
|
542
|
-
function
|
|
543
|
-
|
|
544
|
-
|
|
549
|
+
function openRow(index: number) {
|
|
550
|
+
if (!rows.value[index]) return
|
|
551
|
+
drawer = openDrawer({
|
|
552
|
+
direction: 'right',
|
|
553
|
+
component: RowDrawer,
|
|
554
|
+
...rowDrawerOptions(index),
|
|
555
|
+
})
|
|
556
|
+
drawer.result.finally(() => {
|
|
557
|
+
drawer = null
|
|
558
|
+
})
|
|
545
559
|
}
|
|
546
560
|
|
|
547
|
-
// Cross-instance unions can repeat
|
|
548
|
-
// the instance tag joins the key to keep v-for keys unique.
|
|
561
|
+
// Cross-instance unions can repeat a primary key across instances.
|
|
549
562
|
function rowKey(row: Record<string, unknown>, index: number): string | number {
|
|
550
|
-
const id = rowIdOf(row)
|
|
551
|
-
if (id === null) return index
|
|
552
|
-
return readOnly.value ? `${String(row._instance ??
|
|
563
|
+
const id = rowIdOf(row)
|
|
564
|
+
if (id === null) return index
|
|
565
|
+
return readOnly.value ? `${String(row._instance ?? '')}::${id}` : id
|
|
553
566
|
}
|
|
567
|
+
|
|
568
|
+
const hasRefinements = computed(
|
|
569
|
+
() =>
|
|
570
|
+
state.search !== '' ||
|
|
571
|
+
Object.values(state.filters).some((filter) => filter.value),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
// ⌘ / (Ctrl / elsewhere) focuses the row search, as every DMS page search:
|
|
575
|
+
// "/" alone belongs to the navigation search.
|
|
576
|
+
useEventListener('keydown', (event: KeyboardEvent) => {
|
|
577
|
+
if (event.key === '/' && (event.metaKey || event.ctrlKey)) {
|
|
578
|
+
event.preventDefault()
|
|
579
|
+
searchInput.value?.inputRef?.focus()
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
const target = event.target as HTMLElement | null
|
|
583
|
+
if (target?.closest('input, textarea, select, [contenteditable=true]')) return
|
|
584
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return
|
|
585
|
+
if (event.key === 'r' && !editing.value) reload()
|
|
586
|
+
})
|
|
554
587
|
</script>
|
|
555
588
|
|
|
556
589
|
<template>
|
|
557
590
|
<div class="flex min-h-0 flex-1 flex-col">
|
|
558
|
-
|
|
559
|
-
|
|
591
|
+
<div
|
|
592
|
+
class="border-default flex flex-wrap items-center gap-2 border-b px-3 py-2"
|
|
593
|
+
>
|
|
594
|
+
<div class="flex items-baseline gap-2">
|
|
595
|
+
<span class="text-highlighted font-mono text-sm font-semibold">
|
|
596
|
+
{{ tab.table }}
|
|
597
|
+
</span>
|
|
598
|
+
<span class="text-dimmed font-mono text-xs tabular-nums">
|
|
599
|
+
{{ t('dms_database.data.grid.rows', { count: n(total) }, total) }}
|
|
600
|
+
</span>
|
|
601
|
+
</div>
|
|
560
602
|
<UInput
|
|
603
|
+
ref="searchInput"
|
|
561
604
|
v-model="searchDraft"
|
|
562
605
|
icon="i-ph-magnifying-glass"
|
|
563
|
-
:placeholder="t('dms_database.data.searchPlaceholder')"
|
|
564
606
|
size="sm"
|
|
565
607
|
class="w-64"
|
|
566
|
-
|
|
567
|
-
<UBadge
|
|
568
|
-
v-if="activeFilterCount > 0"
|
|
569
|
-
color="primary"
|
|
570
|
-
variant="subtle"
|
|
571
|
-
class="cursor-pointer"
|
|
572
|
-
@click="clearFilters"
|
|
608
|
+
:placeholder="t('dms_database.data.grid.search')"
|
|
573
609
|
>
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
</UBadge>
|
|
580
|
-
</UTooltip>
|
|
610
|
+
<template #trailing>
|
|
611
|
+
<UKbd value="meta" size="sm" />
|
|
612
|
+
<UKbd value="/" size="sm" />
|
|
613
|
+
</template>
|
|
614
|
+
</UInput>
|
|
581
615
|
<div class="ml-auto flex items-center gap-1.5">
|
|
582
|
-
<
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
616
|
+
<UTooltip :text="t('dms_database.data.grid.refresh')">
|
|
617
|
+
<UButton
|
|
618
|
+
icon="i-ph-arrows-clockwise"
|
|
619
|
+
color="neutral"
|
|
620
|
+
variant="ghost"
|
|
621
|
+
size="sm"
|
|
622
|
+
:loading="loading"
|
|
623
|
+
:aria-label="t('dms_database.data.grid.refresh')"
|
|
624
|
+
@click="reload"
|
|
625
|
+
/>
|
|
626
|
+
</UTooltip>
|
|
591
627
|
<UButton
|
|
592
628
|
icon="i-ph-code"
|
|
629
|
+
color="neutral"
|
|
593
630
|
variant="ghost"
|
|
594
|
-
color="info"
|
|
595
631
|
size="sm"
|
|
596
|
-
:label="t('dms_database.data.
|
|
632
|
+
:label="t('dms_database.data.grid.open_query')"
|
|
597
633
|
@click="openInQueryConsole"
|
|
598
634
|
/>
|
|
599
635
|
</div>
|
|
600
636
|
</div>
|
|
601
637
|
|
|
602
|
-
|
|
638
|
+
<DataFilterBar
|
|
639
|
+
:columns="visibleColumns"
|
|
640
|
+
:filters="state.filters"
|
|
641
|
+
@set="setFilter"
|
|
642
|
+
@clear="clearFilters"
|
|
643
|
+
/>
|
|
644
|
+
|
|
645
|
+
<div
|
|
646
|
+
v-if="readOnly"
|
|
647
|
+
class="border-default bg-warning/5 flex flex-wrap items-center gap-2 border-b px-3 py-2 text-sm"
|
|
648
|
+
>
|
|
649
|
+
<UIcon name="i-ph-lock-simple" class="text-warning size-4" />
|
|
650
|
+
<span>
|
|
651
|
+
<b>{{ t('dms_database.data.grid.read_only') }}</b>
|
|
652
|
+
<span class="text-muted ml-1">
|
|
653
|
+
{{
|
|
654
|
+
t('dms_database.data.grid.read_only_detail', { table: tab.table })
|
|
655
|
+
}}
|
|
656
|
+
</span>
|
|
657
|
+
</span>
|
|
658
|
+
<span class="ml-auto flex gap-1.5">
|
|
659
|
+
<UButton
|
|
660
|
+
v-for="instance in namedInstances"
|
|
661
|
+
:key="instance"
|
|
662
|
+
size="xs"
|
|
663
|
+
color="neutral"
|
|
664
|
+
variant="outline"
|
|
665
|
+
:label="t('dms_database.data.grid.switch_to', { instance })"
|
|
666
|
+
@click="switchInstance(instance)"
|
|
667
|
+
/>
|
|
668
|
+
<UButton
|
|
669
|
+
size="xs"
|
|
670
|
+
color="neutral"
|
|
671
|
+
variant="outline"
|
|
672
|
+
:label="
|
|
673
|
+
t('dms_database.data.grid.switch_to', {
|
|
674
|
+
instance: t('dms_database.data.scope.default'),
|
|
675
|
+
})
|
|
676
|
+
"
|
|
677
|
+
@click="switchInstance(DEFAULT_INSTANCE_VALUE)"
|
|
678
|
+
/>
|
|
679
|
+
</span>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
603
682
|
<div
|
|
604
683
|
ref="scroller"
|
|
605
|
-
class="relative min-h-0 flex-1 overflow-auto outline-none"
|
|
684
|
+
class="relative min-h-0 flex-1 scroll-pl-14 scroll-pt-9 overflow-auto outline-none"
|
|
606
685
|
tabindex="0"
|
|
607
686
|
@keydown="onKeydown"
|
|
608
687
|
>
|
|
609
688
|
<table
|
|
610
|
-
v-if="
|
|
689
|
+
v-if="visibleColumns.length > 0 && rows.length > 0"
|
|
611
690
|
class="w-max min-w-full border-separate border-spacing-0"
|
|
612
691
|
style="table-layout: fixed"
|
|
613
692
|
>
|
|
614
693
|
<colgroup>
|
|
615
|
-
<col style="width:
|
|
694
|
+
<col style="width: 56px" />
|
|
616
695
|
<col
|
|
617
|
-
v-for="column in
|
|
696
|
+
v-for="column in visibleColumns"
|
|
618
697
|
:key="column.name"
|
|
619
698
|
:style="{ width: `${widthFor(column.name)}px` }"
|
|
620
|
-
|
|
699
|
+
/>
|
|
621
700
|
</colgroup>
|
|
622
701
|
<thead class="sticky top-0 z-10">
|
|
623
702
|
<tr>
|
|
624
703
|
<th
|
|
625
|
-
class="sticky left-0 z-20 border-
|
|
704
|
+
class="border-default bg-elevated sticky left-0 z-20 border-b border-r"
|
|
626
705
|
scope="col"
|
|
627
706
|
/>
|
|
628
|
-
<
|
|
629
|
-
v-for="column in
|
|
707
|
+
<DataGridHeaderCell
|
|
708
|
+
v-for="column in visibleColumns"
|
|
630
709
|
:key="column.name"
|
|
631
710
|
:column="column"
|
|
711
|
+
:role="roleOf(column)"
|
|
632
712
|
:sort-key="state.sortKey"
|
|
633
713
|
:sort-direction="state.sortDirection"
|
|
634
714
|
:filter="state.filters[column.name] ?? null"
|
|
@@ -640,96 +720,254 @@ function rowKey(row: Record<string, unknown>, index: number): string | number {
|
|
|
640
720
|
</tr>
|
|
641
721
|
</thead>
|
|
642
722
|
<tbody>
|
|
643
|
-
<tr v-for="(row, r) in rows" :key="rowKey(row, r)">
|
|
723
|
+
<tr v-for="(row, r) in rows" :key="rowKey(row, r)" class="group/row">
|
|
644
724
|
<td
|
|
645
|
-
class="sticky left-0 z-[5] border-
|
|
725
|
+
class="border-default bg-elevated text-dimmed sticky left-0 z-[5] border-b border-r px-1 py-1 text-right font-mono text-[10.5px] tabular-nums"
|
|
646
726
|
>
|
|
647
|
-
|
|
727
|
+
<span class="group-hover/row:hidden">
|
|
728
|
+
{{ state.page * state.pageSize + r + 1 }}
|
|
729
|
+
</span>
|
|
730
|
+
<button
|
|
731
|
+
type="button"
|
|
732
|
+
class="text-muted hover:text-primary hidden w-full justify-end group-hover/row:flex"
|
|
733
|
+
:aria-label="t('dms_database.data.grid.open_row')"
|
|
734
|
+
:title="t('dms_database.data.grid.open_row')"
|
|
735
|
+
@click="openRow(r)"
|
|
736
|
+
>
|
|
737
|
+
<UIcon name="i-ph-arrows-out-simple" class="size-3.5" />
|
|
738
|
+
</button>
|
|
648
739
|
</td>
|
|
649
|
-
<
|
|
650
|
-
v-for="(column, c) in
|
|
740
|
+
<DataGridCell
|
|
741
|
+
v-for="(column, c) in visibleColumns"
|
|
651
742
|
:key="column.name"
|
|
652
743
|
:data-cell="`${r}:${c}`"
|
|
744
|
+
:column="column.name"
|
|
653
745
|
:value="row[column.name]"
|
|
654
|
-
:
|
|
746
|
+
:staged="stagedCell(row, column.name)"
|
|
747
|
+
:role="roleOf(column)"
|
|
748
|
+
:editable="isCellEditable(column.name, row)"
|
|
655
749
|
:editing="editing?.r === r && editing?.c === c"
|
|
656
|
-
:
|
|
657
|
-
|
|
750
|
+
:invalid="
|
|
751
|
+
editing?.r === r && editing?.c === c
|
|
752
|
+
? (invalidReason ?? undefined)
|
|
753
|
+
: undefined
|
|
754
|
+
"
|
|
658
755
|
:focused="focused?.r === r && focused?.c === c"
|
|
659
756
|
@select="focusCell(r, c)"
|
|
660
757
|
@start-edit="openEditorAt(r, c)"
|
|
661
758
|
@commit="commitCell"
|
|
662
759
|
@cancel="cancelEdit"
|
|
663
|
-
@open-json="openEditorAt(r, c)"
|
|
664
760
|
/>
|
|
665
761
|
</tr>
|
|
666
762
|
</tbody>
|
|
667
763
|
</table>
|
|
668
764
|
|
|
765
|
+
<div v-if="loadError" class="grid h-full place-items-center p-6">
|
|
766
|
+
<DmsEmptyState
|
|
767
|
+
variant="error"
|
|
768
|
+
:title="
|
|
769
|
+
t('dms_database.data.empty.error_title', { table: tab.table })
|
|
770
|
+
"
|
|
771
|
+
:actions="[
|
|
772
|
+
{
|
|
773
|
+
label: t('dms_database.common.retry'),
|
|
774
|
+
icon: 'i-ph-arrows-clockwise',
|
|
775
|
+
color: 'neutral',
|
|
776
|
+
variant: 'outline',
|
|
777
|
+
onClick: reload,
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
label: t('dms_database.data.empty.check_connection'),
|
|
781
|
+
color: 'neutral',
|
|
782
|
+
variant: 'ghost',
|
|
783
|
+
to: DATABASE_PAGES.overview,
|
|
784
|
+
},
|
|
785
|
+
]"
|
|
786
|
+
>
|
|
787
|
+
<p class="text-muted text-sm">
|
|
788
|
+
{{ t('dms_database.data.empty.error_description') }}
|
|
789
|
+
</p>
|
|
790
|
+
<p class="text-dimmed mt-1 font-mono text-[11px]">{{ loadError }}</p>
|
|
791
|
+
</DmsEmptyState>
|
|
792
|
+
</div>
|
|
669
793
|
<div
|
|
670
|
-
v-if="
|
|
671
|
-
class="
|
|
794
|
+
v-else-if="!loading && rows.length === 0 && hasRefinements"
|
|
795
|
+
class="grid h-full place-items-center p-6"
|
|
672
796
|
>
|
|
673
|
-
|
|
797
|
+
<DmsEmptyState
|
|
798
|
+
variant="no-result"
|
|
799
|
+
:title="t('dms_database.data.empty.no_match_title')"
|
|
800
|
+
:description="
|
|
801
|
+
t('dms_database.data.empty.no_match_description', {
|
|
802
|
+
count: n(total),
|
|
803
|
+
table: tab.table,
|
|
804
|
+
})
|
|
805
|
+
"
|
|
806
|
+
:actions="[
|
|
807
|
+
{
|
|
808
|
+
label: t('dms_database.data.empty.clear_filters'),
|
|
809
|
+
color: 'neutral',
|
|
810
|
+
variant: 'outline',
|
|
811
|
+
onClick: clearRefinements,
|
|
812
|
+
},
|
|
813
|
+
...(readOnly
|
|
814
|
+
? []
|
|
815
|
+
: [
|
|
816
|
+
{
|
|
817
|
+
label: t('dms_database.data.empty.search_all'),
|
|
818
|
+
color: 'neutral' as const,
|
|
819
|
+
variant: 'ghost' as const,
|
|
820
|
+
onClick: () => switchInstance(CROSS_INSTANCE_VALUE),
|
|
821
|
+
},
|
|
822
|
+
]),
|
|
823
|
+
]"
|
|
824
|
+
/>
|
|
674
825
|
</div>
|
|
675
826
|
<div
|
|
676
|
-
v-else-if="rows.length === 0
|
|
677
|
-
class="
|
|
827
|
+
v-else-if="!loading && rows.length === 0"
|
|
828
|
+
class="grid h-full place-items-center p-6"
|
|
678
829
|
>
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
830
|
+
<DmsEmptyState
|
|
831
|
+
:title="
|
|
832
|
+
t('dms_database.data.empty.no_rows_title', { table: tab.table })
|
|
833
|
+
"
|
|
834
|
+
:description="t('dms_database.data.empty.no_rows_description')"
|
|
835
|
+
/>
|
|
683
836
|
</div>
|
|
684
837
|
<div v-if="loading" class="absolute inset-x-0 top-0">
|
|
685
838
|
<UProgress size="xs" />
|
|
686
839
|
</div>
|
|
687
840
|
</div>
|
|
688
841
|
|
|
689
|
-
<!-- footer / pagination -->
|
|
690
842
|
<div
|
|
691
|
-
|
|
843
|
+
v-if="pendingCount > 0 || saveNotice"
|
|
844
|
+
class="border-default border-t px-3 py-2"
|
|
692
845
|
>
|
|
693
|
-
<
|
|
694
|
-
|
|
846
|
+
<DmsSaveBar
|
|
847
|
+
v-if="pendingCount > 0"
|
|
848
|
+
variant="band"
|
|
849
|
+
dirty
|
|
850
|
+
:saving="saving"
|
|
851
|
+
:changes="changedFields"
|
|
852
|
+
:save-label="t('dms_database.data.save.review', pendingCount)"
|
|
853
|
+
@discard="staged.discard(tab.id)"
|
|
854
|
+
@save="review"
|
|
855
|
+
/>
|
|
856
|
+
<div
|
|
857
|
+
v-else-if="saveNotice"
|
|
858
|
+
role="status"
|
|
859
|
+
class="flex min-h-9 items-center gap-2 text-[13px]"
|
|
860
|
+
>
|
|
861
|
+
<UIcon
|
|
862
|
+
:name="
|
|
863
|
+
saveNotice.state === 'undone'
|
|
864
|
+
? 'i-ph-arrow-counter-clockwise'
|
|
865
|
+
: 'i-ph-check-circle'
|
|
866
|
+
"
|
|
867
|
+
class="size-4 shrink-0"
|
|
868
|
+
:class="saveNotice.state === 'undone' ? 'text-muted' : 'text-success'"
|
|
869
|
+
/>
|
|
870
|
+
<span class="text-toned truncate">
|
|
871
|
+
{{
|
|
872
|
+
saveNotice.state === 'undone'
|
|
873
|
+
? t('dms_database.data.save.undone', saveNotice.changes.length)
|
|
874
|
+
: t(
|
|
875
|
+
'dms_database.data.save.saved',
|
|
876
|
+
{ scope: saveNotice.scope },
|
|
877
|
+
saveNotice.changes.length,
|
|
878
|
+
)
|
|
879
|
+
}}
|
|
880
|
+
</span>
|
|
881
|
+
<UButton
|
|
882
|
+
v-if="saveNotice.state !== 'undone'"
|
|
883
|
+
class="ms-auto"
|
|
884
|
+
:label="t('dms_database.data.save.undo')"
|
|
885
|
+
icon="i-ph-arrow-counter-clockwise"
|
|
886
|
+
color="neutral"
|
|
887
|
+
variant="outline"
|
|
888
|
+
size="lg"
|
|
889
|
+
:loading="saveNotice.state === 'undoing'"
|
|
890
|
+
@click="undo"
|
|
891
|
+
/>
|
|
892
|
+
</div>
|
|
893
|
+
</div>
|
|
894
|
+
|
|
895
|
+
<footer
|
|
896
|
+
class="border-default text-muted flex flex-wrap items-center gap-3 border-t px-3 py-1.5 text-xs"
|
|
897
|
+
>
|
|
898
|
+
<span class="tabular-nums">
|
|
899
|
+
{{ t('dms_database.data.grid.rows', { count: n(total) }, total) }}
|
|
900
|
+
</span>
|
|
901
|
+
<span class="flex items-center gap-1.5">
|
|
902
|
+
{{ t('dms_database.data.grid.page_size') }}
|
|
695
903
|
<USelect
|
|
696
904
|
:model-value="state.pageSize"
|
|
697
905
|
:items="pageSizeOptions"
|
|
698
906
|
size="xs"
|
|
699
907
|
class="w-20"
|
|
700
|
-
@update:model-value="setPageSize($event
|
|
908
|
+
@update:model-value="setPageSize(Number($event))"
|
|
701
909
|
/>
|
|
702
|
-
|
|
910
|
+
</span>
|
|
911
|
+
<span class="text-dimmed hidden items-center gap-1 2xl:flex">
|
|
912
|
+
<UKbd value="enter" size="sm" />
|
|
913
|
+
{{ t('dms_database.data.grid.kbd_edit') }}
|
|
914
|
+
<UKbd value="esc" size="sm" class="ml-1.5" />
|
|
915
|
+
{{ t('dms_database.data.grid.kbd_cancel') }}
|
|
916
|
+
<UKbd value="space" size="sm" class="ml-1.5" />
|
|
917
|
+
{{ t('dms_database.data.grid.kbd_open') }}
|
|
918
|
+
<UKbd value="meta" size="sm" class="ml-1.5" />
|
|
919
|
+
<UKbd value="s" size="sm" />
|
|
920
|
+
{{ t('dms_database.data.grid.kbd_save') }}
|
|
921
|
+
</span>
|
|
922
|
+
<nav
|
|
923
|
+
class="ml-auto flex items-center gap-1"
|
|
924
|
+
:aria-label="t('dms_database.data.grid.pagination')"
|
|
925
|
+
>
|
|
926
|
+
<span class="mr-2 tabular-nums">{{ pageStart }}–{{ pageEnd }}</span>
|
|
703
927
|
<UButton
|
|
704
928
|
icon="i-ph-caret-left"
|
|
705
|
-
variant="ghost"
|
|
706
929
|
color="neutral"
|
|
930
|
+
variant="ghost"
|
|
707
931
|
size="xs"
|
|
708
932
|
:disabled="state.page === 0"
|
|
709
|
-
:aria-label="t('dms_database.data.
|
|
933
|
+
:aria-label="t('dms_database.data.grid.previous_page')"
|
|
710
934
|
@click="state.page -= 1"
|
|
711
935
|
/>
|
|
936
|
+
<template v-for="(page, index) in pages" :key="page">
|
|
937
|
+
<span
|
|
938
|
+
v-if="index > 0 && page - (pages[index - 1] ?? page) > 1"
|
|
939
|
+
class="text-dimmed px-1"
|
|
940
|
+
>
|
|
941
|
+
…
|
|
942
|
+
</span>
|
|
943
|
+
<UButton
|
|
944
|
+
size="xs"
|
|
945
|
+
:color="page === state.page ? 'primary' : 'neutral'"
|
|
946
|
+
:variant="page === state.page ? 'soft' : 'ghost'"
|
|
947
|
+
:label="String(page + 1)"
|
|
948
|
+
class="tabular-nums"
|
|
949
|
+
@click="state.page = page"
|
|
950
|
+
/>
|
|
951
|
+
</template>
|
|
712
952
|
<UButton
|
|
713
953
|
icon="i-ph-caret-right"
|
|
714
|
-
variant="ghost"
|
|
715
954
|
color="neutral"
|
|
955
|
+
variant="ghost"
|
|
716
956
|
size="xs"
|
|
717
|
-
:disabled="
|
|
718
|
-
:aria-label="t('dms_database.data.
|
|
957
|
+
:disabled="pageEnd >= total"
|
|
958
|
+
:aria-label="t('dms_database.data.grid.next_page')"
|
|
719
959
|
@click="state.page += 1"
|
|
720
960
|
/>
|
|
721
|
-
</
|
|
722
|
-
</
|
|
961
|
+
</nav>
|
|
962
|
+
</footer>
|
|
723
963
|
|
|
724
|
-
|
|
725
|
-
<DmsDatabaseJsonCellPanel
|
|
964
|
+
<JsonCellPanel
|
|
726
965
|
:open="jsonPanel !== null"
|
|
727
966
|
:column="jsonPanel?.field ?? ''"
|
|
728
967
|
:value="jsonPanel?.value"
|
|
729
|
-
:saving="savingJson"
|
|
730
968
|
:read-only="jsonPanel?.readOnly ?? true"
|
|
731
969
|
@update:open="jsonPanel = $event ? jsonPanel : null"
|
|
732
|
-
@save="
|
|
970
|
+
@save="stageJson"
|
|
733
971
|
/>
|
|
734
972
|
</div>
|
|
735
973
|
</template>
|