Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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.
Comment thread
clarasb marked this conversation as resolved.
Outdated

## 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