@antelopejs/dms-database 0.0.14 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +55 -41
  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 +527 -193
  141. package/frontend-vue/i18n/locales/database-fr-FR.json +529 -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
@@ -0,0 +1,205 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { ref } from 'vue'
3
+ import { parseDraft } from '../app/build/data/cellValues'
4
+ import { undoBodies, useStagedEdits } from '../app/build/data/stagedEdits'
5
+ import { DiagramHistory } from '../app/build/diagram/history'
6
+ import { toCsv } from '../app/build/query/csv'
7
+ import { groupByDay, runKind } from '../app/build/query/runs'
8
+ import { readQueryTarget } from '../app/build/query/target'
9
+ import {
10
+ decodeMatch,
11
+ tableAccess,
12
+ tableLink,
13
+ } from '../app/build/utils/databaseLinks'
14
+ import type { HistoryEntry } from '../app/build/composables/useQueryStore'
15
+
16
+ afterEach(() => vi.unstubAllGlobals())
17
+
18
+ describe('cell drafts', () => {
19
+ it('keeps the stored type of a cell', () => {
20
+ expect(parseDraft('42.5', 1, 'number')).toEqual({
21
+ ok: true,
22
+ value: 42.5,
23
+ unchanged: false,
24
+ })
25
+ expect(parseDraft('nope', 1, 'number')).toEqual({
26
+ ok: false,
27
+ reason: 'number',
28
+ })
29
+ expect(parseDraft('TRUE', false, 'boolean')).toMatchObject({
30
+ ok: true,
31
+ value: true,
32
+ })
33
+ })
34
+
35
+ it('types an empty cell from its column and leaves it empty when nothing is typed', () => {
36
+ expect(parseDraft('7', null, 'number')).toMatchObject({
37
+ ok: true,
38
+ value: 7,
39
+ })
40
+ expect(parseDraft('', null, 'string')).toMatchObject({
41
+ ok: true,
42
+ unchanged: true,
43
+ })
44
+ })
45
+
46
+ it('reports an echo of the stored value as unchanged', () => {
47
+ expect(parseDraft('42', 42, 'number')).toMatchObject({ unchanged: true })
48
+ })
49
+ })
50
+
51
+ describe('staged edits', () => {
52
+ function staged() {
53
+ vi.stubGlobal('useDmsState', (_key: string, init: () => unknown) =>
54
+ ref(init()),
55
+ )
56
+ return useStagedEdits()
57
+ }
58
+
59
+ it('keeps the first stored value and drops a cell set back to it', () => {
60
+ const edits = staged()
61
+ edits.stage('tab', {
62
+ rowId: 'r1',
63
+ field: 'status',
64
+ before: 'pending',
65
+ after: 'paid',
66
+ })
67
+ edits.stage('tab', {
68
+ rowId: 'r1',
69
+ field: 'status',
70
+ before: 'paid',
71
+ after: 'refunded',
72
+ })
73
+ expect(edits.get('tab', 'r1', 'status')).toEqual({
74
+ before: 'pending',
75
+ after: 'refunded',
76
+ })
77
+ edits.stage('tab', {
78
+ rowId: 'r1',
79
+ field: 'status',
80
+ before: 'refunded',
81
+ after: 'pending',
82
+ })
83
+ expect(edits.count('tab')).toBe(0)
84
+ })
85
+
86
+ it('writes each row once with all its fields, and undoes with the old values', () => {
87
+ const edits = staged()
88
+ edits.stage('tab', { rowId: 'r1', field: 'a', before: 1, after: 2 })
89
+ edits.stage('tab', { rowId: 'r1', field: 'b', before: null, after: 'x' })
90
+ expect(edits.rowBody('tab', 'r1')).toEqual({ a: 2, b: 'x' })
91
+ expect(undoBodies(edits.list('tab')).get('r1')).toEqual({ a: 1, b: null })
92
+ })
93
+ })
94
+
95
+ describe('diagram history', () => {
96
+ it('squashes a burst of moves of one table into one step', () => {
97
+ const state = { positions: {}, notes: [] }
98
+ const history = new DiagramHistory()
99
+ history.push(
100
+ {
101
+ kind: 'tableMove',
102
+ tableName: 't',
103
+ from: { x: 0, y: 0 },
104
+ to: { x: 5, y: 0 },
105
+ },
106
+ 1000,
107
+ )
108
+ history.push(
109
+ {
110
+ kind: 'tableMove',
111
+ tableName: 't',
112
+ from: { x: 5, y: 0 },
113
+ to: { x: 9, y: 0 },
114
+ },
115
+ 1100,
116
+ )
117
+ expect(history.undo(state)).toBe(true)
118
+ expect(state.positions).toEqual({ t: { x: 0, y: 0 } })
119
+ expect(history.canUndo).toBe(false)
120
+ expect(history.redo(state)).toBe(true)
121
+ expect(state.positions).toEqual({ t: { x: 9, y: 0 } })
122
+ })
123
+ })
124
+
125
+ describe('query console helpers', () => {
126
+ const entry = (patch: Partial<HistoryEntry>): HistoryEntry => ({
127
+ id: '1',
128
+ userId: 'u',
129
+ query: {},
130
+ source: '',
131
+ language: 'aql',
132
+ executedAt: '2026-10-07T10:00:00.000Z',
133
+ durationMs: 10,
134
+ rowCount: 1,
135
+ status: 'ok',
136
+ mutation: false,
137
+ ...patch,
138
+ })
139
+
140
+ it('tells runs apart', () => {
141
+ expect(runKind(entry({}))).toBe('fast')
142
+ expect(runKind(entry({ durationMs: 400 }))).toBe('slow')
143
+ expect(runKind(entry({ mutation: true }))).toBe('changed')
144
+ expect(runKind(entry({ status: 'error' }))).toBe('failed')
145
+ })
146
+
147
+ it('groups runs by day, newest first', () => {
148
+ const groups = groupByDay(
149
+ [
150
+ entry({ id: 'a', executedAt: '2026-10-07T10:00:00' }),
151
+ entry({ id: 'b', executedAt: '2026-10-06T10:00:00' }),
152
+ ],
153
+ (run) => run.executedAt,
154
+ )
155
+ expect(groups.map((group) => group.items.map((run) => run.id))).toEqual([
156
+ ['a'],
157
+ ['b'],
158
+ ])
159
+ })
160
+
161
+ it('reads the target of an encoded query', () => {
162
+ const encoded = {
163
+ __cls: 'Selection',
164
+ stages: [
165
+ { stage: 'schema', options: { id: 'shop' }, args: [] },
166
+ { stage: 'instance', options: { id: 'eu' }, args: [] },
167
+ { stage: 'table', options: { id: 'orders' }, args: [] },
168
+ ],
169
+ }
170
+ expect(readQueryTarget(encoded)).toEqual({
171
+ schema: 'shop',
172
+ instance: 'eu',
173
+ table: 'orders',
174
+ })
175
+ })
176
+
177
+ it('quotes CSV cells that need it', () => {
178
+ expect(
179
+ toCsv([
180
+ { a: 'x,y', b: { c: 1 } },
181
+ { a: null, d: 'say "hi"' },
182
+ ]),
183
+ ).toBe('a,b,d\n"x,y","{""c"":1}",\n,,"say ""hi"""\n')
184
+ })
185
+ })
186
+
187
+ describe('links', () => {
188
+ it('builds the DSL and page links of a table', () => {
189
+ expect(tableAccess({ schema: 'dms-core', table: 'users' })).toBe(
190
+ 'schemas["dms-core"].instance().table("users")',
191
+ )
192
+ expect(
193
+ tableLink('data', {
194
+ schema: 'shop',
195
+ table: 'orders',
196
+ instance: 'eu',
197
+ match: { field: '_id', value: 'a:b' },
198
+ }),
199
+ ).toBe(
200
+ '/modules/database/data?schema=shop&table=orders&instance=eu&match=_id%3Aa%3Ab',
201
+ )
202
+ expect(decodeMatch('_id:a:b')).toEqual({ field: '_id', value: 'a:b' })
203
+ expect(decodeMatch('nope')).toBeNull()
204
+ })
205
+ })
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ PICKER_LIMIT,
4
+ matchNames,
5
+ sortNames,
6
+ } from '../app/build/data/instanceOptions'
7
+
8
+ describe('instance picker', () => {
9
+ it('sorts instances by name, numbers in order, once each', () => {
10
+ expect(sortNames(['us', 'tenant-10', 'eu', 'tenant-9', 'eu'])).toEqual([
11
+ 'eu',
12
+ 'tenant-9',
13
+ 'tenant-10',
14
+ 'us',
15
+ ])
16
+ })
17
+
18
+ it('lists every instance without a search', () => {
19
+ expect(matchNames(['eu', 'us'], ' ')).toEqual({
20
+ shown: ['eu', 'us'],
21
+ total: 2,
22
+ })
23
+ })
24
+
25
+ it('matches a part of the name, whatever the case', () => {
26
+ const sorted = sortNames(['acme-EU', 'acme-us', 'globex-eu'])
27
+ expect(matchNames(sorted, 'Eu')).toEqual({
28
+ shown: ['acme-EU', 'globex-eu'],
29
+ total: 2,
30
+ })
31
+ expect(matchNames(sorted, 'initech')).toEqual({ shown: [], total: 0 })
32
+ })
33
+
34
+ it('renders a bounded slice of many instances and counts the rest', () => {
35
+ const sorted = sortNames(
36
+ Array.from({ length: 1_500 }, (_, index) => `tenant-${index}`),
37
+ )
38
+ const all = matchNames(sorted, '')
39
+ expect(all.shown).toHaveLength(PICKER_LIMIT)
40
+ expect(all.total).toBe(1_500)
41
+ expect(all.shown[0]).toBe('tenant-0')
42
+
43
+ const some = matchNames(sorted, 'tenant-14', 5)
44
+ expect(some.shown).toEqual([
45
+ 'tenant-14',
46
+ 'tenant-140',
47
+ 'tenant-141',
48
+ 'tenant-142',
49
+ 'tenant-143',
50
+ ])
51
+ expect(some.total).toBe(111)
52
+ })
53
+ })
@@ -0,0 +1,56 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { baseCompile } from '@intlify/message-compiler'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ // vue-i18n compiles a message the first time it shows it: a stray `@`, `{`,
7
+ // `}` or `|` then fails at runtime, on the page that shows it (S-1). Compile
8
+ // every message of every locale here instead; literals go through `{'@'}`.
9
+
10
+ const LOCALES_DIR = join(import.meta.dirname, '../i18n/locales')
11
+
12
+ type Messages = { [key: string]: string | Messages }
13
+
14
+ function leaves(messages: Messages, prefix = ''): [string, string][] {
15
+ return Object.entries(messages).flatMap(([key, value]) => {
16
+ const path = prefix ? `${prefix}.${key}` : key
17
+ return typeof value === 'string' ? [[path, value]] : leaves(value, path)
18
+ })
19
+ }
20
+
21
+ function compileErrors(message: string): string[] {
22
+ const errors: string[] = []
23
+ baseCompile(message, {
24
+ onError: (error) => errors.push(error.message),
25
+ onWarn: () => undefined,
26
+ })
27
+ return errors
28
+ }
29
+
30
+ const files = readdirSync(LOCALES_DIR).filter((file) => file.endsWith('.json'))
31
+
32
+ describe('locale messages', () => {
33
+ it('finds the locale files', () => {
34
+ expect(files.length).toBeGreaterThan(0)
35
+ })
36
+
37
+ it.each(files)('compiles every message of %s', (file) => {
38
+ const messages = JSON.parse(
39
+ readFileSync(join(LOCALES_DIR, file), 'utf8'),
40
+ ) as Messages
41
+ const failures = leaves(messages)
42
+ .map(([key, message]) => ({ key, errors: compileErrors(message) }))
43
+ .filter((entry) => entry.errors.length > 0)
44
+ .map((entry) => `${entry.key}: ${entry.errors.join(', ')}`)
45
+ expect(failures).toEqual([])
46
+ })
47
+
48
+ it('reports an unescaped linked-message sign', () => {
49
+ expect(compileErrors('declares tables with @RegisterTable.')).not.toEqual(
50
+ [],
51
+ )
52
+ expect(compileErrors("declares tables with {'@'}RegisterTable.")).toEqual(
53
+ [],
54
+ )
55
+ })
56
+ })
@@ -1,6 +1,6 @@
1
- import { afterEach, expect, it, vi } from 'vitest'
2
- import { computed, ref } from 'vue'
3
- import { useDataBrowserTabs } from '../app/composables/useDataBrowserTabs'
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { computed, reactive, ref } from 'vue'
3
+ import { useDataBrowserTabs } from '../app/build/composables/useDataBrowserTabs'
4
4
 
