Skip to content
Merged
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
35 changes: 20 additions & 15 deletions frontend/__tests__/components/common/AsyncContent.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ describe("AsyncContent", () => {
query: {
result: string | Error;
},
options?: Omit<Props<unknown>, "query" | "queries" | "children">,
options?: Omit<Props<{ result: string }>, "queries" | "children">,
): {
container: HTMLElement;
} {
Expand All @@ -160,12 +160,18 @@ describe("AsyncContent", () => {
}));

return (
<AsyncContent query={myQuery} {...(options as Props<string>)}>
{(data: string | undefined) => (
<AsyncContent
queries={{ result: myQuery }}
{...(options as Props<{ result: string | undefined }>)}
>
{({ resultData }) => (
<>
static content
<Show when={data !== undefined} fallback={<div>no data</div>}>
<div data-testid="content">{data}</div>
<Show
when={resultData() !== undefined}
fallback={<div>no data</div>}
>
<div data-testid="content">{resultData()}</div>
</Show>
</>
)}
Expand Down Expand Up @@ -318,7 +324,10 @@ describe("AsyncContent", () => {
first: string | Error | undefined;
second: string | Error | undefined;
},
options?: Omit<Props<unknown>, "query" | "queries" | "children">,
options?: Omit<
Props<{ first: string; second: string }>,
"queries" | "children"
>,
): {
container: HTMLElement;
} {
Expand Down Expand Up @@ -347,24 +356,20 @@ describe("AsyncContent", () => {
}));

type Q = { first: string | undefined; second: string | undefined };

return (
<AsyncContent
queries={{ first: firstQuery, second: secondQuery }}
{...(options as Props<Q>)}
>
{(results: {
first: string | undefined;
second: string | undefined;
}) => (
{({ firstData, secondData }) => (
<>
<Show
when={
results.first !== undefined && results.second !== undefined
}
when={firstData() !== undefined && secondData() !== undefined}
fallback={<div>no data</div>}
>
<div data-testid="first">{results.first}</div>
<div data-testid="second">{results.second}</div>
<div data-testid="first">{firstData()}</div>
<div data-testid="second">{secondData()}</div>
</Show>
</>
)}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/html/pages/test.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div class="page pageTest full-width content-grid hidden" data-nosnippet>
<mount data-component="testconfig"></mount>
<mount data-component="testconfig" class="full-width"></mount>

<div id="testInitFailed" class="content-grid hidden">
<div class="message">
Expand Down
148 changes: 95 additions & 53 deletions frontend/src/ts/components/common/AsyncContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { UseQueryResult } from "@tanstack/solid-query";
import {
Accessor,
createEffect,
createMemo,
ErrorBoundary,
JSXElement,
Expand All @@ -26,8 +27,7 @@ type Collection<T> = Accessor<T> & {
isError: boolean;
};

type QueryMapping = Record<string, unknown> | unknown;
type AsyncMap<T extends QueryMapping> = {
type AsyncMap<T extends Record<string, unknown>> = {
[K in keyof T]: AsyncEntry<T[K]>;
};

Expand All @@ -38,69 +38,59 @@ type BaseProps = {
errorClass?: string;
};

type QueryProps<T extends QueryMapping> = {
type QueryProps<T extends Record<string, unknown>> = {
queries: { [K in keyof T]: UseQueryResult<T[K]> };
};

type SingleQueryProps<T> = {
query: UseQueryResult<T>;
};

type CollectionProps<T extends QueryMapping> = {
type CollectionProps<T extends Record<string, unknown>> = {
collections: { [K in keyof T]: Collection<T[K]> };
};

type SingleCollectionProps<T> = {
collection: Collection<T>;
};
type AccessorMap<T> = { [K in keyof T]: Accessor<T[K]> };
type DataKeys<T> = { [K in keyof T as `${K & string}Data`]: T[K] };

type DeferredChildren<T extends QueryMapping> = {
type Source<T extends Record<string, unknown>> =
| QueryProps<T>
| CollectionProps<T>;

type DeferredChildren<T extends Record<string, unknown>> = {
alwaysShowContent?: false;
children: (data: { [K in keyof T]: T[K] }) => JSXElement;
children: (
data: AccessorMap<DataKeys<{ [K in keyof T]: T[K] }>>,
) => JSXElement;
};

type EagerChildren<T extends QueryMapping> = {
type EagerChildren<T extends Record<string, unknown>> = {
alwaysShowContent: true;
showLoader?: true;
children: (data: { [K in keyof T]: T[K] | undefined }) => JSXElement;
children: (
data: AccessorMap<DataKeys<{ [K in keyof T]: T[K] | undefined }>>,
) => JSXElement;
};

export type Props<T extends QueryMapping> = BaseProps &
(
| QueryProps<T>
| SingleQueryProps<T>
| CollectionProps<T>
| SingleCollectionProps<T>
) &
(DeferredChildren<T> | EagerChildren<T>);
type Children<T extends Record<string, unknown>> =
| DeferredChildren<T>
| EagerChildren<T>;

export type Props<T extends Record<string, unknown>> = BaseProps &
Source<T> &
Children<T>;

export default function AsyncContent<T extends QueryMapping>(
function AsyncContent<T extends Record<string, unknown>>(
props: Props<T>,
): JSXElement {
//@ts-expect-error this is fine
const source = createMemo<AsyncMap<T>>(() => {
if ("query" in props) {
return fromQueries({ defaultQuery: props.query });
} else if ("queries" in props) {
if ("queries" in props) {
return fromQueries(props.queries);
} else if ("collection" in props) {
return fromCollections({ defaultQuery: props.collection });
} else if ("collections" in props) {
} else {
return fromCollections(props.collections);
}
});

const value = (): T => {
if ("defaultQuery" in source()) {
//@ts-expect-error we know the property is present
// oxlint-disable-next-line typescript/no-unsafe-call typescript/no-unsafe-member-access
return source().defaultQuery.value() as T;
} else {
return Object.fromEntries(
typedKeys(source()).map((key) => [key, source()[key].value()]),
) as T; // For multiple queries
}
};
const value = (): T =>
Object.fromEntries(
typedKeys(source()).map((key) => [key, source()[key].value()]),
) as T;

const handleError = (err: unknown): string => {
const message = createErrorMessage(
Expand All @@ -119,12 +109,9 @@ export default function AsyncContent<T extends QueryMapping>(
const allResolved = (
data: ReturnType<typeof value>,
): data is { [K in keyof T]: T[K] } => {
//single query
if (data === undefined || data === null) {
return false;
}
if ("defaultQuery" in source()) return true;

return Object.values(data).every((v) => v !== undefined && v !== null);
};

Expand All @@ -136,6 +123,52 @@ export default function AsyncContent<T extends QueryMapping>(
.find((s) => s.isError())
?.error?.();

// Keep the last resolved value so deferred children stay mounted during
// transient loading states (e.g. navigating away and back).
const lastResolvedValue = createMemo<T | undefined>((prev) => {
const current = value();
return allResolved(current) ? current : prev;
});

const hasResolved = createMemo<boolean>(
(prev) => prev || lastResolvedValue() !== undefined,
false,
);

// Keys are stable for the component lifetime; per-key closures track
// reactivity internally via value()/lastResolvedValue().
// oxlint-disable-next-line solid/reactivity -- intentional snapshot of initial keys
const keys = typedKeys(source());
if (import.meta.env.DEV) {
createEffect(() => {
const currentKeys = typedKeys(source());
if (
currentKeys.length !== keys.length ||
currentKeys.some((k, i) => k !== keys[i])
) {
console.warn(
"AsyncContent: query keys changed between renders. This is not supported.",
);
}
});
}

// oxlint-disable solid/reactivity
const eagerAccessorMap = Object.fromEntries(
typedKeys(source()).map((key) => [
`${String(key)}Data`,
() => value()?.[key],
]),
) as unknown as AccessorMap<DataKeys<{ [K in keyof T]: T[K] | undefined }>>;

const deferredAccessorMap = Object.fromEntries(
typedKeys(source()).map((key) => [
`${String(key)}Data`,
() => lastResolvedValue()?.[key],
]),
) as unknown as AccessorMap<DataKeys<{ [K in keyof T]: T[K] }>>;
// oxlint-enable solid/reactivity

const loader = (): JSXElement =>
props.loader ?? <LoadingCircle class="p-4 text-center text-2xl" />;

Expand All @@ -144,24 +177,31 @@ export default function AsyncContent<T extends QueryMapping>(
<div class={props.errorClass}>{handleError(err)}</div>
);

// Show loader on initial load or when the query key changed (no cached data)
const showLoader = (): boolean =>
isLoading() && !props.alwaysShowContent && !allResolved(value());

return (
<ErrorBoundary fallback={props.ignoreError ? undefined : errorText}>
<Switch
fallback={
<>
<Show when={isLoading() && !props.alwaysShowContent}>
{loader()}
</Show>

<Show when={showLoader()}>{loader()}</Show>
<Show
when={props.alwaysShowContent === true}
fallback={
<Show when={allResolved(value())}>
{props.children(value())}
<Show when={hasResolved()}>
{(_) =>
// oxlint-disable-next-line typescript/no-explicit-any
(props.children as (data: any) => JSXElement)(
deferredAccessorMap,
)
}
</Show>
}
>
{props.children(value())}
{/* oxlint-disable-next-line typescript/no-explicit-any */}
{(props.children as (data: any) => JSXElement)(eagerAccessorMap)}
</Show>
</>
}
Expand All @@ -170,7 +210,7 @@ export default function AsyncContent<T extends QueryMapping>(
{errorText(firstError())}
</Match>

<Match when={isLoading() && !props.alwaysShowContent}>{loader()}</Match>
<Match when={showLoader()}>{loader()}</Match>
</Switch>
</ErrorBoundary>
);
Expand Down Expand Up @@ -204,3 +244,5 @@ function fromCollections<T extends Record<string, unknown>>(collections: {
return acc;
}, {} as AsyncMap<T>);
}

export default AsyncContent;
34 changes: 28 additions & 6 deletions frontend/src/ts/components/common/ChartJs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,23 @@ import {
ScaleChartOptions,
} from "chart.js";
import chartTrendline from "chartjs-plugin-trendline";
import { createEffect, JSXElement, onCleanup, onMount } from "solid-js";
import { createDeferred, JSXElement, onCleanup, onMount } from "solid-js";

import { Theme } from "../../constants/themes";
import { createEffectOn } from "../../hooks/effects";
import { useRefWithUtils } from "../../hooks/useRefWithUtils";
import { getTheme } from "../../states/theme";

function getThemeHash(): string {
return Object.values(getTheme()).join("");
}

Chart.register(chartTrendline);
type ChartJSProps<
T extends ChartType = ChartType,
TData = DefaultDataPoint<T>,
> = {
name: string;
type: T;
data: ChartData<T, TData>;
options?: ChartOptions<T>;
Expand All @@ -32,28 +38,44 @@ export function ChartJs<T extends ChartType, TData = DefaultDataPoint<T>>(
const [canvasRef, canvasEl] = useRefWithUtils<HTMLCanvasElement>();

let chart: Chart<T, TData> | undefined;
let theme = "";

onMount(() => {
const canvas = canvasEl();
if (canvas === undefined) return;
if (chart !== undefined) return;

chart = new Chart(canvas.native, {
type: props.type,
data: props.data,
options: addColorsToOptions(props.options as ChartOptions<T>, getTheme),
});

theme = getThemeHash();
props.onChartInit?.(chart);
});

createEffect(() => {
const updateChart = (data: ChartData<T, TData>): void => {
if (!chart) return;

chart.config.type = props.type;
chart.data = props.data;
chart.data = data;

if (props.options) {
chart.options = addColorsToOptions(props.options, getTheme);
}
chart.update();

chart.update("none");
};

const deferredData = createDeferred(() => props.data, { timeoutMs: 500 });

createEffectOn(deferredData, (data) => updateChart(data));

createEffectOn(getTheme, () => {
if (!chart) return;
const newTheme = getThemeHash();
if (theme === newTheme) return;
theme = newTheme;
updateChart(deferredData());
});

onCleanup(() => {
Expand Down
Loading
Loading