@marimo-team/islands 0.23.16-dev6 → 0.23.16-dev62
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/dist/{ConnectedDataExplorerComponent-CJaUI-S-.js → ConnectedDataExplorerComponent-f9vLjQiF.js} +13 -13
- package/dist/{ErrorBoundary-DXlxsrTF.js → ErrorBoundary-DhLqwSWA.js} +3 -3
- package/dist/{any-language-editor-P9tjkj4n.js → any-language-editor-DifE-fl7.js} +5 -5
- package/dist/{button-BcSml68y.js → button-BSm8IXRU.js} +2 -4
- package/dist/{capabilities-BYLFYTYv.js → capabilities-C6OQ5YBO.js} +1 -1
- package/dist/{chat-ui-BdSDiVjk.js → chat-ui-DsZJj75A.js} +3117 -3120
- package/dist/{check-Cs-KV5XY.js → check-DP7U0H0G.js} +1 -1
- package/dist/{code-visibility-CfBftiVM.js → common-Bpnmu1Yz.js} +1085 -1053
- package/dist/{copy-Cs3yt6Vt.js → copy-DNq8q3zw.js} +2 -2
- package/dist/{dist-B96tMAst.js → dist-DIlxLFXG.js} +2 -2
- package/dist/{error-banner-DR7o3CbX.js → error-banner-B_GQfxxD.js} +2 -2
- package/dist/{esm-k8PIvuZW.js → esm-DzZVaNH4.js} +1 -1
- package/dist/{extends-Cq2JYHKy.js → extends-w2_S44bV.js} +4 -4
- package/dist/{formats-BFhkZJwd.js → formats-Dwq1THhr.js} +3 -3
- package/dist/{glide-data-editor--CaN-f27.js → glide-data-editor-B9K6-MJe.js} +9 -9
- package/dist/{html-to-image-DDENewl2.js → html-to-image-CZ1kLKkq.js} +7350 -6356
- package/dist/{input-DStQIuqD.js → input-C5K4VBvB.js} +7 -7
- package/dist/{label-DIk0QoFe.js → label-DjoYwqmj.js} +2 -2
- package/dist/{loader-CTHCI-qe.js → loader-DX-IDS7n.js} +1 -1
- package/dist/main.js +259 -194
- package/dist/{mermaid-CdSvixW2.js → mermaid-hv6IckVw.js} +5 -5
- package/dist/{node-sql-parser-DLl_zNw0.js → node-sql-parser-v1Vi-Fft.js} +16867 -16665
- package/dist/{process-output-2QPcgBQg.js → process-output-bVfbcx5_.js} +32 -28
- package/dist/{reveal-component-BclLkYnV.js → reveal-component-JIt3oSeU.js} +642 -634
- package/dist/{spec-CnaxwDqy.js → spec-CQKL_D3i.js} +4 -4
- package/dist/{strings-BYAhZnQi.js → strings-DIy9NAmg.js} +4 -4
- package/dist/style.css +1 -1
- package/dist/{swiper-component-DE94711u.js → swiper-component-CEdAYjb7.js} +2 -2
- package/dist/toDate-CqhBWjvO.js +713 -0
- package/dist/{tooltip-B-qh7UPB.js → tooltip-f-qR0iUx.js} +3 -3
- package/dist/{types-DQl7jVYK.js → types-DicH9Oi0.js} +1 -1
- package/dist/{useAsyncData-CvO8WUy3.js → useAsyncData-CYOQb1EW.js} +1 -1
- package/dist/{useDateFormatter-BSoGim-G.js → useDateFormatter-Bt2qr_XS.js} +2 -2
- package/dist/{useDeepCompareMemoize-J3MKRdA4.js → useDeepCompareMemoize-CSHQeQEI.js} +1 -1
- package/dist/{useIframeCapabilities-POcFXHbw.js → useIframeCapabilities-kA09ykNq.js} +1 -1
- package/dist/{useLifecycle-DqPxHgAN.js → useLifecycle-CMJGqBaY.js} +3 -3
- package/dist/{useTheme-XMq9mNf_.js → useTheme-DYfby65G.js} +2 -2
- package/dist/{vega-component-CrFoZT1S.js → vega-component-C7nO5zcf.js} +10 -10
- package/dist/{zod-D0yOqoGe.js → zod-BUcyXSdR.js} +1 -1
- package/package.json +3 -4
- package/src/__mocks__/requests.ts +56 -4
- package/src/components/data-table/__tests__/__snapshots__/chart-spec-model.test.ts.snap +174 -21
- package/src/components/data-table/__tests__/chart-spec-model.test.ts +102 -1
- package/src/components/data-table/__tests__/columns.test.tsx +9 -1
- package/src/components/data-table/column-summary/chart-spec-model.tsx +163 -103
- package/src/components/data-table/column-summary/legacy-chart-spec.ts +15 -7
- package/src/components/data-table/column-summary/utils.ts +26 -7
- package/src/components/data-table/columns.tsx +11 -1
- package/src/components/data-table/types.ts +1 -1
- package/src/components/editor/__tests__/viewer-banner.test.tsx +14 -0
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +99 -18
- package/src/components/editor/actions/__tests__/pdf-export.test.ts +32 -13
- package/src/components/editor/actions/pair-with-agent-commands.ts +21 -10
- package/src/components/editor/actions/pair-with-agent-modal.tsx +2 -2
- package/src/components/editor/actions/pdf-export.ts +11 -14
- package/src/components/editor/actions/useNotebookActions.tsx +28 -19
- package/src/components/editor/cell/code/__tests__/cell-editor-activity-lifecycle.test.ts +250 -0
- package/src/components/editor/cell/code/cell-editor.tsx +13 -7
- package/src/components/editor/chrome/__tests__/state.test.ts +7 -1
- package/src/components/editor/chrome/panels/__tests__/logs-panel.test.tsx +25 -0
- package/src/components/editor/chrome/panels/__tests__/panel-accordion-state.test.ts +42 -0
- package/src/components/editor/chrome/panels/file-explorer-panel.tsx +6 -17
- package/src/components/editor/chrome/panels/logs-panel.tsx +12 -2
- package/src/components/editor/chrome/panels/panel-accordion-state.ts +51 -0
- package/src/components/editor/chrome/panels/session-panel.tsx +5 -16
- package/src/components/editor/chrome/panels/write-secret-modal.tsx +40 -12
- package/src/components/editor/chrome/state.ts +10 -0
- package/src/components/editor/connections/__tests__/json-credentials.test.ts +114 -0
- package/src/components/editor/connections/__tests__/paths.test.ts +39 -0
- package/src/components/editor/connections/__tests__/quick-add-data-sources.test.tsx +113 -0
- package/src/components/editor/connections/add-connection-dialog.tsx +2 -0
- package/src/components/editor/connections/components.tsx +6 -2
- package/src/components/editor/connections/database/__tests__/__snapshots__/as-code.test.ts.snap +27 -5
- package/src/components/editor/connections/database/__tests__/as-code.test.ts +33 -0
- package/src/components/editor/connections/database/as-code.ts +37 -13
- package/src/components/editor/connections/database/schemas.ts +6 -5
- package/src/components/editor/connections/form-renderers.tsx +99 -1
- package/src/components/editor/connections/json-credentials.ts +57 -0
- package/src/components/editor/connections/paths.ts +21 -0
- package/src/components/editor/connections/quick-add-data-sources.tsx +106 -0
- package/src/components/editor/connections/secret-combobox.tsx +41 -20
- package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +88 -2
- package/src/components/editor/connections/storage/__tests__/as-code.test.ts +128 -8
- package/src/components/editor/connections/storage/as-code.ts +79 -36
- package/src/components/editor/connections/storage/schemas.ts +85 -44
- package/src/components/editor/file-tree/__tests__/renderers.test.ts +31 -0
- package/src/components/editor/file-tree/file-viewer.tsx +11 -7
- package/src/components/editor/file-tree/renderers.tsx +21 -0
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +2 -2
- package/src/components/editor/viewer-banner.tsx +6 -1
- package/src/components/forms/form.tsx +59 -24
- package/src/components/forms/options.ts +2 -1
- package/src/components/slides/__tests__/cell-output-block.test.tsx +40 -0
- package/src/components/slides/__tests__/slide-cell-view.test.tsx +33 -0
- package/src/components/slides/reveal-component.tsx +22 -17
- package/src/components/slides/slide-cell-view.tsx +1 -1
- package/src/components/slides/slides.css +4 -2
- package/src/components/static-html/share-modal.tsx +1 -1
- package/src/components/ui/input.tsx +1 -1
- package/src/core/ai/context/providers/file.ts +8 -29
- package/src/core/cells/__tests__/cells.test.ts +12 -2
- package/src/core/cells/__tests__/logs.test.ts +5 -5
- package/src/core/cells/cells.ts +17 -4
- package/src/core/cells/document-changes.ts +1 -0
- package/src/core/cells/logs.ts +14 -4
- package/src/core/codemirror/cm.ts +3 -0
- package/src/core/codemirror/code-lens/__tests__/actions.test.ts +68 -0
- package/src/core/codemirror/code-lens/__tests__/analyzer.test.ts +224 -0
- package/src/core/codemirror/code-lens/__tests__/entities.test.ts +175 -0
- package/src/core/codemirror/code-lens/__tests__/extension.test.ts +325 -0
- package/src/core/codemirror/code-lens/__tests__/perf.test.ts +121 -0
- package/src/core/codemirror/code-lens/__tests__/popover.test.ts +93 -0
- package/src/core/codemirror/code-lens/actions.ts +33 -0
- package/src/core/codemirror/code-lens/analyzer.ts +284 -0
- package/src/core/codemirror/code-lens/entities.ts +123 -0
- package/src/core/codemirror/code-lens/extension.ts +298 -0
- package/src/core/codemirror/code-lens/icons.ts +36 -0
- package/src/core/codemirror/code-lens/popover.tsx +240 -0
- package/src/core/codemirror/copilot/__tests__/language-server.test.ts +396 -100
- package/src/core/codemirror/copilot/__tests__/transport.test.ts +128 -540
- package/src/core/codemirror/copilot/language-server.ts +182 -70
- package/src/core/codemirror/copilot/transport.ts +15 -207
- package/src/core/codemirror/copilot/types.ts +9 -2
- package/src/core/codemirror/find-replace/__tests__/navigate.test.ts +42 -30
- package/src/core/codemirror/find-replace/navigate.ts +2 -0
- package/src/core/codemirror/go-to-definition/__tests__/commands.test.ts +24 -3
- package/src/core/codemirror/go-to-definition/commands.ts +93 -90
- package/src/core/codemirror/keymaps/__tests__/keymaps.test.ts +31 -2
- package/src/core/codemirror/keymaps/keymaps.ts +12 -0
- package/src/core/codemirror/language/__tests__/sql.test.ts +627 -61
- package/src/core/codemirror/language/languages/python.ts +23 -11
- package/src/core/codemirror/language/languages/sql/completion-store.ts +5 -1
- package/src/core/codemirror/language/languages/sql/renderers.tsx +3 -3
- package/src/core/codemirror/language/languages/sql/sql.ts +251 -16
- package/src/core/codemirror/lsp/__tests__/federated-lsp.test.ts +161 -0
- package/src/core/codemirror/lsp/__tests__/log-messages.test.ts +53 -0
- package/src/core/codemirror/lsp/__tests__/normalize-markdown-math.test.ts +25 -0
- package/src/core/codemirror/lsp/__tests__/notebook-lsp.test.ts +598 -156
- package/src/core/codemirror/lsp/__tests__/transports.test.ts +112 -0
- package/src/core/codemirror/lsp/federated-lsp.ts +76 -23
- package/src/core/codemirror/lsp/log-messages.ts +37 -0
- package/src/core/codemirror/lsp/normalize-markdown-math.ts +8 -4
- package/src/core/codemirror/lsp/notebook-lsp.ts +220 -56
- package/src/core/codemirror/lsp/transports.ts +8 -1
- package/src/core/codemirror/lsp/types.ts +68 -8
- package/src/core/codemirror/lsp/utils.ts +14 -0
- package/src/core/codemirror/python-node-names.ts +90 -0
- package/src/core/codemirror/reactive-references/analyzer.ts +52 -57
- package/src/core/codemirror/utils.ts +28 -0
- package/src/core/config/feature-flag.tsx +3 -1
- package/src/core/datasets/data-source-discovery.ts +5 -0
- package/src/core/datasets/request-registry.ts +11 -0
- package/src/core/edit-app.tsx +1 -2
- package/src/core/export/__tests__/hooks.test.ts +2 -4
- package/src/core/export/hooks.ts +4 -3
- package/src/core/hotkeys/hotkeys.ts +0 -2
- package/src/core/islands/__tests__/bridge.test.ts +107 -0
- package/src/core/islands/bootstrap.ts +1 -0
- package/src/core/islands/bridge.ts +18 -0
- package/src/core/kernel/messages.ts +2 -0
- package/src/core/lsp/__tests__/transport.test.ts +509 -337
- package/src/core/lsp/transport.ts +304 -132
- package/src/core/mode.ts +10 -0
- package/src/core/network/__tests__/api.test.ts +42 -0
- package/src/core/network/__tests__/connection.test.ts +45 -0
- package/src/core/network/__tests__/requests-lazy.test.ts +47 -1
- package/src/core/network/__tests__/requests-network.test.ts +48 -0
- package/src/core/network/api.ts +27 -0
- package/src/core/network/connection.ts +15 -0
- package/src/core/network/requests-lazy.ts +3 -0
- package/src/core/network/requests-network.ts +31 -8
- package/src/core/network/requests-static.ts +3 -0
- package/src/core/network/requests-toasting.tsx +3 -0
- package/src/core/network/types.ts +27 -6
- package/src/core/rtc/state.ts +9 -0
- package/src/core/static/download-html.ts +5 -14
- package/src/core/wasm/__tests__/bridge.test.ts +99 -5
- package/src/core/wasm/bridge.ts +42 -9
- package/src/core/wasm/worker/types.ts +10 -3
- package/src/core/wasm/worker/worker.ts +7 -8
- package/src/core/websocket/useMarimoKernelConnection.tsx +4 -0
- package/src/css/app/codemirror.css +16 -2
- package/src/hooks/__tests__/useDataSourceDiscovery.test.ts +80 -0
- package/src/hooks/useDataSourceDiscovery.ts +18 -0
- package/src/plugins/impl/DataTablePlugin.tsx +4 -1
- package/src/plugins/impl/FormPlugin.tsx +1 -1
- package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +158 -0
- package/src/plugins/impl/__tests__/FormPlugin.test.tsx +162 -0
- package/src/plugins/impl/anywidget/__tests__/model.test.ts +55 -0
- package/src/plugins/impl/anywidget/model.ts +20 -5
- package/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +33 -21
- package/src/plugins/impl/data-frames/forms/renderers.tsx +11 -8
- package/src/stories/log-viewer.stories.tsx +12 -6
- package/src/utils/__tests__/blob.test.ts +27 -1
- package/src/utils/__tests__/download.test.tsx +44 -4
- package/src/utils/blob.ts +24 -1
- package/src/utils/download.ts +14 -15
- package/dist/toDate-B3IRfHhw.js +0 -692
|
@@ -12,6 +12,8 @@ import type {
|
|
|
12
12
|
EnvironmentInfo,
|
|
13
13
|
ExportAsHTMLRequest,
|
|
14
14
|
ExportAsMarkdownRequest,
|
|
15
|
+
ExportAsScriptRequest,
|
|
16
|
+
ExportedFile,
|
|
15
17
|
FileCopyRequest,
|
|
16
18
|
FileCopyResponse,
|
|
17
19
|
FileCreateRequest,
|
|
@@ -96,8 +98,11 @@ export interface RawBridge {
|
|
|
96
98
|
load_packages(request: string): Promise<string>;
|
|
97
99
|
read_file(request: string): Promise<string>;
|
|
98
100
|
set_interrupt_buffer(request: Uint8Array): Promise<string>;
|
|
99
|
-
export_html(request: ExportAsHTMLRequest): Promise<string
|
|
100
|
-
export_markdown(
|
|
101
|
+
export_html(request: ExportAsHTMLRequest): Promise<ExportedFile<string>>;
|
|
102
|
+
export_markdown(
|
|
103
|
+
request: ExportAsMarkdownRequest,
|
|
104
|
+
): Promise<ExportedFile<string>>;
|
|
105
|
+
export_script(request: ExportAsScriptRequest): Promise<ExportedFile<string>>;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
export type BridgePayload<T extends keyof RawBridge> = T extends keyof RawBridge
|
|
@@ -105,9 +110,11 @@ export type BridgePayload<T extends keyof RawBridge> = T extends keyof RawBridge
|
|
|
105
110
|
: undefined;
|
|
106
111
|
|
|
107
112
|
export type SerializedBridge = {
|
|
108
|
-
[P in keyof RawBridge]: RawBridge[P] extends (
|
|
113
|
+
[P in Exclude<keyof RawBridge, "save">]: RawBridge[P] extends (
|
|
109
114
|
payload: string,
|
|
110
115
|
) => Promise<unknown>
|
|
111
116
|
? (payload: string) => Promise<string>
|
|
112
117
|
: RawBridge[P];
|
|
118
|
+
} & {
|
|
119
|
+
save(payload: string): Promise<void>;
|
|
113
120
|
};
|
|
@@ -250,13 +250,9 @@ const requestHandler = createRPCRequestHandler({
|
|
|
250
250
|
* Save the notebook
|
|
251
251
|
*/
|
|
252
252
|
saveNotebook: async (opts: SaveNotebookRequest) => {
|
|
253
|
-
// Partially duplicated from save-worker.ts
|
|
254
253
|
await pyodideReadyPromise; // Make sure loading is done
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
save_file
|
|
258
|
-
`);
|
|
259
|
-
await saveFile(JSON.stringify(opts), WasmFileSystem.NOTEBOOK_FILENAME);
|
|
254
|
+
const bridge = await bridgeReady.promise;
|
|
255
|
+
await bridge.save(JSON.stringify(opts));
|
|
260
256
|
},
|
|
261
257
|
|
|
262
258
|
/**
|
|
@@ -287,8 +283,11 @@ const requestHandler = createRPCRequestHandler({
|
|
|
287
283
|
`);
|
|
288
284
|
}
|
|
289
285
|
|
|
290
|
-
//
|
|
291
|
-
if (
|
|
286
|
+
// Script export also parses Markdown frontmatter when present.
|
|
287
|
+
if (
|
|
288
|
+
functionName === "export_markdown" ||
|
|
289
|
+
functionName === "export_script"
|
|
290
|
+
) {
|
|
292
291
|
await self.pyodide.runPythonAsync(`
|
|
293
292
|
import micropip
|
|
294
293
|
|
|
@@ -42,6 +42,7 @@ import { connectionTransportTypeAtom, useSetAppConfig } from "../config/config";
|
|
|
42
42
|
import { useDataSourceActions } from "../datasets/data-source-connections";
|
|
43
43
|
import type { ConnectionName } from "../datasets/engines";
|
|
44
44
|
import {
|
|
45
|
+
DiscoverDataSources,
|
|
45
46
|
PreviewSQLSchemaList,
|
|
46
47
|
PreviewSQLTable,
|
|
47
48
|
PreviewSQLTableList,
|
|
@@ -394,6 +395,9 @@ export function useMarimoKernelConnection(opts: {
|
|
|
394
395
|
case "validate-sql-result":
|
|
395
396
|
ValidateSQL.resolve(msg.data.request_id as RequestId, msg.data);
|
|
396
397
|
return;
|
|
398
|
+
case "data-source-discovery-result":
|
|
399
|
+
DiscoverDataSources.resolve(msg.data.request_id as RequestId, msg.data);
|
|
400
|
+
return;
|
|
397
401
|
case "secret-keys-result":
|
|
398
402
|
SECRETS_REGISTRY.resolve(msg.data.request_id, msg.data);
|
|
399
403
|
return;
|
|
@@ -90,8 +90,22 @@
|
|
|
90
90
|
@apply bg-muted border border-border rounded-md px-1;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
/* The message, the docs link, and related info are siblings; stack them. */
|
|
94
|
+
.cm-lsp-diagnostic-message {
|
|
95
|
+
@apply flex flex-col items-start gap-1;
|
|
96
|
+
|
|
97
|
+
/* Rendered markdown, so the `pre-wrap` on `.cm-diagnostic` would only turn
|
|
98
|
+
the whitespace between block tags into blank lines. */
|
|
99
|
+
white-space: normal;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.cm-diagnostic-code-link {
|
|
103
|
+
@apply text-xs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* The docs link already reads `source(code)`; don't repeat it below. */
|
|
107
|
+
.cm-diagnostic:has(.cm-diagnostic-code-link) .cm-diagnosticSource {
|
|
108
|
+
@apply hidden;
|
|
95
109
|
}
|
|
96
110
|
}
|
|
97
111
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { DiscoverDataSources } from "@/core/datasets/request-registry";
|
|
5
|
+
import { loadDataSourceDiscovery } from "../useDataSourceDiscovery";
|
|
6
|
+
|
|
7
|
+
describe("loadDataSourceDiscovery", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns the kernel's secret-free discovery model", async () => {
|
|
13
|
+
const sources = [
|
|
14
|
+
{
|
|
15
|
+
id: "pyiceberg-prod",
|
|
16
|
+
integration: "pyiceberg",
|
|
17
|
+
category: "catalog" as const,
|
|
18
|
+
displayName: "PyIceberg (prod)",
|
|
19
|
+
confidence: "high" as const,
|
|
20
|
+
origins: [
|
|
21
|
+
{
|
|
22
|
+
type: "configuration" as const,
|
|
23
|
+
label: "Resolved PyIceberg configuration",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
configuration: [
|
|
27
|
+
{
|
|
28
|
+
field: "Catalog",
|
|
29
|
+
value: {
|
|
30
|
+
kind: "safe-literal" as const,
|
|
31
|
+
value: "prod",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
code: 'catalog = load_catalog("prod")',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
const request = vi.spyOn(DiscoverDataSources, "request").mockResolvedValue({
|
|
39
|
+
request_id: "request-id",
|
|
40
|
+
sources,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const detected = await loadDataSourceDiscovery();
|
|
44
|
+
|
|
45
|
+
expect({ detected, requests: request.mock.calls }).toMatchInlineSnapshot(`
|
|
46
|
+
{
|
|
47
|
+
"detected": [
|
|
48
|
+
{
|
|
49
|
+
"category": "catalog",
|
|
50
|
+
"code": "catalog = load_catalog("prod")",
|
|
51
|
+
"confidence": "high",
|
|
52
|
+
"configuration": [
|
|
53
|
+
{
|
|
54
|
+
"field": "Catalog",
|
|
55
|
+
"value": {
|
|
56
|
+
"kind": "safe-literal",
|
|
57
|
+
"value": "prod",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
"displayName": "PyIceberg (prod)",
|
|
62
|
+
"id": "pyiceberg-prod",
|
|
63
|
+
"integration": "pyiceberg",
|
|
64
|
+
"origins": [
|
|
65
|
+
{
|
|
66
|
+
"label": "Resolved PyIceberg configuration",
|
|
67
|
+
"type": "configuration",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
"requests": [
|
|
73
|
+
[
|
|
74
|
+
{},
|
|
75
|
+
],
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
`);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { DetectedDataSource } from "@/core/datasets/data-source-discovery";
|
|
4
|
+
import { DiscoverDataSources } from "@/core/datasets/request-registry";
|
|
5
|
+
import { useAsyncData } from "./useAsyncData";
|
|
6
|
+
|
|
7
|
+
export async function loadDataSourceDiscovery(): Promise<DetectedDataSource[]> {
|
|
8
|
+
const result = await DiscoverDataSources.request({});
|
|
9
|
+
return result.sources;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reusable UI-facing hook for kernel-managed datasource discovery.
|
|
14
|
+
* Consumers decide how to render, filter, or act on the detected model.
|
|
15
|
+
*/
|
|
16
|
+
export function useDataSourceDiscovery() {
|
|
17
|
+
return useAsyncData(loadDataSourceDiscovery, []);
|
|
18
|
+
}
|
|
@@ -908,7 +908,10 @@ const DataTableComponent = ({
|
|
|
908
908
|
);
|
|
909
909
|
}, [fieldTypes, columnSummaries]);
|
|
910
910
|
|
|
911
|
-
const
|
|
911
|
+
const rowHeaderNames = new Set(rowHeaders.map(([name]) => name));
|
|
912
|
+
const fieldTypesOrInferred =
|
|
913
|
+
fieldTypes ??
|
|
914
|
+
inferFieldTypes(data).filter(([name]) => !rowHeaderNames.has(name));
|
|
912
915
|
|
|
913
916
|
const memoizedUnclampedFieldTypes =
|
|
914
917
|
useDeepCompareMemoize(fieldTypesOrInferred);
|
|
@@ -30,6 +30,164 @@ beforeAll(() => {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
describe("LoadingDataTableComponent", () => {
|
|
33
|
+
it("keeps data columns when inferred types include a row header", async () => {
|
|
34
|
+
const host = document.createElement("div");
|
|
35
|
+
const data = JSON.stringify([{ index: "first", value: 1 }]);
|
|
36
|
+
|
|
37
|
+
render(
|
|
38
|
+
<Provider store={store}>
|
|
39
|
+
<TooltipProvider>
|
|
40
|
+
<LoadingDataTableComponent
|
|
41
|
+
label={null}
|
|
42
|
+
totalRows={1}
|
|
43
|
+
pagination={false}
|
|
44
|
+
pageSize={10}
|
|
45
|
+
selection={null}
|
|
46
|
+
showDownload={false}
|
|
47
|
+
showFilters={false}
|
|
48
|
+
showColumnSummaries={false}
|
|
49
|
+
showDataTypes={false}
|
|
50
|
+
showPageSizeSelector={false}
|
|
51
|
+
showColumnExplorer={false}
|
|
52
|
+
showRowExplorer={false}
|
|
53
|
+
showChartBuilder={false}
|
|
54
|
+
rowHeaders={[["index", ["string", "object"]]]}
|
|
55
|
+
fieldTypes={undefined}
|
|
56
|
+
totalColumns={1}
|
|
57
|
+
maxColumns={1}
|
|
58
|
+
hasStableRowId={false}
|
|
59
|
+
lazy={false}
|
|
60
|
+
host={host}
|
|
61
|
+
showSearch={false}
|
|
62
|
+
value={[]}
|
|
63
|
+
setValue={vi.fn()}
|
|
64
|
+
data={data}
|
|
65
|
+
search={vi.fn().mockResolvedValue({
|
|
66
|
+
data,
|
|
67
|
+
total_rows: 1,
|
|
68
|
+
cell_styles: null,
|
|
69
|
+
cell_hover_texts: null,
|
|
70
|
+
})}
|
|
71
|
+
download_as={vi.fn() as DownloadAsArgs}
|
|
72
|
+
get_column_summaries={vi.fn()}
|
|
73
|
+
get_data_url={vi.fn() as GetDataUrl}
|
|
74
|
+
get_row_ids={vi.fn() as GetRowIds}
|
|
75
|
+
get_size_bytes={vi.fn().mockResolvedValue({ size_bytes: null })}
|
|
76
|
+
/>
|
|
77
|
+
</TooltipProvider>
|
|
78
|
+
</Provider>,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(
|
|
83
|
+
screen.getByRole("columnheader", { name: "value" }),
|
|
84
|
+
).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not log duplicate keys when a pivot becomes empty", async () => {
|
|
89
|
+
const host = document.createElement("div");
|
|
90
|
+
const consoleError = vi
|
|
91
|
+
.spyOn(console, "error")
|
|
92
|
+
.mockImplementation(() => {});
|
|
93
|
+
const populatedData = JSON.stringify([
|
|
94
|
+
{ DSGROUP: "a", no: 1, yes: 1 },
|
|
95
|
+
{ DSGROUP: "b", no: 0, yes: 1 },
|
|
96
|
+
]);
|
|
97
|
+
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
98
|
+
<Provider store={store}>
|
|
99
|
+
<TooltipProvider>{children}</TooltipProvider>
|
|
100
|
+
</Provider>
|
|
101
|
+
);
|
|
102
|
+
const commonProps = {
|
|
103
|
+
label: null,
|
|
104
|
+
pagination: false,
|
|
105
|
+
pageSize: 10,
|
|
106
|
+
selection: null,
|
|
107
|
+
showDownload: false,
|
|
108
|
+
showFilters: false,
|
|
109
|
+
showColumnSummaries: false as const,
|
|
110
|
+
showDataTypes: true,
|
|
111
|
+
showPageSizeSelector: false,
|
|
112
|
+
showColumnExplorer: false,
|
|
113
|
+
showRowExplorer: false,
|
|
114
|
+
showChartBuilder: false,
|
|
115
|
+
rowHeaders: [
|
|
116
|
+
["DSGROUP", ["string", "object"]],
|
|
117
|
+
] as FieldTypesWithExternalType,
|
|
118
|
+
maxColumns: "all" as const,
|
|
119
|
+
hasStableRowId: false,
|
|
120
|
+
lazy: false,
|
|
121
|
+
host,
|
|
122
|
+
showSearch: false,
|
|
123
|
+
value: [] as (number | string | { rowId: string; columnName?: string })[],
|
|
124
|
+
setValue: vi.fn(),
|
|
125
|
+
download_as: vi.fn() as DownloadAsArgs,
|
|
126
|
+
get_column_summaries: vi.fn(),
|
|
127
|
+
get_data_url: vi.fn() as GetDataUrl,
|
|
128
|
+
get_row_ids: vi.fn() as GetRowIds,
|
|
129
|
+
get_size_bytes: vi.fn().mockResolvedValue({ size_bytes: null }),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const { rerender } = render(
|
|
134
|
+
<Wrapper>
|
|
135
|
+
<LoadingDataTableComponent
|
|
136
|
+
{...commonProps}
|
|
137
|
+
totalRows={2}
|
|
138
|
+
totalColumns={2}
|
|
139
|
+
fieldTypes={[
|
|
140
|
+
["no", ["number", "int64"]],
|
|
141
|
+
["yes", ["number", "int64"]],
|
|
142
|
+
]}
|
|
143
|
+
data={populatedData}
|
|
144
|
+
search={vi.fn().mockResolvedValue({
|
|
145
|
+
data: populatedData,
|
|
146
|
+
total_rows: 2,
|
|
147
|
+
cell_styles: null,
|
|
148
|
+
cell_hover_texts: null,
|
|
149
|
+
})}
|
|
150
|
+
/>
|
|
151
|
+
</Wrapper>,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
expect(screen.getAllByRole("row")).toHaveLength(3);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await act(async () => {
|
|
159
|
+
rerender(
|
|
160
|
+
<Wrapper>
|
|
161
|
+
<LoadingDataTableComponent
|
|
162
|
+
{...commonProps}
|
|
163
|
+
totalRows={0}
|
|
164
|
+
totalColumns={0}
|
|
165
|
+
fieldTypes={undefined}
|
|
166
|
+
data="[]"
|
|
167
|
+
search={vi.fn().mockResolvedValue({
|
|
168
|
+
data: "[]",
|
|
169
|
+
total_rows: 0,
|
|
170
|
+
cell_styles: null,
|
|
171
|
+
cell_hover_texts: null,
|
|
172
|
+
})}
|
|
173
|
+
/>
|
|
174
|
+
</Wrapper>,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await waitFor(() => {
|
|
179
|
+
expect(screen.getByText("No results.")).toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const duplicateKeyErrors = consoleError.mock.calls.filter((args) =>
|
|
183
|
+
args.some((arg) => typeof arg === "string" && arg.includes("same key")),
|
|
184
|
+
);
|
|
185
|
+
expect(duplicateKeyErrors).toHaveLength(0);
|
|
186
|
+
} finally {
|
|
187
|
+
consoleError.mockRestore();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
33
191
|
/**
|
|
34
192
|
* Regression test for https://github.com/marimo-team/marimo/issues/8023
|
|
35
193
|
*
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { FormWrapper } from "../FormPlugin";
|
|
6
|
+
|
|
7
|
+
describe("FormPlugin", () => {
|
|
8
|
+
it("submits the form on Ctrl+Enter and calls setValue", async () => {
|
|
9
|
+
const setValue = vi.fn();
|
|
10
|
+
const validate = vi.fn().mockResolvedValue(null);
|
|
11
|
+
|
|
12
|
+
render(
|
|
13
|
+
<FormWrapper
|
|
14
|
+
currentValue="old-val"
|
|
15
|
+
newValue="new-val"
|
|
16
|
+
setValue={setValue}
|
|
17
|
+
validate={validate}
|
|
18
|
+
shouldValidate={false}
|
|
19
|
+
clearOnSubmit={false}
|
|
20
|
+
submitButtonLabel="Submit"
|
|
21
|
+
bordered={true}
|
|
22
|
+
loading={false}
|
|
23
|
+
submitButtonDisabled={false}
|
|
24
|
+
showClearButton={false}
|
|
25
|
+
clearButtonLabel="Clear"
|
|
26
|
+
label={null}
|
|
27
|
+
>
|
|
28
|
+
<input data-testid="input" />
|
|
29
|
+
</FormWrapper>,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const input = screen.getByTestId("input");
|
|
33
|
+
|
|
34
|
+
// Simulate Ctrl+Enter on input
|
|
35
|
+
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true });
|
|
36
|
+
|
|
37
|
+
await waitFor(() => {
|
|
38
|
+
expect(setValue).toHaveBeenCalledWith("new-val");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("submits the form on Ctrl+Enter and runs validation when shouldValidate is true", async () => {
|
|
43
|
+
const setValue = vi.fn();
|
|
44
|
+
const validate = vi.fn().mockResolvedValue("Validation failed message");
|
|
45
|
+
|
|
46
|
+
render(
|
|
47
|
+
<FormWrapper
|
|
48
|
+
currentValue="old-val"
|
|
49
|
+
newValue="new-val"
|
|
50
|
+
setValue={setValue}
|
|
51
|
+
validate={validate}
|
|
52
|
+
shouldValidate={true}
|
|
53
|
+
clearOnSubmit={false}
|
|
54
|
+
submitButtonLabel="Submit"
|
|
55
|
+
bordered={true}
|
|
56
|
+
loading={false}
|
|
57
|
+
submitButtonDisabled={false}
|
|
58
|
+
showClearButton={false}
|
|
59
|
+
clearButtonLabel="Clear"
|
|
60
|
+
label={null}
|
|
61
|
+
>
|
|
62
|
+
<input data-testid="input" />
|
|
63
|
+
</FormWrapper>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const input = screen.getByTestId("input");
|
|
67
|
+
|
|
68
|
+
// Simulate Ctrl+Enter on input
|
|
69
|
+
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true });
|
|
70
|
+
|
|
71
|
+
await waitFor(() => {
|
|
72
|
+
expect(validate).toHaveBeenCalledWith({ value: "new-val" });
|
|
73
|
+
// should not submit (setValue not called) due to validation failure
|
|
74
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
75
|
+
// error message displayed
|
|
76
|
+
expect(screen.getByText("Validation failed message")).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("clears inputs on submission when clearOnSubmit is true", async () => {
|
|
81
|
+
const setValue = vi.fn();
|
|
82
|
+
const validate = vi.fn().mockResolvedValue(null);
|
|
83
|
+
|
|
84
|
+
let mockUIElement: (HTMLElement & { reset?: () => void }) | null = null;
|
|
85
|
+
const mockReset = vi.fn();
|
|
86
|
+
|
|
87
|
+
render(
|
|
88
|
+
<FormWrapper
|
|
89
|
+
currentValue="old-val"
|
|
90
|
+
newValue="new-val"
|
|
91
|
+
setValue={setValue}
|
|
92
|
+
validate={validate}
|
|
93
|
+
shouldValidate={false}
|
|
94
|
+
clearOnSubmit={true}
|
|
95
|
+
submitButtonLabel="Submit"
|
|
96
|
+
bordered={true}
|
|
97
|
+
loading={false}
|
|
98
|
+
submitButtonDisabled={false}
|
|
99
|
+
showClearButton={false}
|
|
100
|
+
clearButtonLabel="Clear"
|
|
101
|
+
label={null}
|
|
102
|
+
>
|
|
103
|
+
<div
|
|
104
|
+
ref={(el) => {
|
|
105
|
+
if (el && !mockUIElement) {
|
|
106
|
+
mockUIElement = document.createElement("marimo-ui-element");
|
|
107
|
+
mockUIElement.setAttribute("data-testid", "mock-ui-element");
|
|
108
|
+
mockUIElement.reset = mockReset;
|
|
109
|
+
const inputEl = document.createElement("input");
|
|
110
|
+
inputEl.type = "text";
|
|
111
|
+
mockUIElement.appendChild(inputEl);
|
|
112
|
+
el.appendChild(mockUIElement);
|
|
113
|
+
}
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
</FormWrapper>,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const input = screen.getByRole("textbox");
|
|
120
|
+
|
|
121
|
+
// Simulate Ctrl+Enter on input
|
|
122
|
+
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true });
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(setValue).toHaveBeenCalledWith("new-val");
|
|
126
|
+
expect(mockReset).toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("does not submit on Shift+Enter", async () => {
|
|
131
|
+
const setValue = vi.fn();
|
|
132
|
+
const validate = vi.fn().mockResolvedValue(null);
|
|
133
|
+
|
|
134
|
+
render(
|
|
135
|
+
<FormWrapper
|
|
136
|
+
currentValue="old-val"
|
|
137
|
+
newValue="new-val"
|
|
138
|
+
setValue={setValue}
|
|
139
|
+
validate={validate}
|
|
140
|
+
shouldValidate={false}
|
|
141
|
+
clearOnSubmit={false}
|
|
142
|
+
submitButtonLabel="Submit"
|
|
143
|
+
bordered={true}
|
|
144
|
+
loading={false}
|
|
145
|
+
submitButtonDisabled={false}
|
|
146
|
+
showClearButton={false}
|
|
147
|
+
clearButtonLabel="Clear"
|
|
148
|
+
label={null}
|
|
149
|
+
>
|
|
150
|
+
<input data-testid="input" />
|
|
151
|
+
</FormWrapper>,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const input = screen.getByTestId("input");
|
|
155
|
+
|
|
156
|
+
// Simulate Shift+Enter on input
|
|
157
|
+
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
|
|
158
|
+
|
|
159
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
160
|
+
expect(validate).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -263,6 +263,61 @@ describe("Model", () => {
|
|
|
263
263
|
await TestUtils.nextTick(); // flush
|
|
264
264
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
265
265
|
});
|
|
266
|
+
|
|
267
|
+
it("should not mark kernel-pushed state dirty", () => {
|
|
268
|
+
getMarimoInternal(model).updateAndEmitDiffs({ foo: "changed", bar: 456 });
|
|
269
|
+
|
|
270
|
+
model.save_changes();
|
|
271
|
+
expect(mockComm.sendUpdate).not.toHaveBeenCalled();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("should not echo kernel-pushed state alongside a local set", () => {
|
|
275
|
+
// Kernel pushes a large trait; widget ESM then sets one field.
|
|
276
|
+
getMarimoInternal(model).updateAndEmitDiffs({
|
|
277
|
+
foo: "from kernel",
|
|
278
|
+
bar: 123,
|
|
279
|
+
});
|
|
280
|
+
model.set("bar", 456);
|
|
281
|
+
|
|
282
|
+
model.save_changes();
|
|
283
|
+
expect(mockComm.sendUpdate).toHaveBeenCalledExactlyOnceWith({
|
|
284
|
+
bar: 456,
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("should drop a pending local write superseded by a kernel push", () => {
|
|
289
|
+
// Local write, not yet saved...
|
|
290
|
+
model.set("foo", "local");
|
|
291
|
+
// ...then the kernel pushes a newer value for the same key.
|
|
292
|
+
getMarimoInternal(model).updateAndEmitDiffs({
|
|
293
|
+
foo: "from kernel",
|
|
294
|
+
bar: 123,
|
|
295
|
+
});
|
|
296
|
+
expect(model.get("foo")).toBe("from kernel");
|
|
297
|
+
|
|
298
|
+
// Saving must not resend the stale local value.
|
|
299
|
+
model.save_changes();
|
|
300
|
+
expect(mockComm.sendUpdate).not.toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("should drop a pending local write the kernel pushed the same value for", () => {
|
|
304
|
+
const callback = vi.fn();
|
|
305
|
+
model.on("change:foo", callback);
|
|
306
|
+
|
|
307
|
+
// Local write, not yet saved...
|
|
308
|
+
model.set("foo", "agreed");
|
|
309
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
310
|
+
|
|
311
|
+
// ...then the kernel independently pushes the same value.
|
|
312
|
+
getMarimoInternal(model).updateAndEmitDiffs({
|
|
313
|
+
foo: "agreed",
|
|
314
|
+
bar: 123,
|
|
315
|
+
});
|
|
316
|
+
expect(callback).toHaveBeenCalledTimes(1); // no spurious re-emit
|
|
317
|
+
|
|
318
|
+
model.save_changes();
|
|
319
|
+
expect(mockComm.sendUpdate).not.toHaveBeenCalled();
|
|
320
|
+
});
|
|
266
321
|
});
|
|
267
322
|
|
|
268
323
|
describe("emitCustomMessage", () => {
|
|
@@ -53,7 +53,8 @@ export function getMarimoInternal<T extends ModelState>(
|
|
|
53
53
|
|
|
54
54
|
export class Model<T extends ModelState> implements AnyModel<T> {
|
|
55
55
|
#ANY_CHANGE_EVENT = "change";
|
|
56
|
-
|
|
56
|
+
/** Keys last written by the widget that the kernel hasn't seen yet. */
|
|
57
|
+
#dirtyFields: Set<keyof T>;
|
|
57
58
|
#data: T;
|
|
58
59
|
#comm: MarimoComm<T>;
|
|
59
60
|
#listeners: Record<string, Set<EventHandler> | undefined> = {};
|
|
@@ -70,7 +71,7 @@ export class Model<T extends ModelState> implements AnyModel<T> {
|
|
|
70
71
|
constructor(data: T, comm: MarimoComm<T>, signal?: AbortSignal) {
|
|
71
72
|
this.#data = data;
|
|
72
73
|
this.#comm = comm;
|
|
73
|
-
this.#dirtyFields = new
|
|
74
|
+
this.#dirtyFields = new Set();
|
|
74
75
|
if (signal) {
|
|
75
76
|
signal.addEventListener("abort", () => {
|
|
76
77
|
Logger.debug("[Model] Signal aborted, clearing all listeners");
|
|
@@ -140,7 +141,7 @@ export class Model<T extends ModelState> implements AnyModel<T> {
|
|
|
140
141
|
|
|
141
142
|
set<K extends keyof T>(key: K, value: T[K]): void {
|
|
142
143
|
this.#data = { ...this.#data, [key]: value };
|
|
143
|
-
this.#dirtyFields.
|
|
144
|
+
this.#dirtyFields.add(key);
|
|
144
145
|
this.#emit(`change:${key as K & string}`, value);
|
|
145
146
|
this.#emitAnyChange();
|
|
146
147
|
}
|
|
@@ -151,7 +152,7 @@ export class Model<T extends ModelState> implements AnyModel<T> {
|
|
|
151
152
|
}
|
|
152
153
|
// Only send the dirty fields, not the entire state.
|
|
153
154
|
const partialData = Object.fromEntries(
|
|
154
|
-
this.#dirtyFields.
|
|
155
|
+
[...this.#dirtyFields].map((key) => [key, this.#data[key]]),
|
|
155
156
|
) as Partial<T>;
|
|
156
157
|
|
|
157
158
|
// Clear the dirty fields to avoid sending again.
|
|
@@ -204,6 +205,15 @@ export class Model<T extends ModelState> implements AnyModel<T> {
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Apply a kernel-originated state update.
|
|
210
|
+
*
|
|
211
|
+
* Unlike `set()`, this never marks fields dirty — the kernel already
|
|
212
|
+
* has these values, so `save_changes()` must not echo them back.
|
|
213
|
+
*
|
|
214
|
+
* The kernel wins conflicts: a pending unsaved widget write to the same
|
|
215
|
+
* key is dropped, not replayed on top.
|
|
216
|
+
*/
|
|
207
217
|
#updateAndEmitDiffs(value: T) {
|
|
208
218
|
if (value == null) {
|
|
209
219
|
return;
|
|
@@ -211,9 +221,14 @@ export class Model<T extends ModelState> implements AnyModel<T> {
|
|
|
211
221
|
|
|
212
222
|
Object.keys(value).forEach((key) => {
|
|
213
223
|
const k = key as keyof T;
|
|
224
|
+
// Unconditional: guarding on inequality would leave a same-valued
|
|
225
|
+
// pending write dirty, and echo it back on save.
|
|
226
|
+
this.#dirtyFields.delete(k);
|
|
214
227
|
// Shallow equal since these can be large objects
|
|
215
228
|
if (this.#data[k] !== value[k]) {
|
|
216
|
-
this
|
|
229
|
+
this.#data = { ...this.#data, [k]: value[k] };
|
|
230
|
+
this.#emit(`change:${k as keyof T & string}`, value[k]);
|
|
231
|
+
this.#emitAnyChange();
|
|
217
232
|
}
|
|
218
233
|
});
|
|
219
234
|
}
|