@antelopejs/dms-database 0.0.13 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -5
- package/dist/db/models/query_history.model.d.ts +1 -0
- package/dist/db/models/query_history.model.js +6 -0
- package/dist/db/models/query_history.model.js.map +1 -1
- package/dist/db/tables/query_history.table.d.ts +3 -0
- package/dist/db/tables/query_history.table.js +12 -0
- package/dist/db/tables/query_history.table.js.map +1 -1
- package/dist/db/tables/saved_queries.table.d.ts +1 -0
- package/dist/db/tables/saved_queries.table.js +4 -0
- package/dist/db/tables/saved_queries.table.js.map +1 -1
- package/dist/i18n/messages.d.ts +39 -0
- package/dist/i18n/messages.js +64 -0
- package/dist/i18n/messages.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -7
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +11 -0
- package/dist/module.js +55 -0
- package/dist/module.js.map +1 -0
- package/dist/pages/data.d.ts +1 -1
- package/dist/pages/data.js +9 -6
- package/dist/pages/data.js.map +1 -1
- package/dist/pages/diagram.d.ts +9 -0
- package/dist/pages/diagram.js +45 -0
- package/dist/pages/diagram.js.map +1 -0
- package/dist/pages/index.d.ts +1 -0
- package/dist/pages/index.js +1 -0
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/overview.d.ts +4 -1
- package/dist/pages/overview.js +113 -6
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/query.d.ts +1 -1
- package/dist/pages/query.js +22 -6
- package/dist/pages/query.js.map +1 -1
- package/dist/pages/schemas.d.ts +2 -1
- package/dist/pages/schemas.js +143 -6
- package/dist/pages/schemas.js.map +1 -1
- package/dist/routes/data.d.ts +8 -0
- package/dist/routes/data.js +84 -21
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/overview.d.ts +12 -0
- package/dist/routes/overview.js +55 -0
- package/dist/routes/overview.js.map +1 -0
- package/dist/routes/query.d.ts +5 -1
- package/dist/routes/query.js +162 -27
- package/dist/routes/query.js.map +1 -1
- package/dist/routes/tables.d.ts +15 -12
- package/dist/routes/tables.js +95 -55
- package/dist/routes/tables.js.map +1 -1
- package/dist/service/catalog.d.ts +3 -0
- package/dist/service/catalog.js +26 -0
- package/dist/service/catalog.js.map +1 -0
- package/dist/service/health.d.ts +3 -3
- package/dist/service/health.js +17 -14
- package/dist/service/health.js.map +1 -1
- package/dist/service/introspect.d.ts +3 -1
- package/dist/service/introspect.js +131 -43
- package/dist/service/introspect.js.map +1 -1
- package/dist/service/overview.d.ts +9 -0
- package/dist/service/overview.js +113 -0
- package/dist/service/overview.js.map +1 -0
- package/dist/service/queryInspection.d.ts +15 -0
- package/dist/service/queryInspection.js +107 -0
- package/dist/service/queryInspection.js.map +1 -0
- package/dist/service/references.d.ts +9 -0
- package/dist/service/references.js +52 -0
- package/dist/service/references.js.map +1 -0
- package/dist/service/schemaFilters.d.ts +26 -0
- package/dist/service/schemaFilters.js +42 -0
- package/dist/service/schemaFilters.js.map +1 -0
- package/dist/service/stagedSerialization.d.ts +2 -0
- package/dist/service/stagedSerialization.js +38 -10
- package/dist/service/stagedSerialization.js.map +1 -1
- package/dist/service/types.d.ts +11 -1
- package/dist/types/constants.d.ts +2 -0
- package/dist/types/constants.js +3 -1
- package/dist/types/constants.js.map +1 -1
- package/dist/utils/query.d.ts +0 -2
- package/dist/utils/query.js +0 -20
- package/dist/utils/query.js.map +1 -1
- package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
- package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
- package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
- package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
- package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
- package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
- package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
- package/frontend-vue/app/build/data/cellValues.ts +132 -0
- package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
- package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
- package/frontend-vue/app/build/data/recentTables.ts +48 -0
- package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
- package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
- package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
- package/frontend-vue/app/build/diagram/autosave.ts +72 -0
- package/frontend-vue/app/build/diagram/graph.ts +585 -0
- package/frontend-vue/app/build/diagram/history.ts +178 -0
- package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
- package/frontend-vue/app/build/diagram/sync.ts +137 -0
- package/frontend-vue/app/build/query/completion.ts +100 -0
- package/frontend-vue/app/build/query/csv.ts +32 -0
- package/frontend-vue/app/build/query/examples.ts +38 -0
- package/frontend-vue/app/build/query/runs.ts +168 -0
- package/frontend-vue/app/build/query/tableColumns.ts +35 -0
- package/frontend-vue/app/build/query/target.ts +143 -0
- package/frontend-vue/app/build/schemas/filterState.ts +128 -0
- package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
- package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
- package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
- package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
- package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
- package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
- package/frontend-vue/app/components/DataBrowser.vue +126 -0
- package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
- package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
- package/frontend-vue/app/components/DataFilterBar.vue +163 -0
- package/frontend-vue/app/components/DataGrid.vue +737 -499
- package/frontend-vue/app/components/DataGridCell.vue +125 -77
- package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
- package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
- package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
- package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
- package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
- package/frontend-vue/app/components/InstancePicker.vue +195 -0
- package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
- package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
- package/frontend-vue/app/components/QueryConsole.vue +726 -0
- package/frontend-vue/app/components/QueryLibrary.vue +314 -0
- package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
- package/frontend-vue/app/components/RowDrawer.vue +520 -0
- package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
- package/frontend-vue/app/components/SchemaPicker.vue +86 -0
- package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
- package/frontend-vue/app/components/TableInspector.vue +741 -0
- package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
- package/frontend-vue/dms.frontend.ts +5 -14
- package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
- package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
- package/frontend-vue/package.json +2 -1
- package/frontend-vue/pnpm-lock.yaml +2601 -858
- package/frontend-vue/pnpm-workspace.yaml +1 -1
- package/frontend-vue/tests/dataBrowser.test.ts +206 -0
- package/frontend-vue/tests/diagram.test.ts +516 -0
- package/frontend-vue/tests/helpers.test.ts +205 -0
- package/frontend-vue/tests/instanceOptions.test.ts +53 -0
- package/frontend-vue/tests/locales.test.ts +56 -0
- package/frontend-vue/tests/persistence.test.ts +106 -5
- package/frontend-vue/tests/pickerMenus.test.ts +254 -0
- package/frontend-vue/tests/queryConsole.test.ts +282 -0
- package/frontend-vue/tests/queryRuntime.test.ts +2 -2
- package/frontend-vue/tests/registration.test.ts +36 -25
- package/frontend-vue/tests/schemasFilters.test.ts +115 -0
- package/package.json +9 -6
- package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
- package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
- package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
- package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
- package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
- package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
- package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
- package/frontend-vue/app/composables/useQueryStore.ts +0 -117
- package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
- package/frontend-vue/app/custom-pages/database/data.vue +0 -41
- package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
- package/frontend-vue/app/custom-pages/database/query.vue +0 -436
- package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
- package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
- package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
- package/frontend-vue/app/utils/dmsTableView.ts +0 -83
- package/frontend-vue/app/utils/relativeTime.ts +0 -18
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { SchemaSummary } from '../app/build/composables/useDatabaseSchemas'
|
|
3
|
+
import { DiagramAutosave, type SaveStatus } from '../app/build/diagram/autosave'
|
|
4
|
+
import {
|
|
5
|
+
buildGraph,
|
|
6
|
+
EDGE_ACTIVE_COLOR,
|
|
7
|
+
EDGE_COLOR,
|
|
8
|
+
freeSpot,
|
|
9
|
+
type GraphInput,
|
|
10
|
+
layOutSchema,
|
|
11
|
+
neighbourIds,
|
|
12
|
+
NOTE_Z_INDEX,
|
|
13
|
+
type NoteDraft,
|
|
14
|
+
searchTables,
|
|
15
|
+
TABLE_NODE_TYPE,
|
|
16
|
+
type TableNodeData,
|
|
17
|
+
tableNodeId,
|
|
18
|
+
} from '../app/build/diagram/graph'
|
|
19
|
+
import { DiagramHistory, type DiagramState } from '../app/build/diagram/history'
|
|
20
|
+
import { canvasOwnsKey, diagramCommand } from '../app/build/diagram/shortcuts'
|
|
21
|
+
import { DiagramSync, type DiagramStore } from '../app/build/diagram/sync'
|
|
22
|
+
|
|
23
|
+
// shop: order_items -> orders -> customers, order_items -> products, and
|
|
24
|
+
// orders -> billing.accounts in another schema.
|
|
25
|
+
const SHOP = {
|
|
26
|
+
id: 'shop',
|
|
27
|
+
tables: [
|
|
28
|
+
{
|
|
29
|
+
name: 'customers',
|
|
30
|
+
fields: { _id: { kind: 'string' }, name: { kind: 'string' } },
|
|
31
|
+
indexes: {},
|
|
32
|
+
relations: [],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'products',
|
|
36
|
+
fields: { _id: { kind: 'string' }, tags: { kind: 'array' } },
|
|
37
|
+
indexes: {},
|
|
38
|
+
relations: [],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'orders',
|
|
42
|
+
fields: {
|
|
43
|
+
_id: { kind: 'string' },
|
|
44
|
+
customer_id: { kind: 'string' },
|
|
45
|
+
account_id: { kind: 'string' },
|
|
46
|
+
},
|
|
47
|
+
indexes: {},
|
|
48
|
+
relations: [
|
|
49
|
+
{
|
|
50
|
+
fromField: 'customer_id',
|
|
51
|
+
toSchema: 'shop',
|
|
52
|
+
toTable: 'customers',
|
|
53
|
+
toField: '_id',
|
|
54
|
+
many: false,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
fromField: 'account_id',
|
|
58
|
+
toSchema: 'billing',
|
|
59
|
+
toTable: 'accounts',
|
|
60
|
+
toField: '_id',
|
|
61
|
+
many: false,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'order_items',
|
|
67
|
+
fields: {
|
|
68
|
+
_id: { kind: 'string' },
|
|
69
|
+
order_id: { kind: 'string' },
|
|
70
|
+
product_id: { kind: 'string' },
|
|
71
|
+
},
|
|
72
|
+
indexes: {},
|
|
73
|
+
relations: [
|
|
74
|
+
{
|
|
75
|
+
fromField: 'order_id',
|
|
76
|
+
toSchema: 'shop',
|
|
77
|
+
toTable: 'orders',
|
|
78
|
+
toField: '_id',
|
|
79
|
+
many: false,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
fromField: 'product_id',
|
|
83
|
+
toSchema: 'shop',
|
|
84
|
+
toTable: 'products',
|
|
85
|
+
toField: '_id',
|
|
86
|
+
many: true,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
} as unknown as SchemaSummary
|
|
92
|
+
|
|
93
|
+
function input(overrides: Partial<GraphInput> = {}): GraphInput {
|
|
94
|
+
return {
|
|
95
|
+
schema: SHOP,
|
|
96
|
+
positions: {},
|
|
97
|
+
notes: [],
|
|
98
|
+
selectedId: null,
|
|
99
|
+
focusIds: null,
|
|
100
|
+
noteHandlers: {
|
|
101
|
+
onTextChange: () => undefined,
|
|
102
|
+
onDelete: () => undefined,
|
|
103
|
+
onEditEnd: () => undefined,
|
|
104
|
+
},
|
|
105
|
+
onOpenSchema: () => undefined,
|
|
106
|
+
routing: new Map(),
|
|
107
|
+
...overrides,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function tableData(graph: ReturnType<typeof buildGraph>, name: string) {
|
|
112
|
+
const node = graph.nodes.find((n) => n.id === tableNodeId('shop', name))
|
|
113
|
+
return node?.data as unknown as TableNodeData
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function positionOf(graph: ReturnType<typeof buildGraph>, id: string) {
|
|
117
|
+
return graph.nodes.find((n) => n.id === id)?.position
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe('diagram graph', () => {
|
|
121
|
+
it('keeps an unsaved table where the whole schema puts it, focused or not (D-4)', () => {
|
|
122
|
+
const layout = layOutSchema(SHOP)
|
|
123
|
+
const full = buildGraph(input({ layout }))
|
|
124
|
+
const focused = buildGraph(
|
|
125
|
+
input({ layout, focusIds: neighbourIds(SHOP, 'orders') }),
|
|
126
|
+
)
|
|
127
|
+
for (const name of ['orders', 'customers', 'order_items']) {
|
|
128
|
+
const id = tableNodeId('shop', name)
|
|
129
|
+
expect(positionOf(focused, id)).toEqual(positionOf(full, id))
|
|
130
|
+
expect(positionOf(full, id)).toEqual(layout.tables[name])
|
|
131
|
+
}
|
|
132
|
+
// Without a cached layout the result is the same.
|
|
133
|
+
expect(buildGraph(input()).nodes.map((n) => n.position)).toEqual(
|
|
134
|
+
full.nodes.map((n) => n.position),
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('draws a stub where the table the focus leaves out is (D-10)', () => {
|
|
139
|
+
const layout = layOutSchema(SHOP)
|
|
140
|
+
const focused = buildGraph(
|
|
141
|
+
input({ layout, focusIds: neighbourIds(SHOP, 'customers') }),
|
|
142
|
+
)
|
|
143
|
+
// customers -> orders is drawn; orders -> billing.accounts is a stub of
|
|
144
|
+
// another schema; order_items is not a neighbour of customers.
|
|
145
|
+
const stub = focused.nodes.find((n) => n.id === 'stub::billing::accounts')
|
|
146
|
+
expect(stub?.data).toMatchObject({
|
|
147
|
+
targetSchema: 'billing',
|
|
148
|
+
targetTable: 'accounts',
|
|
149
|
+
})
|
|
150
|
+
const productsStub = buildGraph(
|
|
151
|
+
input({ layout, focusIds: neighbourIds(SHOP, 'order_items') }),
|
|
152
|
+
)
|
|
153
|
+
const sameSchema = productsStub.nodes.find(
|
|
154
|
+
(n) => n.id === 'stub::shop::customers',
|
|
155
|
+
)
|
|
156
|
+
expect(sameSchema?.position).toEqual(layout.tables.customers)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('opens a stub on its table, in its schema', () => {
|
|
160
|
+
const onOpenSchema = vi.fn()
|
|
161
|
+
const graph = buildGraph(input({ onOpenSchema }))
|
|
162
|
+
const stub = graph.nodes.find((n) => n.id === 'stub::billing::accounts')
|
|
163
|
+
;(stub?.data.onOpenSchema as (s: string, t: string) => void)(
|
|
164
|
+
'billing',
|
|
165
|
+
'accounts',
|
|
166
|
+
)
|
|
167
|
+
expect(onOpenSchema).toHaveBeenCalledWith('billing', 'accounts')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('keeps tables and stubs out of the canvas selection and deletion (D-3, D-9)', () => {
|
|
171
|
+
const graph = buildGraph(input())
|
|
172
|
+
for (const node of graph.nodes) {
|
|
173
|
+
expect(node.deletable).toBe(false)
|
|
174
|
+
expect(node.selectable).toBe(false)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('puts notes above the tables and opens the new one for writing (D-8)', () => {
|
|
179
|
+
const note: NoteDraft = {
|
|
180
|
+
id: 'temp-1',
|
|
181
|
+
schemaName: 'shop',
|
|
182
|
+
text: '',
|
|
183
|
+
x: 0,
|
|
184
|
+
y: 0,
|
|
185
|
+
width: 220,
|
|
186
|
+
height: 140,
|
|
187
|
+
color: null,
|
|
188
|
+
isTemp: true,
|
|
189
|
+
}
|
|
190
|
+
const graph = buildGraph(
|
|
191
|
+
input({ notes: [note, { ...note, id: 'n2' }], editNoteId: 'temp-1' }),
|
|
192
|
+
)
|
|
193
|
+
const notes = graph.nodes.filter((n) => n.id.startsWith('note::'))
|
|
194
|
+
expect(notes.map((n) => n.zIndex)).toEqual([NOTE_Z_INDEX, NOTE_Z_INDEX])
|
|
195
|
+
expect(notes.map((n) => n.data.autoEdit)).toEqual([true, false])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('lights up the column a relation points at when its table is selected (D-19)', () => {
|
|
199
|
+
const graph = buildGraph(
|
|
200
|
+
input({ selectedId: tableNodeId('shop', 'customers') }),
|
|
201
|
+
)
|
|
202
|
+
expect(tableData(graph, 'customers').highlighted).toEqual(['_id'])
|
|
203
|
+
expect(tableData(graph, 'orders').highlighted).toEqual(['customer_id'])
|
|
204
|
+
expect(tableData(graph, 'products').dimmed).toBe(true)
|
|
205
|
+
const fromOrders = buildGraph(
|
|
206
|
+
input({ selectedId: tableNodeId('shop', 'orders') }),
|
|
207
|
+
)
|
|
208
|
+
expect(tableData(fromOrders, 'customers').highlighted).toEqual(['_id'])
|
|
209
|
+
expect(tableData(fromOrders, 'orders').highlighted.sort()).toEqual([
|
|
210
|
+
'_id',
|
|
211
|
+
'account_id',
|
|
212
|
+
'customer_id',
|
|
213
|
+
])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('colours arrows with theme tokens, the active ones in primary (D-1)', () => {
|
|
217
|
+
const graph = buildGraph(
|
|
218
|
+
input({ selectedId: tableNodeId('shop', 'customers') }),
|
|
219
|
+
)
|
|
220
|
+
const colour = (id: string) =>
|
|
221
|
+
graph.edges.find((e) => e.id.startsWith(id))?.markerEnd.color
|
|
222
|
+
expect(colour('shop::orders::customer_id')).toBe(EDGE_ACTIVE_COLOR)
|
|
223
|
+
expect(colour('shop::order_items::order_id')).toBe(EDGE_COLOR)
|
|
224
|
+
for (const edge of graph.edges)
|
|
225
|
+
expect(edge.markerEnd.color).toMatch(/^var\(--/)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('only draws the table nodes it is asked for', () => {
|
|
229
|
+
const graph = buildGraph(
|
|
230
|
+
input({ focusIds: new Set([tableNodeId('shop', 'products')]) }),
|
|
231
|
+
)
|
|
232
|
+
expect(
|
|
233
|
+
graph.nodes.filter((n) => n.type === TABLE_NODE_TYPE).map((n) => n.id),
|
|
234
|
+
).toEqual([tableNodeId('shop', 'products')])
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('diagram search (D-13)', () => {
|
|
239
|
+
it('finds tables by name first, then by column, each once', () => {
|
|
240
|
+
expect(searchTables(SHOP, 'order')).toEqual(['orders', 'order_items'])
|
|
241
|
+
expect(searchTables(SHOP, 'CUSTOMER')).toEqual(['customers', 'orders'])
|
|
242
|
+
expect(searchTables(SHOP, 'tags')).toEqual(['products'])
|
|
243
|
+
expect(searchTables(SHOP, ' ')).toEqual([])
|
|
244
|
+
expect(searchTables(SHOP, 'nothing')).toEqual([])
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe('note placement (D-8)', () => {
|
|
249
|
+
it('keeps the wanted spot when it is free', () => {
|
|
250
|
+
expect(freeSpot({ x: 0, y: 0 }, { width: 100, height: 100 }, [])).toEqual({
|
|
251
|
+
x: 0,
|
|
252
|
+
y: 0,
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('moves off a table to the nearest free spot', () => {
|
|
257
|
+
const table = { x: 0, y: 0, width: 200, height: 200 }
|
|
258
|
+
const spot = freeSpot({ x: 50, y: 50 }, { width: 100, height: 100 }, [
|
|
259
|
+
table,
|
|
260
|
+
])
|
|
261
|
+
const overlaps =
|
|
262
|
+
spot.x < table.x + table.width &&
|
|
263
|
+
table.x < spot.x + 100 &&
|
|
264
|
+
spot.y < table.y + table.height &&
|
|
265
|
+
table.y < spot.y + 100
|
|
266
|
+
expect(overlaps).toBe(false)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('diagram shortcuts (D-5, D-6)', () => {
|
|
271
|
+
const key = (k: string, mods: Partial<KeyboardEvent> = {}) =>
|
|
272
|
+
diagramCommand({
|
|
273
|
+
key: k,
|
|
274
|
+
ctrlKey: false,
|
|
275
|
+
metaKey: false,
|
|
276
|
+
altKey: false,
|
|
277
|
+
shiftKey: false,
|
|
278
|
+
...mods,
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('reads letters whatever their case', () => {
|
|
282
|
+
expect(key('f')).toBe('search')
|
|
283
|
+
expect(key('F')).toBe('search')
|
|
284
|
+
expect(key('N', { shiftKey: true })).toBe('note')
|
|
285
|
+
expect(key('V')).toBe('select')
|
|
286
|
+
expect(key('H')).toBe('pan')
|
|
287
|
+
expect(key('Escape')).toBe('escape')
|
|
288
|
+
expect(key('x')).toBeNull()
|
|
289
|
+
expect(key('f', { altKey: true })).toBeNull()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('undoes with Ctrl+Z and redoes with Ctrl+Shift+Z or Ctrl+Y', () => {
|
|
293
|
+
expect(key('z', { ctrlKey: true })).toBe('undo')
|
|
294
|
+
expect(key('Z', { ctrlKey: true, shiftKey: true })).toBe('redo')
|
|
295
|
+
expect(key('Z', { metaKey: true, shiftKey: true })).toBe('redo')
|
|
296
|
+
expect(key('y', { ctrlKey: true })).toBe('redo')
|
|
297
|
+
expect(key('f', { ctrlKey: true })).toBeNull()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
function element(
|
|
301
|
+
matches: Record<string, boolean>,
|
|
302
|
+
page = false,
|
|
303
|
+
dialogOpen = false,
|
|
304
|
+
): Element & { ownerDocument: unknown } {
|
|
305
|
+
const doc = {
|
|
306
|
+
body: null as unknown,
|
|
307
|
+
documentElement: null,
|
|
308
|
+
querySelector: (selector: string) =>
|
|
309
|
+
dialogOpen && selector.includes('dialog') ? {} : null,
|
|
310
|
+
}
|
|
311
|
+
const el = {
|
|
312
|
+
closest: (selector: string) =>
|
|
313
|
+
Object.entries(matches).some(
|
|
314
|
+
([part, hit]) => hit && selector.includes(part),
|
|
315
|
+
)
|
|
316
|
+
? el
|
|
317
|
+
: null,
|
|
318
|
+
ownerDocument: doc,
|
|
319
|
+
}
|
|
320
|
+
if (page) doc.body = el
|
|
321
|
+
return el as unknown as Element & { ownerDocument: unknown }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
it('leaves keys to fields, dialogs and the rest of the page', () => {
|
|
325
|
+
const inside = element({})
|
|
326
|
+
const canvas = { contains: (e: unknown) => e === inside } as Element
|
|
327
|
+
expect(canvasOwnsKey(inside, canvas)).toBe(true)
|
|
328
|
+
expect(canvasOwnsKey(element({}, true), canvas)).toBe(true)
|
|
329
|
+
expect(canvasOwnsKey(element({ textarea: true }), canvas)).toBe(false)
|
|
330
|
+
// Any open dialog, wherever the focus is.
|
|
331
|
+
expect(canvasOwnsKey(element({}, true, true), canvas)).toBe(false)
|
|
332
|
+
expect(canvasOwnsKey(element({}), canvas)).toBe(false)
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
function memoryStore() {
|
|
337
|
+
const calls: string[] = []
|
|
338
|
+
let next = 0
|
|
339
|
+
const store: DiagramStore = {
|
|
340
|
+
saveLayout: async (schema, positions) => {
|
|
341
|
+
calls.push(`layout ${schema} ${Object.keys(positions).join(',')}`)
|
|
342
|
+
},
|
|
343
|
+
createNote: async (payload) => {
|
|
344
|
+
next += 1
|
|
345
|
+
calls.push(`create ${payload.schemaName} ${payload.text}`)
|
|
346
|
+
return {
|
|
347
|
+
...payload,
|
|
348
|
+
id: `n${next}`,
|
|
349
|
+
color: payload.color ?? null,
|
|
350
|
+
createdAt: '',
|
|
351
|
+
updatedAt: '',
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
updateNote: async (id, patch) => {
|
|
355
|
+
calls.push(`update ${id} ${patch.text}`)
|
|
356
|
+
return {} as never
|
|
357
|
+
},
|
|
358
|
+
deleteNote: async (id) => {
|
|
359
|
+
calls.push(`delete ${id}`)
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
return { store, calls }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function note(id: string, text: string, schemaName = 'a'): NoteDraft {
|
|
366
|
+
return {
|
|
367
|
+
id,
|
|
368
|
+
schemaName,
|
|
369
|
+
text,
|
|
370
|
+
x: 0,
|
|
371
|
+
y: 0,
|
|
372
|
+
width: 220,
|
|
373
|
+
height: 140,
|
|
374
|
+
color: null,
|
|
375
|
+
isTemp: id.startsWith('temp'),
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
describe('diagram autosave (D-2)', () => {
|
|
380
|
+
afterEach(() => {
|
|
381
|
+
vi.useRealTimers()
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('writes a schema left as it was, under its own id', async () => {
|
|
385
|
+
vi.useFakeTimers()
|
|
386
|
+
const { store, calls } = memoryStore()
|
|
387
|
+
const statuses: SaveStatus[] = []
|
|
388
|
+
// One table moved and one note written in a, then b loads at once.
|
|
389
|
+
const shown: DiagramState = {
|
|
390
|
+
positions: { t: { x: 1, y: 2 } },
|
|
391
|
+
notes: [note('temp-1', 'hello')],
|
|
392
|
+
}
|
|
393
|
+
const a = new DiagramAutosave(
|
|
394
|
+
new DiagramSync(store, 'a'),
|
|
395
|
+
() => shown,
|
|
396
|
+
800,
|
|
397
|
+
(status) => statuses.push(status),
|
|
398
|
+
)
|
|
399
|
+
a.schedule()
|
|
400
|
+
const leaving = a.flush()
|
|
401
|
+
// b's state replaces a's on the canvas before a's write ends.
|
|
402
|
+
shown.positions = { u: { x: 0, y: 0 } }
|
|
403
|
+
shown.notes = []
|
|
404
|
+
await leaving
|
|
405
|
+
expect(calls).toEqual(['layout a t', 'create a hello'])
|
|
406
|
+
expect(statuses).toEqual(['saving', 'saved'])
|
|
407
|
+
// Nothing is left scheduled for a.
|
|
408
|
+
await vi.runAllTimersAsync()
|
|
409
|
+
expect(calls).toHaveLength(2)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('waits for edits to pause', async () => {
|
|
413
|
+
vi.useFakeTimers()
|
|
414
|
+
const { store, calls } = memoryStore()
|
|
415
|
+
const state: DiagramState = { positions: { t: { x: 1, y: 1 } }, notes: [] }
|
|
416
|
+
const autosave = new DiagramAutosave(
|
|
417
|
+
new DiagramSync(store, 'a'),
|
|
418
|
+
() => state,
|
|
419
|
+
800,
|
|
420
|
+
() => undefined,
|
|
421
|
+
)
|
|
422
|
+
autosave.schedule()
|
|
423
|
+
await vi.advanceTimersByTimeAsync(500)
|
|
424
|
+
autosave.schedule()
|
|
425
|
+
await vi.advanceTimersByTimeAsync(500)
|
|
426
|
+
expect(calls).toEqual([])
|
|
427
|
+
await vi.advanceTimersByTimeAsync(300)
|
|
428
|
+
expect(calls).toEqual(['layout a t'])
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('reports a failed save and writes it again on retry', async () => {
|
|
432
|
+
const { store, calls } = memoryStore()
|
|
433
|
+
let fail = true
|
|
434
|
+
const failing: DiagramStore = {
|
|
435
|
+
...store,
|
|
436
|
+
saveLayout: async (schema, positions) => {
|
|
437
|
+
if (fail) throw new Error('timeout')
|
|
438
|
+
await store.saveLayout(schema, positions)
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
const statuses: SaveStatus[] = []
|
|
442
|
+
const state: DiagramState = { positions: { t: { x: 1, y: 1 } }, notes: [] }
|
|
443
|
+
const autosave = new DiagramAutosave(
|
|
444
|
+
new DiagramSync(failing, 'a'),
|
|
445
|
+
() => state,
|
|
446
|
+
0,
|
|
447
|
+
(status) => statuses.push(status),
|
|
448
|
+
)
|
|
449
|
+
autosave.schedule()
|
|
450
|
+
await autosave.flush()
|
|
451
|
+
expect(statuses).toEqual(['saving', 'error'])
|
|
452
|
+
fail = false
|
|
453
|
+
autosave.schedule()
|
|
454
|
+
await autosave.flush()
|
|
455
|
+
expect(statuses).toEqual(['saving', 'error', 'saving', 'saved'])
|
|
456
|
+
expect(calls).toEqual(['layout a t'])
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('clears an error once nothing is left to write', async () => {
|
|
460
|
+
const { store } = memoryStore()
|
|
461
|
+
const failing: DiagramStore = {
|
|
462
|
+
...store,
|
|
463
|
+
saveLayout: async () => {
|
|
464
|
+
throw new Error('timeout')
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
const statuses: SaveStatus[] = []
|
|
468
|
+
let state: DiagramState = { positions: { t: { x: 1, y: 1 } }, notes: [] }
|
|
469
|
+
const autosave = new DiagramAutosave(
|
|
470
|
+
new DiagramSync(failing, 'a'),
|
|
471
|
+
() => state,
|
|
472
|
+
0,
|
|
473
|
+
(status) => statuses.push(status),
|
|
474
|
+
)
|
|
475
|
+
autosave.schedule()
|
|
476
|
+
await autosave.flush()
|
|
477
|
+
// The move is undone: the stored state is the shown one again.
|
|
478
|
+
state = { positions: {}, notes: [] }
|
|
479
|
+
autosave.schedule()
|
|
480
|
+
await autosave.flush()
|
|
481
|
+
expect(statuses).toEqual(['saving', 'error', 'idle'])
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
describe('diagram sync', () => {
|
|
486
|
+
it('writes a note under its stored id while the canvas keeps the temporary one', async () => {
|
|
487
|
+
const { store, calls } = memoryStore()
|
|
488
|
+
const sync = new DiagramSync(store, 'a')
|
|
489
|
+
await sync.save({ positions: {}, notes: [note('temp-1', 'hi')] })
|
|
490
|
+
expect(
|
|
491
|
+
sync.hasChanges({ positions: {}, notes: [note('temp-1', 'hi')] }),
|
|
492
|
+
).toBe(false)
|
|
493
|
+
await sync.save({ positions: {}, notes: [note('temp-1', 'hi there')] })
|
|
494
|
+
await sync.save({ positions: {}, notes: [] })
|
|
495
|
+
expect(calls).toEqual(['create a hi', 'update n1 hi there', 'delete n1'])
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
describe('diagram history', () => {
|
|
500
|
+
it('undoes a note created and gives it back on redo', () => {
|
|
501
|
+
const state: DiagramState = { positions: {}, notes: [note('n1', 'x')] }
|
|
502
|
+
const history = new DiagramHistory()
|
|
503
|
+
history.push({ kind: 'noteCreate', snapshot: note('n1', 'x') })
|
|
504
|
+
history.push(
|
|
505
|
+
{ kind: 'noteDelete', snapshot: note('n1', 'x') },
|
|
506
|
+
Date.now() + 1000,
|
|
507
|
+
)
|
|
508
|
+
state.notes = []
|
|
509
|
+
expect(history.undo(state)).toBe(true)
|
|
510
|
+
expect(state.notes.map((n) => n.id)).toEqual(['n1'])
|
|
511
|
+
expect(history.undo(state)).toBe(true)
|
|
512
|
+
expect(state.notes).toEqual([])
|
|
513
|
+
expect(history.redo(state)).toBe(true)
|
|
514
|
+
expect(state.notes.map((n) => n.id)).toEqual(['n1'])
|
|
515
|
+
})
|
|
516
|
+
})
|