@jxsuite/studio 0.37.0 → 1.0.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 (38) hide show
  1. package/dist/studio.js +77654 -76269
  2. package/dist/studio.js.map +128 -119
  3. package/package.json +44 -44
  4. package/src/account-status.ts +39 -0
  5. package/src/browse/browse.ts +10 -14
  6. package/src/canvas/iframe-host.ts +1 -1
  7. package/src/editor/context-menu.ts +1 -1
  8. package/src/editor/repeater-scope.ts +8 -13
  9. package/src/files/files.ts +3 -0
  10. package/src/format/format-host.ts +63 -1
  11. package/src/new-project/add-repo-modal.ts +183 -0
  12. package/src/new-project/new-project-modal.ts +22 -3
  13. package/src/page-params.ts +34 -8
  14. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  15. package/src/panels/ai-panel.ts +61 -4
  16. package/src/panels/data-grid.ts +619 -0
  17. package/src/panels/signals-panel.ts +102 -437
  18. package/src/panels/statusbar.ts +1 -1
  19. package/src/panels/welcome-screen.ts +50 -0
  20. package/src/platform-errors.ts +30 -0
  21. package/src/platforms/cloud.ts +172 -89
  22. package/src/platforms/devserver.ts +172 -0
  23. package/src/services/context-resolver.ts +73 -0
  24. package/src/services/data-service.ts +155 -0
  25. package/src/services/monaco-setup.ts +75 -26
  26. package/src/settings/contributed-section.ts +406 -0
  27. package/src/settings/extension-sections.ts +145 -0
  28. package/src/settings/schema-field-ui.ts +4 -2
  29. package/src/settings/settings-modal.ts +101 -42
  30. package/src/site-context.ts +10 -1
  31. package/src/studio.ts +24 -0
  32. package/src/tabs/transact.ts +1 -1
  33. package/src/types.ts +120 -1
  34. package/src/ui/form-controls.ts +322 -0
  35. package/src/ui/progress-modal.ts +2 -2
  36. package/src/ui/schema-form.ts +524 -0
  37. package/src/utils/studio-utils.ts +4 -3
  38. package/src/settings/content-types-editor.ts +0 -599
