@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.
Files changed (173) hide show
  1. package/README.md +30 -5
  2. package/dist/db/models/query_history.model.d.ts +1 -0
  3. package/dist/db/models/query_history.model.js +6 -0
  4. package/dist/db/models/query_history.model.js.map +1 -1
  5. package/dist/db/tables/query_history.table.d.ts +3 -0
  6. package/dist/db/tables/query_history.table.js +12 -0
  7. package/dist/db/tables/query_history.table.js.map +1 -1
  8. package/dist/db/tables/saved_queries.table.d.ts +1 -0
  9. package/dist/db/tables/saved_queries.table.js +4 -0
  10. package/dist/db/tables/saved_queries.table.js.map +1 -1
  11. package/dist/i18n/messages.d.ts +39 -0
  12. package/dist/i18n/messages.js +64 -0
  13. package/dist/i18n/messages.js.map +1 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -7
  16. package/dist/index.js.map +1 -1
  17. package/dist/module.d.ts +11 -0
  18. package/dist/module.js +55 -0
  19. package/dist/module.js.map +1 -0
  20. package/dist/pages/data.d.ts +1 -1
  21. package/dist/pages/data.js +9 -6
  22. package/dist/pages/data.js.map +1 -1
  23. package/dist/pages/diagram.d.ts +9 -0
  24. package/dist/pages/diagram.js +45 -0
  25. package/dist/pages/diagram.js.map +1 -0
  26. package/dist/pages/index.d.ts +1 -0
  27. package/dist/pages/index.js +1 -0
  28. package/dist/pages/index.js.map +1 -1
  29. package/dist/pages/overview.d.ts +4 -1
  30. package/dist/pages/overview.js +113 -6
  31. package/dist/pages/overview.js.map +1 -1
  32. package/dist/pages/query.d.ts +1 -1
  33. package/dist/pages/query.js +22 -6
  34. package/dist/pages/query.js.map +1 -1
  35. package/dist/pages/schemas.d.ts +2 -1
  36. package/dist/pages/schemas.js +143 -6
  37. package/dist/pages/schemas.js.map +1 -1
  38. package/dist/routes/data.d.ts +8 -0
  39. package/dist/routes/data.js +84 -21
  40. package/dist/routes/data.js.map +1 -1
  41. package/dist/routes/index.d.ts +1 -0
  42. package/dist/routes/index.js +1 -0
  43. package/dist/routes/index.js.map +1 -1
  44. package/dist/routes/overview.d.ts +12 -0
  45. package/dist/routes/overview.js +55 -0
  46. package/dist/routes/overview.js.map +1 -0
  47. package/dist/routes/query.d.ts +5 -1
  48. package/dist/routes/query.js +162 -27
  49. package/dist/routes/query.js.map +1 -1
  50. package/dist/routes/tables.d.ts +15 -12
  51. package/dist/routes/tables.js +95 -55
  52. package/dist/routes/tables.js.map +1 -1
  53. package/dist/service/catalog.d.ts +3 -0
  54. package/dist/service/catalog.js +26 -0
  55. package/dist/service/catalog.js.map +1 -0
  56. package/dist/service/health.d.ts +3 -3
  57. package/dist/service/health.js +17 -14
  58. package/dist/service/health.js.map +1 -1
  59. package/dist/service/introspect.d.ts +3 -1
  60. package/dist/service/introspect.js +131 -43
  61. package/dist/service/introspect.js.map +1 -1
  62. package/dist/service/overview.d.ts +9 -0
  63. package/dist/service/overview.js +113 -0
  64. package/dist/service/overview.js.map +1 -0
  65. package/dist/service/queryInspection.d.ts +15 -0
  66. package/dist/service/queryInspection.js +107 -0
  67. package/dist/service/queryInspection.js.map +1 -0
  68. package/dist/service/references.d.ts +9 -0
  69. package/dist/service/references.js +52 -0
  70. package/dist/service/references.js.map +1 -0
  71. package/dist/service/schemaFilters.d.ts +26 -0
  72. package/dist/service/schemaFilters.js +42 -0
  73. package/dist/service/schemaFilters.js.map +1 -0
  74. package/dist/service/stagedSerialization.d.ts +2 -0
  75. package/dist/service/stagedSerialization.js +38 -10
  76. package/dist/service/stagedSerialization.js.map +1 -1
  77. package/dist/service/types.d.ts +11 -1
  78. package/dist/types/constants.d.ts +2 -0
  79. package/dist/types/constants.js +3 -1
  80. package/dist/types/constants.js.map +1 -1
  81. package/dist/utils/query.d.ts +0 -2
  82. package/dist/utils/query.js +0 -20
  83. package/dist/utils/query.js.map +1 -1
  84. package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
  85. package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
  86. package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
  87. package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
  88. package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
  89. package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
  90. package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
  91. package/frontend-vue/app/build/data/cellValues.ts +132 -0
  92. package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
  93. package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
  94. package/frontend-vue/app/build/data/recentTables.ts +48 -0
  95. package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
  96. package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
  97. package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
  98. package/frontend-vue/app/build/diagram/autosave.ts +72 -0
  99. package/frontend-vue/app/build/diagram/graph.ts +585 -0
  100. package/frontend-vue/app/build/diagram/history.ts +178 -0
  101. package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
  102. package/frontend-vue/app/build/diagram/sync.ts +137 -0
  103. package/frontend-vue/app/build/query/completion.ts +100 -0
  104. package/frontend-vue/app/build/query/csv.ts +32 -0
  105. package/frontend-vue/app/build/query/examples.ts +38 -0
  106. package/frontend-vue/app/build/query/runs.ts +168 -0
  107. package/frontend-vue/app/build/query/tableColumns.ts +35 -0
  108. package/frontend-vue/app/build/query/target.ts +143 -0
  109. package/frontend-vue/app/build/schemas/filterState.ts +128 -0
  110. package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
  111. package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
  112. package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
  113. package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
  114. package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
  115. package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
  116. package/frontend-vue/app/components/DataBrowser.vue +126 -0
  117. package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
  118. package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
  119. package/frontend-vue/app/components/DataFilterBar.vue +163 -0
  120. package/frontend-vue/app/components/DataGrid.vue +737 -499
  121. package/frontend-vue/app/components/DataGridCell.vue +125 -77
  122. package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
  123. package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
  124. package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
  125. package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
  126. package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
  127. package/frontend-vue/app/components/InstancePicker.vue +195 -0
  128. package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
  129. package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
  130. package/frontend-vue/app/components/QueryConsole.vue +726 -0
  131. package/frontend-vue/app/components/QueryLibrary.vue +314 -0
  132. package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
  133. package/frontend-vue/app/components/RowDrawer.vue +520 -0
  134. package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
  135. package/frontend-vue/app/components/SchemaPicker.vue +86 -0
  136. package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
  137. package/frontend-vue/app/components/TableInspector.vue +741 -0
  138. package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
  139. package/frontend-vue/dms.frontend.ts +5 -14
  140. package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
  141. package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
  142. package/frontend-vue/package.json +2 -1
  143. package/frontend-vue/pnpm-lock.yaml +2601 -858
  144. package/frontend-vue/pnpm-workspace.yaml +1 -1
  145. package/frontend-vue/tests/dataBrowser.test.ts +206 -0
  146. package/frontend-vue/tests/diagram.test.ts +516 -0
  147. package/frontend-vue/tests/helpers.test.ts +205 -0
  148. package/frontend-vue/tests/instanceOptions.test.ts +53 -0
  149. package/frontend-vue/tests/locales.test.ts +56 -0
  150. package/frontend-vue/tests/persistence.test.ts +106 -5
  151. package/frontend-vue/tests/pickerMenus.test.ts +254 -0
  152. package/frontend-vue/tests/queryConsole.test.ts +282 -0
  153. package/frontend-vue/tests/queryRuntime.test.ts +2 -2
  154. package/frontend-vue/tests/registration.test.ts +36 -25
  155. package/frontend-vue/tests/schemasFilters.test.ts +115 -0
  156. package/package.json +7 -7
  157. package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
  158. package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
  159. package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
  160. package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
  161. package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
  162. package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
  163. package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
  164. package/frontend-vue/app/composables/useQueryStore.ts +0 -117
  165. package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
  166. package/frontend-vue/app/custom-pages/database/data.vue +0 -41
  167. package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
  168. package/frontend-vue/app/custom-pages/database/query.vue +0 -436
  169. package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
  170. package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
  171. package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
  172. package/frontend-vue/app/utils/dmsTableView.ts +0 -83
  173. package/frontend-vue/app/utils/relativeTime.ts +0 -18
