Skip to content
Open
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
5 changes: 5 additions & 0 deletions chartlets.js/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
- `vite: ^8.0.16`
- `vitest: ^4.1.8`

* Added `visible` support for progress components. Progress indicators can
now be hidden via `visible={false}` and are automatically shown while a
server-side callback with an output such as `Output("progress", "visible")`
is pending.

## Version 0.2.0 (from 2026/03/11)

* Updated dependencies
Expand Down
184 changes: 92 additions & 92 deletions chartlets.js/package-lock.json

Large diffs are not rendered by default.

143 changes: 143 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,143 @@
/*
* 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 { CallbackRequest, StateChangeRequest } from "@/types/model/callback";
import type { ComponentState } from "@/types/state/component";
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);
}

const callbackRequest: CallbackRequest = {
contribPoint: "panels",
contribIndex: 0,
callbackIndex: 0,
inputIndex: 0,
inputValues: [true],
};

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 pending progress and applies callback results", async () => {
const deferred = createDeferred<Response>();
globalThis.fetch = vi.fn().mockReturnValue(deferred.promise);

invokeCallbacks([callbackRequest]);

expect(getProgressComponent().visible).toBe(true);

deferred.resolve(createCallbackResponse([
{
contribPoint: "panels",
contribIndex: 0,
stateChanges: [{ id: "progress", property: "visible", value: false }],
},
]));

await vi.waitFor(() => {
expect(getProgressComponent().visible).toBe(false);
});
});

it("logs and releases pending progress when a callback fails", async () => {
const deferred = createDeferred<Response>();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
globalThis.fetch = vi.fn().mockReturnValue(deferred.promise);

invokeCallbacks([callbackRequest]);

expect(getProgressComponent().visible).toBe(true);

deferred.resolve({
ok: true,
status: 200,
statusText: "ok",
json: vi.fn().mockResolvedValue({ message: "unexpected" }),
} as unknown as Response);

await vi.waitFor(() => {
expect(getProgressComponent().visible).toBe(false);
});
expect(consoleError).toHaveBeenCalledOnce();
});

it("logs callback requests and results when logging is enabled", async () => {
const deferred = createDeferred<Response>();
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
globalThis.fetch = vi.fn().mockReturnValue(deferred.promise);
store.setState({ configuration: { logging: { enabled: true } } });

invokeCallbacks([callbackRequest]);

deferred.resolve(createCallbackResponse([]));

await vi.waitFor(() => {
expect(consoleInfo).toHaveBeenCalledTimes(2);
});
});
});

function createCallbackResponse(result: StateChangeRequest[]) {
return {
ok: true,
status: 200,
statusText: "ok",
json: vi.fn().mockResolvedValue({ result }),
} as unknown as Response;
}
11 changes: 10 additions & 1 deletion chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@ import { store } from "@/store";
import type { CallbackRequest } from "@/types/model/callback";
import { fetchCallback } from "@/api/fetchCallback";
import { applyStateChangeRequests } from "@/actions/helpers/applyStateChangeRequests";
import {
getPendingProgressTargets,
releasePendingProgressTargets,
showPendingProgressTargets,
} from "@/actions/helpers/pendingProgress";

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,13 +36,15 @@ 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);
}
},
);
Expand All @@ -45,4 +54,4 @@ let invocationCounter = 0;

function getInvocationId() {
return invocationCounter++;
}
}
161 changes: 161 additions & 0 deletions chartlets.js/packages/lib/src/actions/helpers/pendingProgress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* 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 { beforeEach, describe, expect, it } from "vitest";

import { store } from "@/store";
import type { CallbackRequest } from "@/types/model/callback";
import type { ComponentState } from "@/types/state/component";
import {
getPendingProgressTargets,
releasePendingProgressTargets,
showPendingProgressTargets,
} from "./pendingProgress";

const callbackRequest: CallbackRequest = {
contribPoint: "panels",
contribIndex: 0,
callbackIndex: 0,
inputIndex: 0,
inputValues: [true],
};

function getProgressComponent() {
return (store.getState().contributionsRecord.panels[0].component!
.children![0] as ComponentState);
}

describe("pendingProgress", () => {
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,
},
{
type: "Typography",
id: "text",
visible: false,
},
],
},
callbacks: [
{
function: { name: "calculate", parameters: [], return: {} },
inputs: [{ id: "run", property: "clicked" }],
outputs: [{ id: "progress", property: "visible" }],
},
{
function: { name: "duplicate", parameters: [], return: {} },
inputs: [{ id: "run", property: "clicked" }],
outputs: [
{ id: "progress", property: "visible" },
{ id: "progress", property: "visible" },
],
},
{
function: { name: "text", parameters: [], return: {} },
inputs: [{ id: "run", property: "clicked" }],
outputs: [{ id: "text", property: "visible" }],
},
{
function: { name: "value", parameters: [], return: {} },
inputs: [{ id: "run", property: "clicked" }],
outputs: [{ id: "progress", property: "value" }],
},
],
initialState: {},
},
],
},
lastCallbackInputValues: {},
});
});

it("finds progress components targeted by visible callback outputs", () => {
expect(getPendingProgressTargets([callbackRequest])).toEqual([
{
contribPoint: "panels",
contribIndex: 0,
id: "progress",
output: { id: "progress", property: "visible" },
},
]);
});

it("deduplicates repeated progress outputs from the same callback", () => {
const targets = getPendingProgressTargets([
{ ...callbackRequest, callbackIndex: 1 },
]);

expect(targets).toHaveLength(1);
});

it("ignores non-progress components and non-visible outputs", () => {
expect(
getPendingProgressTargets([{ ...callbackRequest, callbackIndex: 2 }]),
).toEqual([]);
expect(
getPendingProgressTargets([{ ...callbackRequest, callbackIndex: 3 }]),
).toEqual([]);
});

it("ignores progress outputs when no component tree has been loaded", () => {
store.setState({
contributionsRecord: {
panels: [
{
...store.getState().contributionsRecord.panels[0],
component: undefined,
},
],
},
});

expect(getPendingProgressTargets([callbackRequest])).toEqual([]);
});

it("shows and hides progress when a callback fails", () => {
const targets = getPendingProgressTargets([callbackRequest]);

showPendingProgressTargets(targets);

expect(getProgressComponent().visible).toBe(true);

releasePendingProgressTargets(targets, false);

expect(getProgressComponent().visible).toBe(false);
});

it("keeps progress visible until overlapping callbacks have completed", () => {
const targets = getPendingProgressTargets([callbackRequest]);

showPendingProgressTargets(targets);
showPendingProgressTargets(targets);

releasePendingProgressTargets(targets, true);

expect(getProgressComponent().visible).toBe(true);

releasePendingProgressTargets(targets, false);

expect(getProgressComponent().visible).toBe(false);
});
});
Loading
Loading