@@ -0,0 +1,619 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Data grid — the owner console over the platform's data surface (plan Part 4a).
4
+ *
5
+ * Integration: the settings modal's contributed sections stay fully generic — extension-sections
6
+ * passes this module's {@link dataSectionActions} into the ContributedSectionOptions.actions slot
7
+ * for the data-domain sections ("connections"/"data") whenever the platform implements the
8
+ * protocol's data routes. Those actions surface Test Connection, Push Schema (dry-run plan
9
+ * confirmation before apply), and Open Data Grid; the grid itself is a full-surface modal
10
+ * (settings-modal pattern) gated on `platform.dataRows`.
11
+ *
12
+ * The grid is v1-minimal by design: connection + table picker (declared tables plus system tables
13
+ * discovered from backend introspection), 50-row pagination, inline cell edit committed via
14
+ * dataUpdateRow keyed on the introspected primary key, an add-row footer, and two-step per-row
15
+ * delete. No filter/sort UI.
16
+ */
17
+
18
+ import { html, nothing } from "lit-html";
19
+ import { live } from "lit-html/directives/live.js";
20
+ import { openModal } from "../ui/layers";
21
+ import {
22
+ dataSurfaceAvailable,
23
+ deleteRow,
24
+ fetchConnections,
25
+ fetchRows,
26
+ insertRow,
27
+ pushSchema,
28
+ testConnection,
29
+ updateRow,
30
+ } from "../services/data-service";
31
+ import type { TemplateResult } from "lit-html";
32
+ import type {
33
+ DataColumnMeta,
34
+ DataConnectionInfo,
35
+ DataConnectionTestResult,
36
+ DataPushResult,
37
+ } from "../types";
38
+ import type { SectionActionsContext } from "../settings/contributed-section";
39
+
40
+ export const DATA_GRID_PAGE_SIZE = 50;
41
+
42
+ // ─── Grid state ───────────────────────────────────────────────────────────────
43
+
44
+ interface GridState {
45
+ connections: DataConnectionInfo[];
46
+ connection: string | null;
47
+ table: string | null;
48
+ columns: DataColumnMeta[];
49
+ rows: Record<string, unknown>[];
50
+ total: number;
51
+ offset: number;
52
+ loading: boolean;
53
+ error: string | null;
54
+ /** Pk value armed for the two-step delete confirm. */
55
+ pendingDelete: string | null;
56
+ /** Add-row footer draft, keyed by column name. */
57
+ draft: Record<string, string>;
58
+ }
59
+
60
+ const blankGrid = (): GridState => ({
61
+ columns: [],
62
+ connection: null,
63
+ connections: [],
64
+ draft: {},
65
+ error: null,
66
+ loading: false,
67
+ offset: 0,
68
+ pendingDelete: null,
69
+ rows: [],
70
+ table: null,
71
+ total: 0,
72
+ });
73
+
74
+ let grid: GridState = blankGrid();
75
+ let gridHandle: ReturnType<typeof openModal> | null = null;
76
+
77
+ /** Reset all module UI state and close any open surfaces (test hook / project switch). */
78
+ export function resetDataGridState(): void {
79
+ grid = blankGrid();
80
+ gridHandle?.close();
81
+ gridHandle = null;
82
+ actionsState = { pushing: false, testResult: null, testing: null };
83
+ closePushDialog();
84
+ }
85
+
86
+ /** True when the platform serves the data grid. */
87
+ export function isDataGridAvailable(): boolean {
88
+ return dataSurfaceAvailable();
89
+ }
90
+
91
+ /** Close the grid modal. */
92
+ export function closeDataGrid(): void {
93
+ gridHandle?.close();
94
+ gridHandle = null;
95
+ }
96
+
97
+ /**
98
+ * Open the data grid modal, loading connections and the first page.
99
+ *
100
+ * @param {{ connection?: string; table?: string }} [preselect]
101
+ */
102
+ export async function openDataGrid(preselect: { connection?: string; table?: string } = {}) {
103
+ if (!isDataGridAvailable() || gridHandle) {
104
+ return;
105
+ }
106
+ grid = blankGrid();
107
+ grid.loading = true;
108
+ renderGrid();
109
+ const res = await fetchConnections().catch(() => null);
110
+ grid.connections = res?.connections ?? [];
111
+ grid.connection =
112
+ (preselect.connection &&
113
+ grid.connections.some((c) => c.name === preselect.connection) &&
114
+ preselect.connection) ||
115
+ grid.connections.find((c) => c.isDefault)?.name ||
116
+ grid.connections[0]?.name ||
117
+ null;
118
+ const tables = currentTables();
119
+ grid.table =
120
+ preselect.table && tables.includes(preselect.table) ? preselect.table : (tables[0] ?? null);
121
+ await loadRows();
122
+ }
123
+
124
+ /** Table names of the selected connection (declared + backend-introspected system tables). */
125
+ function currentTables(): string[] {
126
+ return grid.connections.find((c) => c.name === grid.connection)?.tables ?? [];
127
+ }
128
+
129
+ /** The primary-key column name (backend convention: "id" when nothing is flagged). */
130
+ function pkColumn(): string {
131
+ return grid.columns.find((c) => c.pk)?.name ?? "id";
132
+ }
133
+
134
+ /** Load the current page. */
135
+ async function loadRows(): Promise<void> {
136
+ if (!grid.table) {
137
+ grid.rows = [];
138
+ grid.columns = [];
139
+ grid.total = 0;
140
+ grid.loading = false;
141
+ renderGrid();
142
+ return;
143
+ }
144
+ grid.loading = true;
145
+ renderGrid();
146
+ try {
147
+ const result = await fetchRows({
148
+ limit: DATA_GRID_PAGE_SIZE,
149
+ offset: grid.offset,
150
+ table: grid.table,
151
+ ...(grid.connection === null ? {} : { connection: grid.connection }),
152
+ });
153
+ grid.rows = result.rows;
154
+ grid.columns = result.columns;
155
+ grid.total = result.total;
156
+ grid.error = null;
157
+ } catch (error) {
158
+ grid.rows = [];
159
+ grid.columns = [];
160
+ grid.total = 0;
161
+ grid.error = error instanceof Error ? error.message : String(error);
162
+ }
163
+ grid.loading = false;
164
+ grid.pendingDelete = null;
165
+ renderGrid();
166
+ }
167
+
168
+ /** Commit one edited cell via dataUpdateRow, keyed on the row's pk. */
169
+ async function commitCell(
170
+ row: Record<string, unknown>,
171
+ column: DataColumnMeta,
172
+ raw: string,
173
+ ): Promise<void> {
174
+ const current = row[column.name];
175
+ const currentText = current == null ? "" : String(current);
176
+ if (raw === currentText || !grid.table) {
177
+ return;
178
+ }
179
+ try {
180
+ const updated = await updateRow({
181
+ pk: row[pkColumn()] as string | number,
182
+ set: { [column.name]: raw === "" ? null : raw },
183
+ table: grid.table,
184
+ ...(grid.connection === null ? {} : { connection: grid.connection }),
185
+ });
186
+ const index = grid.rows.indexOf(row);
187
+ if (index !== -1) {
188
+ grid.rows[index] = updated.row;
189
+ }
190
+ grid.error = null;
191
+ } catch (error) {
192
+ grid.error = error instanceof Error ? error.message : String(error);
193
+ }
194
+ renderGrid();
195
+ }
196
+
197
+ /** Two-step delete: first click arms the row, second click deletes. */
198
+ async function requestDelete(pk: string): Promise<void> {
199
+ if (grid.pendingDelete !== pk) {
200
+ grid.pendingDelete = pk;
201
+ renderGrid();
202
+ return;
203
+ }
204
+ if (!grid.table) {
205
+ return;
206
+ }
207
+ try {
208
+ await deleteRow({
209
+ pk,
210
+ table: grid.table,
211
+ ...(grid.connection === null ? {} : { connection: grid.connection }),
212
+ });
213
+ await loadRows();
214
+ } catch (error) {
215
+ grid.error = error instanceof Error ? error.message : String(error);
216
+ grid.pendingDelete = null;
217
+ renderGrid();
218
+ }
219
+ }
220
+
221
+ /** Insert the add-row footer draft. */
222
+ async function commitDraft(): Promise<void> {
223
+ if (!grid.table) {
224
+ return;
225
+ }
226
+ const values: Record<string, unknown> = {};
227
+ for (const [name, raw] of Object.entries(grid.draft)) {
228
+ if (raw !== "") {
229
+ values[name] = raw;
230
+ }
231
+ }
232
+ try {
233
+ await insertRow({
234
+ table: grid.table,
235
+ values,
236
+ ...(grid.connection === null ? {} : { connection: grid.connection }),
237
+ });
238
+ grid.draft = {};
239
+ await loadRows();
240
+ } catch (error) {
241
+ grid.error = error instanceof Error ? error.message : String(error);
242
+ renderGrid();
243
+ }
244
+ }
245
+
246
+ /** Columns editable in the grid (system-managed columns are display-only). */
247
+ function editableColumn(column: DataColumnMeta): boolean {
248
+ return !column.pk && column.name !== "created_at" && column.name !== "updated_at";
249
+ }
250
+
251
+ function gridBody(): TemplateResult {
252
+ if (grid.loading) {
253
+ return html`<div class="data-grid-empty">Loading…</div>`;
254
+ }
255
+ if (grid.error) {
256
+ return html`<div class="data-grid-error">${grid.error}</div>`;
257
+ }
258
+ if (!grid.table) {
259
+ return html`<div class="data-grid-empty">
260
+ No tables on this connection — push a schema first.
261
+ </div>`;
262
+ }
263
+ const pk = pkColumn();
264
+ return html`
265
+ <table class="data-grid-table">
266
+ <thead>
267
+ <tr>
268
+ ${grid.columns.map(
269
+ (c) =>
270
+ html`<th title=${c.type}>
271
+ ${c.name}${c.pk ? html` <span class="data-grid-pk">pk</span>` : nothing}
272
+ </th>`,
273
+ )}
274
+ <th></th>
275
+ </tr>
276
+ </thead>
277
+ <tbody>
278
+ ${grid.rows.map((row) => {
279
+ const pkValue = String(row[pk] ?? "");
280
+ return html`<tr>
281
+ ${grid.columns.map((c) =>
282
+ editableColumn(c)
283
+ ? html`<td>
284
+ <input
285
+ class="data-grid-cell"
286
+ .value=${live(row[c.name] == null ? "" : String(row[c.name]))}
287
+ @change=${(e: Event) =>
288
+ void commitCell(row, c, (e.target as HTMLInputElement).value)}
289
+ />
290
+ </td>`
291
+ : html`<td class="data-grid-readonly">
292
+ ${row[c.name] == null ? "" : String(row[c.name])}
293
+ </td>`,
294
+ )}
295
+ <td>
296
+ <sp-action-button
297
+ quiet
298
+ size="s"
299
+ class="data-grid-delete"
300
+ @click=${() => void requestDelete(pkValue)}
301
+ >
302
+ ${grid.pendingDelete === pkValue ? "Confirm?" : "Delete"}
303
+ </sp-action-button>
304
+ </td>
305
+ </tr>`;
306
+ })}
307
+ </tbody>
308
+ <tfoot>
309
+ <tr class="data-grid-add-row">
310
+ ${grid.columns.map((c) =>
311
+ editableColumn(c)
312
+ ? html`<td>
313
+ <input
314
+ class="data-grid-draft"
315
+ placeholder=${c.name}
316
+ .value=${live(grid.draft[c.name] ?? "")}
317
+ @input=${(e: Event) => {
318
+ grid.draft[c.name] = (e.target as HTMLInputElement).value;
319
+ }}
320
+ />
321
+ </td>`
322
+ : html`<td></td>`,
323
+ )}
324
+ <td>
325
+ <sp-action-button size="s" class="data-grid-add" @click=${() => void commitDraft()}>
326
+ Add
327
+ </sp-action-button>
328
+ </td>
329
+ </tr>
330
+ </tfoot>
331
+ </table>
332
+ `;
333
+ }
334
+
335
+ function renderGrid(): void {
336
+ const first = grid.total === 0 ? 0 : grid.offset + 1;
337
+ const last = grid.offset + grid.rows.length;
338
+ const tpl = html`
339
+ <sp-underlay open @close=${closeDataGrid}></sp-underlay>
340
+ <div class="settings-modal data-grid-modal">
341
+ <div class="settings-modal-header">
342
+ <h2 class="settings-modal-title">Data</h2>
343
+ <sp-picker
344
+ size="s"
345
+ class="data-grid-connection"
346
+ value=${grid.connection ?? ""}
347
+ @change=${(e: Event) => {
348
+ grid.connection = (e.target as HTMLInputElement).value || null;
349
+ grid.offset = 0;
350
+ grid.table = currentTables()[0] ?? null;
351
+ void loadRows();
352
+ }}
353
+ >
354
+ ${grid.connections.map(
355
+ (c) => html`<sp-menu-item value=${c.name}>${c.name}</sp-menu-item>`,
356
+ )}
357
+ </sp-picker>
358
+ <sp-picker
359
+ size="s"
360
+ class="data-grid-tables"
361
+ value=${grid.table ?? ""}
362
+ @change=${(e: Event) => {
363
+ grid.table = (e.target as HTMLInputElement).value || null;
364
+ grid.offset = 0;
365
+ grid.draft = {};
366
+ void loadRows();
367
+ }}
368
+ >
369
+ ${currentTables().map(
370
+ (table) => html`<sp-menu-item value=${table}>${table}</sp-menu-item>`,
371
+ )}
372
+ </sp-picker>
373
+ <sp-action-button quiet size="s" title="Close" @click=${closeDataGrid}>
374
+ <sp-icon-close slot="icon"></sp-icon-close>
375
+ </sp-action-button>
376
+ </div>
377
+ <div class="settings-modal-body data-grid-body">${gridBody()}</div>
378
+ <div class="data-grid-footer">
379
+ <sp-action-button
380
+ size="s"
381
+ class="data-grid-prev"
382
+ ?disabled=${grid.offset === 0}
383
+ @click=${() => {
384
+ grid.offset = Math.max(0, grid.offset - DATA_GRID_PAGE_SIZE);
385
+ void loadRows();
386
+ }}
387
+ >Prev</sp-action-button
388
+ >
389
+ <span class="data-grid-range">${first}–${last} of ${grid.total}</span>
390
+ <sp-action-button
391
+ size="s"
392
+ class="data-grid-next"
393
+ ?disabled=${grid.offset + DATA_GRID_PAGE_SIZE >= grid.total}
394
+ @click=${() => {
395
+ grid.offset += DATA_GRID_PAGE_SIZE;
396
+ void loadRows();
397
+ }}
398
+ >Next</sp-action-button
399
+ >
400
+ </div>
401
+ </div>
402
+ `;
403
+ if (gridHandle) {
404
+ gridHandle.update(tpl);
405
+ } else {
406
+ gridHandle = openModal(tpl);
407
+ }
408
+ }
409
+
410
+ // ─── Push dialog (dry-run plan confirmation before apply) ─────────────────────
411
+
412
+ interface PushDialogState {
413
+ handle: ReturnType<typeof openModal>;
414
+ connection: string | undefined;
415
+ phase: "loading" | "confirm" | "applying" | "done";
416
+ plan: DataPushResult | null;
417
+ result: DataPushResult | null;
418
+ onDone: () => void;
419
+ }
420
+
421
+ let pushDialog: PushDialogState | null = null;
422
+
423
+ /** Close the push dialog (also part of resetDataGridState). */
424
+ function closePushDialog(): void {
425
+ pushDialog?.handle.close();
426
+ pushDialog = null;
427
+ }
428
+
429
+ /** Start a push: dry-run first, then a confirmation dialog gates the apply. */
430
+ export async function startPush(connection: string | undefined, onDone: () => void) {
431
+ if (pushDialog) {
432
+ return;
433
+ }
434
+ const handle = openModal(html``);
435
+ pushDialog = { connection, handle, onDone, phase: "loading", plan: null, result: null };
436
+ renderPushDialog();
437
+ const plan = await pushSchema({
438
+ dryRun: true,
439
+ ...(connection === undefined ? {} : { connection }),
440
+ });
441
+ if (!pushDialog) {
442
+ return;
443
+ }
444
+ pushDialog.plan = plan;
445
+ pushDialog.phase = "confirm";
446
+ renderPushDialog();
447
+ }
448
+
449
+ async function applyPush(): Promise<void> {
450
+ if (!pushDialog) {
451
+ return;
452
+ }
453
+ pushDialog.phase = "applying";
454
+ renderPushDialog();
455
+ const result = await pushSchema(
456
+ pushDialog.connection === undefined ? {} : { connection: pushDialog.connection },
457
+ );
458
+ if (!pushDialog) {
459
+ return;
460
+ }
461
+ pushDialog.result = result;
462
+ pushDialog.phase = "done";
463
+ renderPushDialog();
464
+ pushDialog.onDone();
465
+ }
466
+
467
+ function pushDialogBody(state: PushDialogState): TemplateResult {
468
+ if (state.phase === "loading" || state.phase === "applying") {
469
+ return html`<div class="push-dialog-status">
470
+ ${state.phase === "loading" ? "Compiling plan…" : "Applying…"}
471
+ </div>`;
472
+ }
473
+ const shown = state.phase === "done" ? state.result : state.plan;
474
+ const steps = shown?.plan ?? [];
475
+ const warnings = shown?.warnings ?? [];
476
+ const errors = shown?.errors ?? [];
477
+ return html`
478
+ <div class="push-dialog-plan">
479
+ ${state.phase === "done"
480
+ ? html`<div class="push-dialog-status">
481
+ ${shown?.applied ? "Schema applied." : "Push failed."}
482
+ </div>`
483
+ : steps.length === 0 && errors.length === 0
484
+ ? html`<div class="push-dialog-status">Nothing to push — the schema is up to date.</div>`
485
+ : nothing}
486
+ <ul class="push-dialog-steps">
487
+ ${steps.map(
488
+ (step) => html`<li class="push-step push-step-${step.kind}">${step.summary}</li>`,
489
+ )}
490
+ </ul>
491
+ ${warnings.map((w) => html`<div class="push-dialog-warning">${w}</div>`)}
492
+ ${errors.map((e) => html`<div class="push-dialog-error">${e}</div>`)}
493
+ </div>
494
+ `;
495
+ }
496
+
497
+ function renderPushDialog(): void {
498
+ if (!pushDialog) {
499
+ return;
500
+ }
501
+ const state = pushDialog;
502
+ const confirmable = state.phase === "confirm" && (state.plan?.plan.length ?? 0) > 0;
503
+ const tpl = html`
504
+ <sp-underlay open @close=${closePushDialog}></sp-underlay>
505
+ <div class="settings-modal push-dialog">
506
+ <div class="settings-modal-header">
507
+ <h2 class="settings-modal-title">
508
+ Push Schema${state.connection ? html` — ${state.connection}` : nothing}
509
+ </h2>
510
+ </div>
511
+ <div class="settings-modal-body">${pushDialogBody(state)}</div>
512
+ <div class="push-dialog-actions">
513
+ <sp-action-button size="s" class="push-cancel" @click=${closePushDialog}>
514
+ ${state.phase === "done" ? "Close" : "Cancel"}
515
+ </sp-action-button>
516
+ ${confirmable
517
+ ? html`<sp-action-button
518
+ size="s"
519
+ emphasized
520
+ class="push-apply"
521
+ @click=${() => void applyPush()}
522
+ >Apply</sp-action-button
523
+ >`
524
+ : nothing}
525
+ </div>
526
+ </div>
527
+ `;
528
+ state.handle.update(tpl);
529
+ }
530
+
531
+ // ─── Contributed-section actions (Test / Push / Open grid) ────────────────────
532
+
533
+ interface ActionsState {
534
+ /** Connection currently being tested, when any. */
535
+ testing: string | null;
536
+ testResult: (DataConnectionTestResult & { connection: string }) | null;
537
+ pushing: boolean;
538
+ }
539
+
540
+ let actionsState: ActionsState = { pushing: false, testResult: null, testing: null };
541
+
542
+ /**
543
+ * The actions renderer for a data-domain contributed section, or null when the section is not
544
+ * data-domain or the platform lacks the data routes. Section keys "connections"/"data" are the
545
+ * connector's host wire contract (the same literals the backend's data routes serve) — the generic
546
+ * contributed-section renderer itself stays extension-agnostic.
547
+ *
548
+ * @param {string} sectionKey
549
+ * @returns {((ctx: SectionActionsContext) => TemplateResult) | null}
550
+ */
551
+ export function dataSectionActions(
552
+ sectionKey: string,
553
+ ): ((ctx: SectionActionsContext) => TemplateResult) | null {
554
+ if ((sectionKey !== "connections" && sectionKey !== "data") || !dataSurfaceAvailable()) {
555
+ return null;
556
+ }
557
+ return (ctx) => renderSectionActions(sectionKey, ctx);
558
+ }
559
+
560
+ async function runTest(connection: string, rerender: () => void): Promise<void> {
561
+ actionsState.testing = connection;
562
+ actionsState.testResult = null;
563
+ rerender();
564
+ const result = await testConnection(connection);
565
+ actionsState.testing = null;
566
+ actionsState.testResult = { ...result, connection };
567
+ rerender();
568
+ }
569
+
570
+ function renderSectionActions(sectionKey: string, ctx: SectionActionsContext): TemplateResult {
571
+ const { selected, rerender } = ctx;
572
+ const { testResult } = actionsState;
573
+ // On the connections section a selected entry scopes both Test and Push to that connection.
574
+ const pushTarget = sectionKey === "connections" && selected ? selected : undefined;
575
+ return html`
576
+ <div class="data-section-actions">
577
+ ${sectionKey === "connections"
578
+ ? html`<sp-action-button
579
+ size="s"
580
+ class="data-action-test"
581
+ ?disabled=${!selected || actionsState.testing !== null}
582
+ @click=${() => {
583
+ if (selected) {
584
+ void runTest(selected, rerender);
585
+ }
586
+ }}
587
+ >
588
+ ${actionsState.testing ? "Testing…" : "Test Connection"}
589
+ </sp-action-button>`
590
+ : nothing}
591
+ <sp-action-button
592
+ size="s"
593
+ class="data-action-push"
594
+ @click=${() => void startPush(pushTarget, rerender)}
595
+ >
596
+ Push Schema
597
+ </sp-action-button>
598
+ <sp-action-button
599
+ size="s"
600
+ class="data-action-grid"
601
+ @click=${() =>
602
+ void openDataGrid(
603
+ sectionKey === "connections" && selected ? { connection: selected } : {},
604
+ )}
605
+ >
606
+ Open Data Grid
607
+ </sp-action-button>
608
+ ${testResult
609
+ ? html`<span
610
+ class="data-test-result ${testResult.ok ? "ok" : "failed"}"
611
+ title=${testResult.error ?? ""}
612
+ >
613
+ ${testResult.connection}:
614
+ ${testResult.ok ? "connected" : (testResult.error ?? "failed")}
615
+ </span>`
616
+ : nothing}
617
+ </div>
618
+ `;
619
+ }