From 042913903423fa1b674c8f540cc9cabaa3fe7453 Mon Sep 17 00:00:00 2001 From: clarasb Date: Wed, 24 Jun 2026 14:37:37 +0200 Subject: [PATCH 1/5] Add demo pannel 10 --- chartlets.py/demo/my_extension/__init__.py | 2 + chartlets.py/demo/my_extension/my_panel_10.py | 73 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 chartlets.py/demo/my_extension/my_panel_10.py diff --git a/chartlets.py/demo/my_extension/__init__.py b/chartlets.py/demo/my_extension/__init__.py index bd22edc..11b9d3c 100644 --- a/chartlets.py/demo/my_extension/__init__.py +++ b/chartlets.py/demo/my_extension/__init__.py @@ -12,6 +12,7 @@ from .my_panel_7 import panel as my_panel_7 from .my_panel_8 import panel as my_panel_8 from .my_panel_9 import panel as my_panel_9 +from .my_panel_10 import panel as my_panel_10 ext = Extension(__name__) @@ -24,3 +25,4 @@ ext.add(my_panel_7) ext.add(my_panel_8) ext.add(my_panel_9) +ext.add(my_panel_10) diff --git a/chartlets.py/demo/my_extension/my_panel_10.py b/chartlets.py/demo/my_extension/my_panel_10.py new file mode 100644 index 0000000..a322f4d --- /dev/null +++ b/chartlets.py/demo/my_extension/my_panel_10.py @@ -0,0 +1,73 @@ +# Copyright (c) 2019-2026 by Brockmann Consult Development team +# Permissions are hereby granted under the terms of the MIT License: +# https://opensource.org/licenses/MIT. + +import time + +from chartlets import Component, Input, Output, State +from chartlets.components import ( + Box, + Button, + CircularProgress, + CircularProgressWithLabel, + LinearProgress, + Typography, +) + +from server.context import Context +from server.panel import Panel + +panel = Panel(__name__, title="Panel J") + + +# noinspection PyUnusedLocal +@panel.layout() +def render_panel(ctx: Context) -> Component: + button = Button( + id="start_button", + text="wait for 3 seconds", + color="primary", + variant="contained", + style={"width": "fit-content"}, + ) + progress = CircularProgress( + id="loading_progress", + visible=False, + size=32, + style={"margin": "16px 0"}, + ) + result_text = Typography( + id="result_text", + text="", + variant="body1", + ) + + return Box( + style={ + "display": "flex", + "flexDirection": "column", + "alignItems": "flex-start", + "gap": "8px", + "padding": "16px", + }, + children=[button, progress, result_text], + ) + + +# noinspection PyUnusedLocal +@panel.callback( + Input("start_button", "clicked"), + State("start_button", "text"), + Output("loading_progress", "visible"), + Output("result_text", "text"), + Output("start_button", "text"), + Output("start_button", "color"), +) +def run_calculation( + ctx: Context, clicked: bool, button_text: str +) -> tuple[bool, str, str, str]: + if button_text == "reset": + return False, "", "wait for 3 seconds", "primary" + + time.sleep(3) + return False, "Finished waiting after 3 seconds.", "reset", "inherit" From 5b4321b755a28345bb4d84534c0d4537b9b631bd Mon Sep 17 00:00:00 2001 From: clarasb Date: Wed, 24 Jun 2026 14:41:23 +0200 Subject: [PATCH 2/5] add `visible` property --- .../lib/src/plugins/mui/CircularProgress.test.tsx | 13 +++++++++++++ .../lib/src/plugins/mui/CircularProgress.tsx | 5 +++++ .../packages/lib/src/types/state/component.ts | 1 + chartlets.py/chartlets/component.py | 3 +++ chartlets.py/tests/components/progress_test.py | 9 +++++++-- 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/chartlets.js/packages/lib/src/plugins/mui/CircularProgress.test.tsx b/chartlets.js/packages/lib/src/plugins/mui/CircularProgress.test.tsx index 0f8755d..f9bc62e 100644 --- a/chartlets.js/packages/lib/src/plugins/mui/CircularProgress.test.tsx +++ b/chartlets.js/packages/lib/src/plugins/mui/CircularProgress.test.tsx @@ -22,4 +22,17 @@ describe("CircularProgress", () => { // expect(document.querySelector("#cp")).toEqual({}); expect(screen.getByRole("progressbar")).not.toBeUndefined(); }); + + it("should not render when visible is false", () => { + render( + {}} + />, + ); + + expect(screen.queryByRole("progressbar")).toBeNull(); + }); }); diff --git a/chartlets.js/packages/lib/src/plugins/mui/CircularProgress.tsx b/chartlets.js/packages/lib/src/plugins/mui/CircularProgress.tsx index d7ea1b4..4ea2473 100644 --- a/chartlets.js/packages/lib/src/plugins/mui/CircularProgress.tsx +++ b/chartlets.js/packages/lib/src/plugins/mui/CircularProgress.tsx @@ -22,7 +22,12 @@ export const CircularProgress = ({ size, value, variant, + visible = true, }: CircularProgressProps) => { + if (!visible) { + return null; + } + return ( Date: Wed, 24 Jun 2026 14:52:37 +0200 Subject: [PATCH 3/5] extend invokeCallback with pendingProcesses --- .../actions/helpers/invokeCallbacks.test.ts | 103 +++++++++++++ .../src/actions/helpers/invokeCallbacks.ts | 138 +++++++++++++++++- 2 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.test.ts diff --git a/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.test.ts b/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.test.ts new file mode 100644 index 0000000..32051d3 --- /dev/null +++ b/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2019-2026 by Brockmann Consult Development team + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { store } from "@/store"; +import type { ComponentState } from "@/types/state/component"; +import type { StateChangeRequest } from "@/types/model/callback"; +import { invokeCallbacks } from "./invokeCallbacks"; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function getProgressComponent() { + return (store.getState().contributionsRecord.panels[0].component! + .children![0] as ComponentState); +} + +describe("invokeCallbacks", () => { + beforeEach(() => { + store.setState({ + configuration: {}, + extensions: [{ name: "ext", version: "0", contributes: ["panels"] }], + contributionsResult: {}, + contributionsRecord: { + panels: [ + { + name: "panel", + extension: "ext", + container: {}, + componentResult: { status: "ok" }, + component: { + type: "Box", + children: [ + { + type: "CircularProgress", + id: "progress", + visible: false, + }, + ], + }, + callbacks: [ + { + function: { name: "calculate", parameters: [], return: {} }, + inputs: [{ id: "run", property: "clicked" }], + outputs: [{ id: "progress", property: "visible" }], + }, + ], + initialState: {}, + }, + ], + }, + lastCallbackInputValues: {}, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows progress while a callback with a progress visibility output is pending", async () => { + const deferred = createDeferred(); + globalThis.fetch = vi.fn().mockReturnValue(deferred.promise); + + invokeCallbacks([ + { + contribPoint: "panels", + contribIndex: 0, + callbackIndex: 0, + inputIndex: 0, + inputValues: [true], + }, + ]); + + expect(getProgressComponent().visible).toBe(true); + + const callbackResult: StateChangeRequest[] = [ + { + contribPoint: "panels", + contribIndex: 0, + stateChanges: [{ id: "progress", property: "visible", value: false }], + }, + ]; + deferred.resolve({ + ok: true, + status: 200, + statusText: "ok", + json: vi.fn().mockResolvedValue({ result: callbackResult }), + } as unknown as Response); + + await vi.waitFor(() => { + expect(getProgressComponent().visible).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts b/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts index 9bc0eb0..ef101f4 100644 --- a/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts +++ b/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts @@ -5,14 +5,38 @@ */ import { store } from "@/store"; -import type { CallbackRequest } from "@/types/model/callback"; +import type { + CallbackRequest, + StateChangeRequest, +} from "@/types/model/callback"; +import type { Output } from "@/types/model/channel"; +import type { ComponentState } from "@/types/state/component"; import { fetchCallback } from "@/api/fetchCallback"; import { applyStateChangeRequests } from "@/actions/helpers/applyStateChangeRequests"; +import { formatObjPath } from "@/utils/objPath"; + +interface PendingProgressTarget { + contribPoint: string; + contribIndex: number; + id: string; + output: Output; +} + +const progressComponentTypes = new Set([ + "CircularProgress", + "CircularProgressWithLabel", + "LinearProgress", + "LinearProgressWithLabel", +]); + +const pendingProgressCounts: Record = {}; export function invokeCallbacks(callbackRequests: CallbackRequest[]) { const { configuration } = store.getState(); const shouldLog = configuration.logging?.enabled; const invocationId = getInvocationId(); + const pendingProgressTargets = getPendingProgressTargets(callbackRequests); + showPendingProgressTargets(pendingProgressTargets); if (shouldLog) { console.info( `chartlets: invokeCallbacks (${invocationId})-->`, @@ -29,6 +53,7 @@ export function invokeCallbacks(callbackRequests: CallbackRequest[]) { ); } applyStateChangeRequests(changeRequestsResult.data); + releasePendingProgressTargets(pendingProgressTargets, true); } else { console.error( "callback failed:", @@ -36,11 +61,122 @@ export function invokeCallbacks(callbackRequests: CallbackRequest[]) { "for call requests:", callbackRequests, ); + releasePendingProgressTargets(pendingProgressTargets, false); } }, ); } +function getPendingProgressTargets( + callbackRequests: CallbackRequest[], +): PendingProgressTarget[] { + const { contributionsRecord } = store.getState(); + const targets: PendingProgressTarget[] = []; + const targetKeys = new Set(); + + callbackRequests.forEach(({ contribPoint, contribIndex, callbackIndex }) => { + const contribution = contributionsRecord[contribPoint]?.[contribIndex]; + const callback = contribution?.callbacks?.[callbackIndex]; + callback?.outputs?.forEach((output) => { + if ( + formatObjPath(output.property) === "visible" && + isProgressComponent(contribution.component, output.id) + ) { + const target = { contribPoint, contribIndex, id: output.id, output }; + const key = getPendingProgressTargetKey(target); + if (!targetKeys.has(key)) { + targetKeys.add(key); + targets.push(target); + } + } + }); + }); + + return targets; +} + +function showPendingProgressTargets(targets: PendingProgressTarget[]) { + incrementPendingProgressCounts(targets); + applyPendingProgressTargets(targets, true); +} + +function releasePendingProgressTargets( + targets: PendingProgressTarget[], + callbackSucceeded: boolean, +) { + decrementPendingProgressCounts(targets); + const stillPendingTargets = targets.filter( + (target) => pendingProgressCounts[getPendingProgressTargetKey(target)] > 0, + ); + applyPendingProgressTargets(stillPendingTargets, true); + + if (!callbackSucceeded) { + const completedTargets = targets.filter( + (target) => !pendingProgressCounts[getPendingProgressTargetKey(target)], + ); + applyPendingProgressTargets(completedTargets, false); + } +} + +function incrementPendingProgressCounts(targets: PendingProgressTarget[]) { + targets.forEach((target) => { + const key = getPendingProgressTargetKey(target); + pendingProgressCounts[key] = (pendingProgressCounts[key] || 0) + 1; + }); +} + +function decrementPendingProgressCounts(targets: PendingProgressTarget[]) { + targets.forEach((target) => { + const key = getPendingProgressTargetKey(target); + const count = (pendingProgressCounts[key] || 0) - 1; + if (count > 0) { + pendingProgressCounts[key] = count; + } else { + delete pendingProgressCounts[key]; + } + }); +} + +function applyPendingProgressTargets( + targets: PendingProgressTarget[], + visible: boolean, +) { + if (targets.length === 0) { + return; + } + applyStateChangeRequests( + targets.map((target) => ({ + contribPoint: target.contribPoint, + contribIndex: target.contribIndex, + stateChanges: [{ ...target.output, value: visible }], + })), + ); +} + +function getPendingProgressTargetKey(target: PendingProgressTarget) { + return `${target.contribPoint}-${target.contribIndex}-${target.id}`; +} + +function isProgressComponent( + component: ComponentState | undefined, + id: string, +): boolean { + if (!component) { + return false; + } + if (component.id === id) { + return progressComponentTypes.has(component.type); + } + return Boolean( + component.children?.some( + (child) => + typeof child === "object" && + child !== null && + isProgressComponent(child, id), + ), + ); +} + let invocationCounter = 0; function getInvocationId() { From 5a335923839b6a1c1dcc36955d6b76d2b5315e4e Mon Sep 17 00:00:00 2001 From: clarasb Date: Wed, 24 Jun 2026 15:13:04 +0200 Subject: [PATCH 4/5] add `visible` property to `LinearProgress` component --- .../src/plugins/mui/LinearProgress.test.tsx | 13 +++++++++ .../lib/src/plugins/mui/LinearProgress.tsx | 5 ++++ .../tests/components/progress_test.py | 27 ++++++++++++++----- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/chartlets.js/packages/lib/src/plugins/mui/LinearProgress.test.tsx b/chartlets.js/packages/lib/src/plugins/mui/LinearProgress.test.tsx index f6676f8..87dc87b 100644 --- a/chartlets.js/packages/lib/src/plugins/mui/LinearProgress.test.tsx +++ b/chartlets.js/packages/lib/src/plugins/mui/LinearProgress.test.tsx @@ -22,4 +22,17 @@ describe("LinearProgress", () => { // expect(document.querySelector("#cp")).toEqual({}); expect(screen.getByRole("progressbar")).not.toBeUndefined(); }); + + it("should not render when visible is false", () => { + render( + {}} + />, + ); + + expect(screen.queryByRole("progressbar")).toBeNull(); + }); }); diff --git a/chartlets.js/packages/lib/src/plugins/mui/LinearProgress.tsx b/chartlets.js/packages/lib/src/plugins/mui/LinearProgress.tsx index b56176a..112d0a2 100644 --- a/chartlets.js/packages/lib/src/plugins/mui/LinearProgress.tsx +++ b/chartlets.js/packages/lib/src/plugins/mui/LinearProgress.tsx @@ -21,7 +21,12 @@ export const LinearProgress = ({ style, value, variant, + visible = true, }: LinearProgressProps) => { + if (!visible) { + return null; + } + return ( ); diff --git a/chartlets.py/tests/components/progress_test.py b/chartlets.py/tests/components/progress_test.py index 6c6302a..343d6b7 100644 --- a/chartlets.py/tests/components/progress_test.py +++ b/chartlets.py/tests/components/progress_test.py @@ -27,8 +27,13 @@ class CircularProgressWithLabelTest(make_base(CircularProgressWithLabel)): def test_is_json_serializable(self): self.assert_is_json_serializable( - self.cls(color="primary", value=12), - {"type": "CircularProgressWithLabel", "color": "primary", "value": 12}, + self.cls(color="primary", value=12, visible=False), + { + "type": "CircularProgressWithLabel", + "color": "primary", + "value": 12, + "visible": False, + }, ) @@ -36,8 +41,13 @@ class LinearProgressTest(make_base(LinearProgress)): def test_is_json_serializable(self): self.assert_is_json_serializable( - self.cls(color="success", value=40), - {"type": "LinearProgress", "color": "success", "value": 40}, + self.cls(color="success", value=40, visible=False), + { + "type": "LinearProgress", + "color": "success", + "value": 40, + "visible": False, + }, ) @@ -45,6 +55,11 @@ class LinearProgressWithLabelTest(make_base(LinearProgressWithLabel)): def test_is_json_serializable(self): self.assert_is_json_serializable( - self.cls(color="secondary", value=42), - {"type": "LinearProgressWithLabel", "color": "secondary", "value": 42}, + self.cls(color="secondary", value=42, visible=False), + { + "type": "LinearProgressWithLabel", + "color": "secondary", + "value": 42, + "visible": False, + }, ) From 13ca6d68afa0d482d11f1c112a2aab4e1dcba223 Mon Sep 17 00:00:00 2001 From: clarasb Date: Wed, 24 Jun 2026 15:37:22 +0200 Subject: [PATCH 5/5] fix vega_test.py --- .../tests/components/charts/vega_test.py | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/chartlets.py/tests/components/charts/vega_test.py b/chartlets.py/tests/components/charts/vega_test.py index 62dc9bd..1ac59dc 100644 --- a/chartlets.py/tests/components/charts/vega_test.py +++ b/chartlets.py/tests/components/charts/vega_test.py @@ -35,27 +35,7 @@ def test_with_chart_prop(self): "type": "VegaChart", "id": "plot", "theme": "dark", - "chart": { - "$schema": "https://vega.github.io/schema/vega-lite/v6.1.0.json", - "config": { - "view": {"continuousHeight": 300, "continuousWidth": 300} - }, - "data": {"name": "data-2780b27b376c14369bf3f449cf25f092"}, - "datasets": { - "data-2780b27b376c14369bf3f449cf25f092": [ - {"a": 28, "x": "A"}, - {"a": 55, "x": "B"}, - {"a": 43, "x": "C"}, - {"a": 91, "x": "D"}, - {"a": 81, "x": "E"}, - ] - }, - "encoding": { - "x": {"field": "x", "title": "x", "type": "nominal"}, - "y": {"field": "a", "title": "a", "type": "quantitative"}, - }, - "mark": {"type": "bar"}, - }, + "chart": self.chart.to_dict(), }, )