Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<Response>();
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);
});
});
});
138 changes: 137 additions & 1 deletion chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {};

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})-->`,
Expand All @@ -29,18 +53,130 @@ export function invokeCallbacks(callbackRequests: CallbackRequest[]) {
);
}
applyStateChangeRequests(changeRequestsResult.data);
releasePendingProgressTargets(pendingProgressTargets, true);
} else {
console.error(
"callback failed:",
changeRequestsResult.error,
"for call requests:",
callbackRequests,
);
releasePendingProgressTargets(pendingProgressTargets, false);
}
},
);
}

function getPendingProgressTargets(
callbackRequests: CallbackRequest[],
): PendingProgressTarget[] {
const { contributionsRecord } = store.getState();
const targets: PendingProgressTarget[] = [];
const targetKeys = new Set<string>();

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<StateChangeRequest>((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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CircularProgress
type="CircularProgress"
id="cp"
visible={false}
onChange={() => {}}
/>,
);

expect(screen.queryByRole("progressbar")).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ export const CircularProgress = ({
size,
value,
variant,
visible = true,
}: CircularProgressProps) => {
if (!visible) {
return null;
}

return (
<MuiCircularProgress
id={id}
Expand Down
13 changes: 13 additions & 0 deletions chartlets.js/packages/lib/src/plugins/mui/LinearProgress.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<LinearProgress
type="LinearProgress"
id="cp"
visible={false}
onChange={() => {}}
/>,
);

expect(screen.queryByRole("progressbar")).toBeNull();
});
});
5 changes: 5 additions & 0 deletions chartlets.js/packages/lib/src/plugins/mui/LinearProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ export const LinearProgress = ({
style,
value,
variant,
visible = true,
}: LinearProgressProps) => {
if (!visible) {
return null;
}

return (
<MuiLinearProgress id={id} style={style} value={value} variant={variant} />
);
Expand Down
1 change: 1 addition & 0 deletions chartlets.js/packages/lib/src/types/state/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface ComponentState {
label?: string;
color?: string;
tooltip?: string;
visible?: boolean;
}

export interface ContainerState extends ComponentState {
Expand Down
3 changes: 3 additions & 0 deletions chartlets.py/chartlets/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class Component(ABC):
color: str | None = None
"""HTML `color` attribute. Optional."""

visible: bool | None = None
"""If set, controls whether the component is visible."""

children: list[Union["Component", str, None]] | None = None
"""Children used by many specific components. Optional."""

Expand Down
2 changes: 2 additions & 0 deletions chartlets.py/demo/my_extension/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -24,3 +25,4 @@
ext.add(my_panel_7)
ext.add(my_panel_8)
ext.add(my_panel_9)
ext.add(my_panel_10)
Loading
Loading