Skip to content

Commit

Permalink
use intl in error screen, refactor ReactIntegration, added tests
Browse files Browse the repository at this point in the history
  • Loading branch information
antoniave committed Oct 22, 2024
1 parent d56630a commit 6800448
Show file tree
Hide file tree
Showing 8 changed files with 135 additions and 63 deletions.
26 changes: 25 additions & 1 deletion src/packages/runtime/CustomElement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ describe("application lifecycle events", function () {
const { node } = await renderComponent(elem);
await waitFor(() => {
const state = (node as InternalElementType).$inspectElementState?.().state;
if (state !== "destroyed") {
if (state !== "error") {
throw new Error(`App did not reach destroyed state.`);
}
});
Expand Down Expand Up @@ -675,3 +675,27 @@ describe("i18n support", function () {
return result;
}
});

it("renders an error screen when the app fails to start", async () => {
const elem = createCustomElement({
async resolveConfig() {
throw new Error("help!");
}
});

const { node } = await renderComponent(elem);
await waitFor(() => {
const state = (node as InternalElementType).$inspectElementState?.().state;
if (state !== "error") {
throw new Error(`App did not reach error state.`);
}
});

const errorScreen = node.shadowRoot?.querySelector("div");
expect(errorScreen).not.toBe(undefined);
expect(errorScreen?.className).toBe("pioneer-root pioneer-root-error-screen");
const includesErrorText = Array.from(errorScreen?.children ?? []).some((child) =>
child.textContent?.includes("Error")
);
expect(includesErrorText).toBe(true);
});
52 changes: 24 additions & 28 deletions src/packages/runtime/CustomElement.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
// SPDX-License-Identifier: Apache-2.0
import { ComponentType } from "react";
import { ComponentType, createElement } from "react";
import {
createAbortError,
createLogger,
createManualPromise,
destroyResource,
Error,
isAbortError,
ManualPromise,
Resource,
throwAbortError
Expand All @@ -26,9 +25,9 @@ import {
RUNTIME_AUTO_START
} from "./builtin-services";
import { ReferenceSpec } from "./service-layer/InterfaceSpec";
import { AppI18n, getBrowserLocales, initI18n } from "./i18n";
import { AppI18n, createPackageIntl, getBrowserLocales, I18nConfig, initI18n } from "./i18n";
import { ApplicationLifecycleEventService } from "./builtin-services/ApplicationLifecycleEventService";
import { ErrorScreen } from "./ErrorScreen";
import { ErrorScreen, MESSAGES_BY_LOCALE } from "./ErrorScreen";
const LOG = createLogger("runtime:CustomElement");

/**
Expand Down Expand Up @@ -272,8 +271,7 @@ class ApplicationInstance {
private apiPromise: ManualPromise<ApiMethods> | undefined; // Present when callers are waiting for the API
private api: ApiMethods | undefined; // Present once started

private state = "not-started" as "not-started" | "starting" | "started" | "destroyed";
private locale: string | undefined;
private state = "not-started" as "not-started" | "starting" | "started" | "destroyed" | "error";
private container: HTMLDivElement | undefined;
private config: ApplicationConfig | undefined;
private serviceLayer: ServiceLayer | undefined;
Expand All @@ -292,11 +290,12 @@ class ApplicationInstance {

this.state = "starting";
this.startImpl().catch((e) => {
// this.destroy(); TODO introduce error state and do needed cleanup instead of calling destroy
if (!isAbortError(e)) {
this.showErrorScreen();
logError(e);
}
if (this.state === "destroyed") return;

logError(e);
this.reset();
this.state = "error";
this.showErrorScreen();
});
}

Expand All @@ -314,6 +313,10 @@ class ApplicationInstance {
}
}
this.state = "destroyed";
this.reset();
}

private reset() {
this.apiPromise?.reject(createAbortError());
this.reactIntegration = destroyResource(this.reactIntegration);
this.options.shadowRoot.replaceChildren();
Expand Down Expand Up @@ -380,7 +383,7 @@ class ApplicationInstance {

private render() {
const component = this.options.elementOptions.component ?? emptyComponent;
this.reactIntegration?.render(component);
this.reactIntegration?.render(createElement(component));
}

private initStyles() {
Expand Down Expand Up @@ -468,29 +471,22 @@ class ApplicationInstance {
}

private showErrorScreen() {
const { shadowRoot, elementOptions, overrides } = this.options;
const container = document.createElement("div");
const userLocales = getBrowserLocales();
const i18nConfig = new I18nConfig(["en", "de"]);
const { messageLocale } = i18nConfig.pickSupportedLocale(undefined, userLocales);
const useLocale = messageLocale === "de" ? "de" : "en";
const intl = createPackageIntl(messageLocale, MESSAGES_BY_LOCALE[useLocale]);

const container = (this.container = createContainer(useLocale));
container.classList.add("pioneer-root-error-screen");
container.style.minHeight = "100%";
container.style.height = "100%";

let locale = "en";
if (overrides?.locale && overrides.locale == "de") {
locale = "de";
} else {
const userLocales = getBrowserLocales();
if (userLocales[0] == "de") {
locale = "de";
}
}
container.lang = locale;

const { shadowRoot, elementOptions } = this.options;
this.reactIntegration = ReactIntegration.createForErrorScreen({
rootNode: container,
container: shadowRoot,
theme: elementOptions.theme
});
this.reactIntegration?.render(ErrorScreen);
this.reactIntegration?.render(createElement(ErrorScreen, { intl }));

shadowRoot.replaceChildren(container);
}
Expand Down
30 changes: 24 additions & 6 deletions src/packages/runtime/ErrorScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,38 @@ import {
AlertDescription,
Alert
} from "@open-pioneer/chakra-integration";
import { PackageIntl } from "./i18n";

// todo render error message if started in dev mode
const MESSAGES_DE = {
"title": "Anwendungsstart fehlgeschlagen",
"alertTitle": "Fehler",
"alertDescription": "Leider ist beim Start der Anwendung eine Fehler aufgetreten."
};

export function ErrorScreen() {
// todo how to access lang parameter? --> pass root as ref?
const MESSAGES_EN = {
"title": "Application start failed",
"alertTitle": "Error",
"alertDescription": "Unfortunately an error occurred during application start."
};

export const MESSAGES_BY_LOCALE: Record<"en" | "de", Record<string, string>> = {
"en": MESSAGES_EN,
"de": MESSAGES_DE
};

// todo render error message (and callstack) if started in dev mode

export function ErrorScreen(props: { intl: PackageIntl }) {
const intl = props.intl;
return (
<Box>
<VStack padding={3}>
<Heading size="md">Application start failed</Heading>
<Heading size="md">{intl.formatMessage({ id: "title" })}</Heading>
<Alert status="error" maxWidth={550}>
<AlertIcon />
<AlertTitle>Error</AlertTitle>
<AlertTitle>{intl.formatMessage({ id: "alertTitle" })}</AlertTitle>
<AlertDescription>
Unfortunately an error occurred during application start.
{intl.formatMessage({ id: "alertDescription" })}
</AlertDescription>
</Alert>
</VStack>
Expand Down
1 change: 1 addition & 0 deletions src/packages/runtime/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export enum ErrorId {
NOT_MOUNTED = "runtime:element-not-mounted",
UNSUPPORTED_LOCALE = "runtime:unsupported-locale",
CONFIG_RESOLUTION_FAILED = "runtime:config-resolution-failed",
INVALID_STATE = "runtime:invalide-state",

// Service layer
INTERFACE_NOT_FOUND = "runtime:interface-not-found",
Expand Down
2 changes: 1 addition & 1 deletion src/packages/runtime/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface AppI18n {
*/
export type PackageIntl = Pick<IntlShape, "locale" | "timeZone"> & IntlFormatters<string>;

function createPackageIntl(locale: string, messages: Record<string, string>) {
export function createPackageIntl(locale: string, messages: Record<string, string>) {
const cache = createIntlCache();
return createIntl(
{
Expand Down
62 changes: 47 additions & 15 deletions src/packages/runtime/react-integration/ReactIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { findByTestId, findByText } from "@testing-library/dom";
import { act } from "@testing-library/react";
import { createElement } from "react";
import { beforeEach, expect, it, MockInstance, afterEach, vi } from "vitest";
import { beforeEach, expect, it, MockInstance, afterEach, vi, describe } from "vitest";
import { Service, ServiceConstructor } from "../Service";
import { usePropertiesInternal, useServiceInternal, useServicesInternal } from "./hooks";
import { useTheme } from "@open-pioneer/chakra-integration";
Expand Down Expand Up @@ -57,7 +57,7 @@ it("should allow access to service via react hook", async () => {
});

act(() => {
integration.render(TestComponent);
integration.render(createElement(TestComponent));
});

const node = await findByText(wrapper, "Hello TEST");
Expand All @@ -79,7 +79,7 @@ it("should get error when using undefined service", async () => {

expect(() => {
act(() => {
integration.render(TestComponent);
integration.render(createElement(TestComponent));
});
}).toThrowErrorMatchingSnapshot();
expect(errorSpy).toHaveBeenCalledOnce();
Expand All @@ -99,7 +99,7 @@ it("reports a helpful error when package metadata is missing", async () => {
const { integration } = createIntegration();
expect(() => {
act(() => {
integration.render(TestComponent);
integration.render(createElement(TestComponent));
});
}).toThrowErrorMatchingSnapshot();
expect(errorSpy).toHaveBeenCalledOnce();
Expand Down Expand Up @@ -127,7 +127,7 @@ it("should allow access to service with qualifier via react hook", async () => {
});

act(() => {
integration.render(TestComponent);
integration.render(createElement(TestComponent));
});

const node = await findByText(wrapper, "Hello TEST");
Expand Down Expand Up @@ -159,7 +159,7 @@ it("should deny access to service when the qualifier does not match", async () =

expect(() => {
act(() => {
integration.render(TestComponent);
integration.render(createElement(TestComponent));
});
}).toThrowErrorMatchingSnapshot();
expect(errorSpy).toHaveBeenCalledOnce();
Expand Down Expand Up @@ -203,7 +203,7 @@ it("should allow access to all services via react hook", async () => {
});

act(() => {
integration.render(TestComponent);
integration.render(createElement(TestComponent));
});

const node = await findByText(wrapper, /^Joined Values:/);
Expand All @@ -228,7 +228,7 @@ it("should deny access to all services if declaration is missing", async () => {

expect(() => {
act(() => {
integration.render(TestComponent);
integration.render(createElement(TestComponent));
});
}).toThrowErrorMatchingSnapshot();
expect(errorSpy).toHaveBeenCalledOnce();
Expand All @@ -247,7 +247,7 @@ it("should be able to read properties from react component", async () => {
});

act(() => {
integration.render(TestComponent);
integration.render(createElement(TestComponent));
});

const node = await findByText(wrapper, "Hello USER");
Expand All @@ -271,7 +271,7 @@ it("should provide the autogenerated useService hook", async () => {
});

act(() => {
integration.render(UIWithService);
integration.render(createElement(UIWithService));
});

const node = await findByText(wrapper, /^Test-UI:/);
Expand Down Expand Up @@ -302,7 +302,7 @@ it("should provide the autogenerated useServices hook", async () => {
});

act(() => {
integration.render(UIWithServices);
integration.render(createElement(UIWithServices));
});

const node = await findByText(wrapper, /^Test-UI:/);
Expand All @@ -319,7 +319,7 @@ it("should provide the autogenerated useProperties hook", async () => {
});

act(() => {
integration.render(UIWithProperties);
integration.render(createElement(UIWithProperties));
});

const node = await findByText(wrapper, /^Test-UI:/);
Expand All @@ -340,7 +340,7 @@ it("should throw error when requesting properties from an unknown package", asyn

expect(() => {
act(() => {
integration.render(TestComponent);
integration.render(createElement(TestComponent));
});
}).toThrowErrorMatchingSnapshot();
expect(errorSpy).toHaveBeenCalledOnce();
Expand Down Expand Up @@ -370,14 +370,46 @@ it("should apply the configured chakra theme", async () => {
}

act(() => {
integration.render(TestComponent);
integration.render(createElement(TestComponent));
});

const node = await findByTestId(wrapper, "test-div");
expect(node.textContent).toBe("Color: #123456");
});

// TODO: Test `createForErrorScreen`
describe("integration for error screen ", function () {
it("should create an ReactIntegration for an error screen", async () => {
const integration = ReactIntegration.createForErrorScreen({
rootNode: document.createElement("div"),
container: document.createElement("div"),
theme: undefined
});

expect(integration).toBeInstanceOf(ReactIntegration);
});

it("should throw an error when trying to access a service on an error screen", async () => {
errorSpy.mockImplementation(doNothing);

const integration = ReactIntegration.createForErrorScreen({
rootNode: document.createElement("div"),
container: document.createElement("div"),
theme: undefined
});

function TestComponent() {
const service = useServiceInternal<unknown>("test", "test.Provider") as TestProvider;
return createElement("span", undefined, `Hello ${service.value}`);
}

expect(() => {
act(() => {
integration.render(createElement(TestComponent));
});
}).toThrowErrorMatchingSnapshot();
expect(errorSpy).toHaveBeenCalledOnce();
});
});

interface ServiceSpec {
name: string;
Expand Down
Loading

0 comments on commit 6800448

Please sign in to comment.