@@ -1,133 +1,163 @@
1
1
  <script setup lang="ts">
2
- import type { BrowserTab } from "../composables/useDataBrowserTabs";
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 "../composables/useDataBrowserGrid";
8
-
9
- const BROWSE_ENDPOINT = "/api/database/browse";
10
- const QUERY_PATH = "/modules/database/query";
11
- // Browse-only synthetic columns; never editable, never persisted.
12
- const SYNTHETIC_FIELDS = new Set(["_instance"]);
13
- const DEFAULT_COLUMN_WIDTH = 192;
14
- // Primary key assumed when the backend reports none (e.g. MongoDB's implicit id).
15
- const DEFAULT_PRIMARY_KEY = "_id";
16
- // Debounce for the toolbar's global search input.
17
- const SEARCH_DEBOUNCE_MS = 300;
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
- offset: number;
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
- // Schema-introspected field names, used when the table is empty and the
36
- // backend column sampling returns nothing.
37
- fallbackFields: string[];
38
- }>();
39
-
40
- const { t } = useI18n();
41
- const { $authFetch } = useAuthFetch();
42
- const toast = useToast();
43
- const router = useDmsRouter();
44
- const grid = useDataBrowserGrid();
45
- const { tabs: browserTabs, pinTab } = useDataBrowserTabs();
46
-
47
- const state = grid.getState(props.tab.id);
48
- const readOnly = computed(() => props.tab.instance === CROSS_INSTANCE_VALUE);
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's selection (the component remounts
56
- // per tab), so it is fetched once — not on every page/sort/filter change — and
57
- // refreshed only by an explicit reload. `null` means not fetched or failed.
58
- const columnsMeta = ref<ColumnsResponse | null>(null);
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
- // Restoring from cache must also retire any in-flight request (its late
65
- // response would pass the seq guard and overwrite the cache with rows
66
- // for parameters the user has already left) and clear transient state
67
- // from a previous attempt.
68
- requestSeq += 1;
69
- data.value = cached;
70
- loadError.value = null;
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 selection = browseSelectionQuery(props.tab);
79
- const needColumns = force || columnsMeta.value === null;
80
- const [columnsRes, listRes] = await Promise.all([
81
- needColumns
82
- ? // A columns failure must not discard a successful rows fetch: the
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: names.map((name) => ({
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
- editingCell &&
117
- rowIdOf(next.rows[editingCell.r] ?? {}) !== editingCell.rowId
147
+ editing.value &&
148
+ rowIdOf(next.rows[editing.value.r] ?? {}) !== editing.value.rowId
118
149
  ) {
119
- cancelEdit();
150
+ cancelEdit()
120
151
  }
121
- // Clamp the page when the total shrank under the current offset (rows
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
- // An empty table cached with no columns while the schemas fetch was still in
141
- // flight must recover once the schema-introspected fields arrive — nothing
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.fallbackFields,
145
- (fields) => {
146
- if (
147
- fields.length > 0 &&
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
- // --- toolbar: debounced global search ---
167
- const searchDraft = ref(state.search);
168
- let searchTimer: ReturnType<typeof setTimeout> | undefined;
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
- // Retire any in-flight browse request: its late response would otherwise
179
- // setCache under this tab's id AFTER the tab was replaced/closed and
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
- const activeFilterCount = computed(
193
- () => Object.values(state.filters).filter((filter) => filter.value).length,
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 = "asc";
206
- } else if (state.sortDirection === "asc") {
207
- state.sortDirection = "desc";
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 = "asc";
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 hasNext = computed(() => pageEnd.value < total.value);
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
- // --- open in query console (prefilled DSL, kept from the previous page) ---
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 source = `${schemaAccessExpr(props.tab.schema)}.instance(${instanceArgExpr()}).table(${JSON.stringify(props.tab.table)}).slice(0, 100)`;
262
- router.push({ path: QUERY_PATH, query: { source } });
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 & keyboard navigation ---
266
- const scroller = useTemplateRef<HTMLDivElement>("scroller");
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: "nearest", inline: "nearest" });
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
- if (editing.value || jsonPanel.value) return;
280
- const current = focused.value;
281
- if (!current) return;
282
- const maxR = rows.value.length - 1;
283
- const maxC = columns.value.length - 1;
284
- let { r, c } = current;
285
- switch (event.key) {
286
- case "ArrowUp":
287
- r -= 1;
288
- break;
289
- case "ArrowDown":
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.preventDefault();
320
- focusCell(Math.max(0, Math.min(maxR, r)), Math.max(0, Math.min(maxC, c)));
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 | null;
332
- field: string;
333
- } | null>(null);
334
- const invalidDraft = ref(false);
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 columnAt(c: number): GridColumn | null {
345
- return columns.value[c] ?? null;
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 isCellEditable(field: string): boolean {
349
- if (readOnly.value) return false;
350
- if (field === DEFAULT_PRIMARY_KEY || field === rowIdKey.value) return false;
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 = columnAt(c);
363
- const row = rows.value[r];
364
- if (!column || !row) return;
365
- const value = row[column.name];
366
- if (value !== null && value !== undefined && typeof value === "object") {
367
- // Structured cell: always viewable through the JSON panel; editable only
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: rowIdOf(row),
431
+ rowId,
373
432
  field: column.name,
374
433
  value,
375
- readOnly: !isCellEditable(column.name) || rowIdOf(row) === null,
376
- };
377
- return;
434
+ readOnly: !isCellEditable(column.name, row),
435
+ }
436
+ return
378
437
  }
379
- // A row with no addressable primary key cannot be saved on any path, so
380
- // refuse to open the editor instead of failing at commit time (mirrors the
381
- // JSON panel's readOnly guard).
382
- const rowId = rowIdOf(row);
383
- if (!isCellEditable(column.name) || rowId === null) return;
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
- invalidDraft.value = false;
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
- // A double-clicked NULL cell committed untouched must stay NULL — writing
472
- // "" over null on click-away would silently mutate the row.
473
- if (
474
- (before === null || before === undefined) &&
475
- typeof next === "string" &&
476
- next === ""
477
- ) {
478
- cancelEdit();
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
- // Unchanged echo: close silently, nothing to save.
483
- if (before !== null && before !== undefined && String(before) === String(next)) {
484
- cancelEdit();
485
- return;
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
- const rowId = cell.rowId;
489
- if (!rowId) {
490
- toast.add({ title: t("dms_database.data.editError"), color: "error" });
491
- cancelEdit();
492
- return;
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
- editing.value = null;
496
- invalidDraft.value = false;
497
- pendingCell.value = { rowId, field: column.name };
498
- try {
499
- await persistField(rowId, column.name, next);
500
- } catch (error) {
501
- handleEditError(error);
502
- } finally {
503
- pendingCell.value = null;
504
- refocusGrid();
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
- async function saveJson(value: unknown) {
509
- const panel = jsonPanel.value;
510
- if (!panel || panel.readOnly || !panel.rowId) return;
511
- savingJson.value = true;
512
- try {
513
- await persistField(panel.rowId, panel.field, value);
514
- jsonPanel.value = null;
515
- } catch (error) {
516
- handleEditError(error);
517
- } finally {
518
- savingJson.value = false;
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 handleEditError(error: unknown) {
523
- const status =
524
- (error as { statusCode?: number } | null)?.statusCode ??
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 isPending(row: Record<string, unknown>, field: string): boolean {
543
- const pending = pendingCell.value;
544
- return pending !== null && pending.field === field && rowIdOf(row) === pending.rowId;
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 the same primary key across instances, so
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 ?? "")}::${id}` : id;
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
- <!-- toolbar -->
559
- <div class="flex items-center gap-2 border-b border-default px-3 py-2">
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
- {{ t("dms_database.data.filtersActive", { count: activeFilterCount }) }} ✕
575
- </UBadge>
576
- <UTooltip v-if="readOnly" :text="t('dms_database.data.crossInstanceNotice')">
577
- <UBadge color="warning" variant="subtle" icon="i-ph-lock">
578
- {{ t("dms_database.data.readOnly") }}
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
- <UButton
583
- icon="i-ph-arrow-clockwise"
584
- variant="ghost"
585
- color="neutral"
586
- size="sm"
587
- :loading="loading"
588
- :label="t('dms_database.data.refresh')"
589
- @click="reload"
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.openInQuery')"
632
+ :label="t('dms_database.data.grid.open_query')"
597
633
  @click="openInQueryConsole"
598
634
  />
599
635
  </div>
600
636
  </div>
601
637
 
602
- <!-- grid -->
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="columns.length > 0"
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: 44px">
694
+ <col style="width: 56px" />
616
695
  <col
617
- v-for="column in columns"
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-r border-b border-default bg-elevated"
704
+ class="border-default bg-elevated sticky left-0 z-20 border-b border-r"
626
705
  scope="col"
627
706
  />
628
- <DmsDatabaseDataGridHeaderCell
629
- v-for="column in columns"
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-r border-b border-default bg-elevated px-2 py-1 text-right font-mono text-[10.5px] tabular-nums text-dimmed"
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
- {{ state.page * state.pageSize + r + 1 }}
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
- <DmsDatabaseDataGridCell
650
- v-for="(column, c) in columns"
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
- :editable="isCellEditable(column.name)"
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
- :pending="isPending(row, column.name)"
657
- :invalid="invalidDraft && editing?.r === r && editing?.c === c"
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="columns.length === 0 && !loading"
671
- class="flex h-full items-center justify-center text-sm text-muted"
794
+ v-else-if="!loading && rows.length === 0 && hasRefinements"
795
+ class="grid h-full place-items-center p-6"
672
796
  >
673
- {{ t("dms_database.data.noColumns") }}
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 && !loading && !loadError"
677
- class="absolute inset-x-0 top-16 text-center text-sm text-muted"
827
+ v-else-if="!loading && rows.length === 0"
828
+ class="grid h-full place-items-center p-6"
678
829
  >
679
- {{ t("dms_database.data.noRows") }}
680
- </div>
681
- <div v-if="loadError" class="absolute inset-x-0 top-16 mx-auto max-w-md">
682
- <UAlert color="error" icon="i-ph-warning" :description="loadError" />
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
- class="flex items-center gap-3 border-t border-default px-3 py-1.5 text-xs text-muted"
843
+ v-if="pendingCount > 0 || saveNotice"
844
+ class="border-default border-t px-3 py-2"
692
845
  >
693
- <span>{{ t("dms_database.data.rowsTotal", { count: total }) }}</span>
694
- <div class="ml-auto flex items-center gap-2">
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 as number)"
908
+ @update:model-value="setPageSize(Number($event))"
701
909
  />
702
- <span class="tabular-nums">{{ pageStart }}–{{ pageEnd }}</span>
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.prevPage')"
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="!hasNext"
718
- :aria-label="t('dms_database.data.nextPage')"
957
+ :disabled="pageEnd >= total"
958
+ :aria-label="t('dms_database.data.grid.next_page')"
719
959
  @click="state.page += 1"
720
960
  />
721
- </div>
722
- </div>
961
+ </nav>
962
+ </footer>
723
963
 
724
- <!-- JSON editor panel -->
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="saveJson"
970
+ @save="stageJson"
733
971
  />
734
972
  </div>
735
973
  </template>