5
5
  afterEach(() => vi.unstubAllGlobals())
6
6
 
@@ -57,7 +57,12 @@ it('does not revisit an already selected deep link while restoring tabs', () =>
57
57
  const replace = vi.fn()
58
58
  vi.stubGlobal('useDmsRouter', () => ({ replace }))
59
59
  vi.stubGlobal('useDmsRoute', () => ({
60
- query: { schema: 'sales', table: 'orders', instance: 'eu', panel: 'detail' },
60
+ query: {
61
+ schema: 'sales',
62
+ table: 'orders',
63
+ instance: 'eu',
64
+ panel: 'detail',
65
+ },
61
66
  }))
62
67
  const browser = useDataBrowserTabs()
63
68
  browser.restore()
@@ -65,6 +70,102 @@ it('does not revisit an already selected deep link while restoring tabs', () =>
65
70
  expect(replace).not.toHaveBeenCalled()
66
71
  browser.openTab('sales', 'us', 'orders', false)
67
72
  expect(replace).toHaveBeenCalledExactlyOnceWith({
68
- query: { schema: 'sales', table: 'orders', instance: 'us', panel: 'detail' },
73
+ query: {
74
+ schema: 'sales',
75
+ table: 'orders',
76
+ instance: 'us',
77
+ panel: 'detail',
78
+ },
79
+ })
80
+ })
81
+
82
+ it('restores the preview tab as a preview, the URL naming it included', () => {
83
+ setup()
84
+ const replace = vi.fn()
85
+ vi.stubGlobal('useDmsRouter', () => ({ replace }))
86
+ vi.stubGlobal('useDmsRoute', () => ({
87
+ query: { schema: 'sales', table: 'orders', instance: 'eu' },
88
+ }))
89
+ vi.stubGlobal('window', {
90
+ sessionStorage: {
91
+ getItem: () =>
92
+ JSON.stringify({
93
+ tabs: [
94
+ { schema: 'sales', instance: 'eu', table: 'customers' },
95
+ {
96
+ schema: 'sales',
97
+ instance: 'eu',
98
+ table: 'orders',
99
+ preview: true,
100
+ },
101
+ ],
102
+ activeId: 'sales::eu::orders',
103
+ }),
104
+ setItem: vi.fn(),
105
+ },
106
+ })
107
+ const browser = useDataBrowserTabs()
108
+ browser.restore()
109
+ expect(browser.activeId.value).toBe('sales::eu::orders')
110
+ expect(browser.tabs.value.map((tab) => tab.preview)).toEqual([false, true])
111
+ expect(replace).not.toHaveBeenCalled()
112
+ })
113
+
114
+ describe('data browser: the route it writes itself', () => {
115
+ // A reactive route the router writes to, as the DMS's stable
116
+ // useDmsRoute() reflects a landed visit.
117
+ function routed(query: Record<string, string> = {}) {
118
+ setup()
119
+ vi.stubGlobal('window', {
120
+ sessionStorage: { getItem: () => null, setItem: vi.fn() },
121
+ })
122
+ const route = reactive({ query })
123
+ const replace = vi.fn(({ query: next }: { query: typeof query }) => {
124
+ route.query = next
125
+ })
126
+ vi.stubGlobal('useDmsRoute', () => route)
127
+ vi.stubGlobal('useDmsRouter', () => ({ replace }))
128
+ return { route, replace, browser: useDataBrowserTabs() }
129
+ }
130
+
131
+ it('keeps a preview tab a preview once its URL lands', () => {
132
+ const { route, replace, browser } = routed()
133
+ browser.openTab('sales', 'eu', 'orders')
134
+ expect(replace).toHaveBeenCalledExactlyOnceWith({
135
+ query: { schema: 'sales', table: 'orders', instance: 'eu' },
136
+ })
137
+ // The page's route watcher sees the URL the browser wrote.
138
+ expect(route.query.table).toBe('orders')
139
+ expect(browser.openFromRoute()).toBe(true)
140
+ expect(browser.tabs.value).toEqual([
141
+ expect.objectContaining({ id: 'sales::eu::orders', preview: true }),
142
+ ])
143
+ })
144
+
145
+ it('opens a linked table pinned, the preview slot left alone', () => {
146
+ const { route, browser } = routed()
147
+ browser.openTab('sales', 'eu', 'orders')
148
+ route.query = { schema: 'sales', table: 'customers', instance: 'eu' }
149
+ browser.openFromRoute()
150
+ expect(browser.activeId.value).toBe('sales::eu::customers')
151
+ expect(browser.tabs.value.map((tab) => [tab.id, tab.preview])).toEqual([
152
+ ['sales::eu::orders', true],
153
+ ['sales::eu::customers', false],
154
+ ])
155
+ })
156
+
157
+ it('pins the active preview tab when a link filters it on a row', () => {
158
+ const { route, browser } = routed()
159
+ browser.openTab('sales', 'eu', 'orders')
160
+ route.query = {
161
+ schema: 'sales',
162
+ table: 'orders',
163
+ instance: 'eu',
164
+ match: '_id:42',
165
+ }
166
+ browser.openFromRoute()
167
+ expect(browser.tabs.value).toEqual([
168
+ expect.objectContaining({ id: 'sales::eu::orders', preview: false }),
169
+ ])
69
170
  })
70
171
  })
@@ -0,0 +1,254 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ CROSS_INSTANCE_VALUE,
4
+ DEFAULT_INSTANCE_VALUE,
5
+ instanceChoiceOf,
6
+ tabInstanceOf,
7
+ } from '../app/build/composables/useDataBrowserTabs'
8
+ import {
9
+ PICK_ALL,
10
+ PICK_DEFAULT,
11
+ instanceMenu,
12
+ instancePickChoice,
13
+ instancePickValue,
14
+ schemaMenu,
15
+ type InstanceMenu,
16
+ type PickerItem,
17
+ } from '../app/build/data/pickerMenus'
18
+ import { sortNames } from '../app/build/data/instanceOptions'
19
+
20
+ // The schema and instance pickers the Schemas page's filter bar and the data
21
+ // browser's sidebar share (SchemaPicker.vue, InstancePicker.vue).
22
+
23
+ const values = (groups: PickerItem[][]) =>
24
+ groups.map((group) => group.map((item) => item.value ?? `#${item.label}`))
25
+
26
+ const schemaLabels = {
27
+ more: (shown: number, total: number) => `First ${shown} of ${total}`,
28
+ }
29
+
30
+ describe('schema picker menu', () => {
31
+ const ids = sortNames(['shop', 'demo', 'core'])
32
+
33
+ it('pins "All schemas" first when the page lists every schema', () => {
34
+ const groups = schemaMenu({
35
+ ids,
36
+ search: '',
37
+ allLabel: 'All schemas',
38
+ ...schemaLabels,
39
+ })
40
+ expect(values(groups)).toEqual([[PICK_ALL], ['core', 'demo', 'shop']])
41
+ expect(groups[0]?.[0]?.class).toBe('pin-top')
42
+ })
43
+
44
+ it('offers no "All schemas" when a schema is required', () => {
45
+ expect(
46
+ values(
47
+ schemaMenu({
48
+ ids,
49
+ search: '',
50
+ allLabel: null,
51
+ ...schemaLabels,
52
+ }),
53
+ ),
54
+ ).toEqual([['core', 'demo', 'shop']])
55
+ })
56
+
57
+ it('searches every schema', () => {
58
+ expect(
59
+ values(
60
+ schemaMenu({
61
+ ids,
62
+ search: 'o',
63
+ allLabel: null,
64
+ ...schemaLabels,
65
+ }),
66
+ ),
67
+ ).toEqual([['core', 'demo', 'shop']])
68
+ })
69
+
70
+ it('says how many matched beyond the ones listed', () => {
71
+ const many = sortNames(
72
+ Array.from({ length: 150 }, (_, index) => `module-${index}`),
73
+ )
74
+ const groups = schemaMenu({
75
+ ids: many,
76
+ search: '',
77
+ allLabel: null,
78
+ ...schemaLabels,
79
+ })
80
+ expect(groups[0]?.at(-1)).toEqual({
81
+ label: 'First 100 of 150',
82
+ type: 'label',
83
+ })
84
+ })
85
+ })
86
+
87
+ function menu(overrides: Partial<InstanceMenu>): InstanceMenu {
88
+ return {
89
+ search: '',
90
+ hasSchema: true,
91
+ names: [],
92
+ matched: 0,
93
+ total: null,
94
+ status: 'idle',
95
+ allMode: 'filter',
96
+ labels: {
97
+ all: 'All instances',
98
+ readOnly: 'Read-only view',
99
+ default: 'default',
100
+ pickSchema: 'Pick a schema',
101
+ loading: 'Loading',
102
+ failed: 'Failed',
103
+ noNamed: 'No named instance',
104
+ noMatch: 'No instance matches',
105
+ more: (shown, total) => `First ${shown} of ${total}`,
106
+ count: (value) => String(value),
107
+ },
108
+ ...overrides,
109
+ }
110
+ }
111
+
112
+ describe('instance picker menu', () => {
113
+ it('pins every instance, counted, and default first as a filter', () => {
114
+ const groups = instanceMenu(
115
+ menu({ names: ['eu', 'us'], matched: 2, total: 2, status: 'success' }),
116
+ )
117
+ expect(values(groups)).toEqual([
118
+ [PICK_ALL, PICK_DEFAULT],
119
+ ['eu', 'us'],
120
+ ])
121
+ expect(groups[0]?.[0]).toMatchObject({
122
+ label: 'All instances (3)',
123
+ icon: 'i-ph-stack-simple',
124
+ class: 'pin-top',
125
+ })
126
+ })
127
+
128
+ it('offers every instance first, locked, as the read-only view', () => {
129
+ const groups = instanceMenu(
130
+ menu({
131
+ names: ['eu'],
132
+ matched: 1,
133
+ total: 1,
134
+ status: 'success',
135
+ allMode: 'readonly',
136
+ }),
137
+ )
138
+ expect(values(groups)).toEqual([[PICK_ALL, PICK_DEFAULT], ['eu']])
139
+ expect(groups[0]?.[0]).toMatchObject({
140
+ label: 'All instances (2)',
141
+ description: 'Read-only view',
142
+ icon: 'i-ph-lock-simple',
143
+ class: 'pin-top',
144
+ })
145
+ })
146
+
147
+ it('keeps the read-only view after the matches of a search', () => {
148
+ const groups = instanceMenu(
149
+ menu({
150
+ search: 'zz',
151
+ total: 4,
152
+ status: 'success',
153
+ allMode: 'readonly',
154
+ }),
155
+ )
156
+ expect(values(groups)).toEqual([['#No instance matches'], [PICK_ALL]])
157
+ expect(groups[1]?.[0]?.class).toBeUndefined()
158
+ expect(
159
+ values(
160
+ instanceMenu(
161
+ menu({
162
+ search: 'e',
163
+ names: ['eu'],
164
+ matched: 1,
165
+ total: 4,
166
+ status: 'success',
167
+ allMode: 'readonly',
168
+ }),
169
+ ),
170
+ ),
171
+ ).toEqual([[PICK_DEFAULT], ['eu'], [PICK_ALL]])
172
+ })
173
+
174
+ it('lists the "all" filter after the matches, or drops it', () => {
175
+ expect(
176
+ values(
177
+ instanceMenu(
178
+ menu({
179
+ search: 'all',
180
+ names: ['ball'],
181
+ matched: 1,
182
+ status: 'success',
183
+ }),
184
+ ),
185
+ ),
186
+ ).toEqual([['ball'], [PICK_ALL]])
187
+ })
188
+
189
+ it('drops the "all" filter a search does not match', () => {
190
+ expect(
191
+ values(
192
+ instanceMenu(
193
+ menu({ search: 'eu', names: ['eu'], matched: 1, status: 'success' }),
194
+ ),
195
+ ),
196
+ ).toEqual([['eu']])
197
+ })
198
+
199
+ it('says the instances load, failed, or are more than listed', () => {
200
+ expect(values(instanceMenu(menu({ status: 'pending' })))[1]).toEqual([
201
+ '#Loading',
202
+ ])
203
+ expect(values(instanceMenu(menu({ status: 'error' })))[1]).toEqual([
204
+ '#Failed',
205
+ ])
206
+ expect(
207
+ values(
208
+ instanceMenu(
209
+ menu({ status: 'success', total: 0, matched: 0, names: [] }),
210
+ ),
211
+ )[1],
212
+ ).toEqual(['#No named instance'])
213
+ expect(
214
+ values(
215
+ instanceMenu(
216
+ menu({
217
+ status: 'success',
218
+ names: ['tenant-1'],
219
+ matched: 500,
220
+ total: 500,
221
+ }),
222
+ ),
223
+ )[1],
224
+ ).toEqual(['tenant-1', '#First 1 of 500'])
225
+ })
226
+
227
+ it('asks for a schema before listing named instances', () => {
228
+ for (const allMode of ['filter', 'readonly'] as const)
229
+ expect(values(instanceMenu(menu({ hasSchema: false, allMode })))).toEqual(
230
+ [[PICK_ALL, PICK_DEFAULT], ['#Pick a schema']],
231
+ )
232
+ })
233
+
234
+ it('maps choices to menu values and back', () => {
235
+ for (const choice of [
236
+ { kind: 'all' },
237
+ { kind: 'default' },
238
+ { kind: 'named', id: 'eu' },
239
+ ] as const)
240
+ expect(instancePickChoice(instancePickValue(choice))).toEqual(choice)
241
+ })
242
+
243
+ it('maps a data-browser tab instance to a choice and back', () => {
244
+ for (const instance of [
245
+ DEFAULT_INSTANCE_VALUE,
246
+ CROSS_INSTANCE_VALUE,
247
+ 'eu',
248
+ 'all',
249
+ ])
250
+ expect(tabInstanceOf(instanceChoiceOf(instance))).toBe(instance)
251
+ expect(instanceChoiceOf(CROSS_INSTANCE_VALUE)).toEqual({ kind: 'all' })
252
+ expect(instanceChoiceOf('all')).toEqual({ kind: 'named', id: 'all' })
253
+ })
254
+ })