Refactor and Move TileErrorBoundary to Shared Components (#32793)

* creation of stories and view in shared-components

* migrate EventTile error fallback to shared TileErrorView MVVM

* Fix lint errors and unused import

* Update tests because of the refactoring

* Update snapshots + stories

* removal of mxEvent since it never changes in timeline

* Update packages/shared-components/src/message-body/TileErrorView/TileErrorView.stories.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update apps/web/src/viewmodels/message-body/TileErrorViewModel.ts

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Update apps/web/src/viewmodels/message-body/TileErrorViewModel.ts

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* docs: add TileErrorView tsdoc

* docs: add TileErrorViewModel tsdoc

* docs: add view source label tsdoc

* refactor: move tile error layout into vm

* docs: add TileErrorView story view docs

* docs: move tile error story list wrapper

* refactor: remove unused tile error event setter

* Update packages/shared-components/src/message-body/TileErrorView/TileErrorView.stories.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* docs: add tsdoc for event tile error fallback props

* refactor: rely on snapshot merge no-op checks

* remove unessecery if statment

* test: restore EventTile mocks in afterEach

* test(shared-components): move TileErrorView baselines

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
Zack
2026-04-08 11:05:31 +02:00
committed by GitHub
parent 6e9fc9b8fa
commit d197fb4e30
16 changed files with 780 additions and 102 deletions
+1
View File
@@ -17,6 +17,7 @@ export * from "./room/timeline/event-tile/body/EventContentBodyView";
export * from "./room/timeline/event-tile/body/RedactedBodyView";
export * from "./room/timeline/event-tile/body/MFileBodyView";
export * from "./room/timeline/event-tile/body/MVideoBodyView";
export * from "./room/timeline/event-tile/EventTileView/TileErrorView";
export * from "./core/pill-input/Pill";
export * from "./core/pill-input/PillInput";
export * from "./room/RoomStatusBar";
@@ -0,0 +1,38 @@
.tileErrorView {
color: var(--cpd-color-text-critical-primary);
list-style: none;
text-align: center;
}
.line {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--cpd-space-2x);
}
.bubble .line {
flex-direction: column;
}
.message {
padding: var(--cpd-space-1x) var(--cpd-space-4x);
}
.viewSourceButton {
appearance: none;
padding: 0;
border: 0;
background: none;
color: var(--cpd-color-text-action-primary);
cursor: pointer;
font: inherit;
text-decoration: underline;
}
.viewSourceButton:focus-visible {
outline: 2px solid var(--cpd-color-border-focused);
outline-offset: 2px;
border-radius: var(--cpd-space-1x);
}
@@ -0,0 +1,72 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentProps, type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { TileErrorView, type TileErrorViewActions, type TileErrorViewSnapshot } from "./TileErrorView";
type WrapperProps = TileErrorViewSnapshot &
Partial<TileErrorViewActions> &
Omit<ComponentProps<typeof TileErrorView>, "vm">;
const TileErrorViewWrapperImpl = ({
className,
onBugReportClick = fn(),
onViewSourceClick = fn(),
...snapshotProps
}: WrapperProps): JSX.Element => {
const vm = useMockedViewModel(snapshotProps, {
onBugReportClick,
onViewSourceClick,
});
return <TileErrorView vm={vm} className={className} />;
};
const TileErrorViewWrapper = withViewDocs(TileErrorViewWrapperImpl, TileErrorView);
const meta = {
title: "MessageBody/TileErrorView",
component: TileErrorViewWrapper,
tags: ["autodocs"],
decorators: [
(Story): JSX.Element => (
<ul>
<Story />
</ul>
),
],
args: {
message: "Can't load this message",
eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs",
viewSourceCtaLabel: "View source",
layout: "group",
},
} satisfies Meta<typeof TileErrorViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const BubbleLayout: Story = {
args: {
layout: "bubble",
},
};
export const WithoutActions: Story = {
args: {
bugReportCtaLabel: undefined,
viewSourceCtaLabel: undefined,
},
};
@@ -0,0 +1,92 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type MouseEventHandler } from "react";
import { composeStories } from "@storybook/react-vite";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@test-utils";
import { describe, expect, it, vi } from "vitest";
import { MockViewModel } from "../../../../../core/viewmodel";
import {
TileErrorView,
type TileErrorViewActions,
type TileErrorViewModel,
type TileErrorViewSnapshot,
} from "./TileErrorView";
import * as stories from "./TileErrorView.stories";
const { Default, BubbleLayout, WithoutActions } = composeStories(stories);
describe("TileErrorView", () => {
it("renders the default tile error state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the bubble layout variant", () => {
const { container } = render(<BubbleLayout />);
expect(container).toMatchSnapshot();
});
it("renders the fallback text without actions", () => {
render(<WithoutActions />);
expect(screen.getByText("Can't load this message (m.room.message)")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Submit debug logs" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "View source" })).not.toBeInTheDocument();
});
it("invokes bug-report and view-source actions", async () => {
const user = userEvent.setup();
const onBugReportClick = vi.fn();
const onViewSourceClick = vi.fn();
class TestTileErrorViewModel extends MockViewModel<TileErrorViewSnapshot> implements TileErrorViewActions {
public onBugReportClick?: MouseEventHandler<HTMLButtonElement>;
public onViewSourceClick?: MouseEventHandler<HTMLButtonElement>;
public constructor(snapshot: TileErrorViewSnapshot, actions: TileErrorViewActions) {
super(snapshot);
Object.assign(this, actions);
}
}
const vm = new TestTileErrorViewModel(
{
layout: "group",
message: "Can't load this message",
eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs",
viewSourceCtaLabel: "View source",
},
{
onBugReportClick,
onViewSourceClick,
},
) as TileErrorViewModel;
render(<TileErrorView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Submit debug logs" }));
await user.click(screen.getByRole("button", { name: "View source" }));
expect(onBugReportClick).toHaveBeenCalledTimes(1);
expect(onViewSourceClick).toHaveBeenCalledTimes(1);
});
it("applies a custom className to the root element", () => {
const vm = new MockViewModel<TileErrorViewSnapshot>({
layout: "group",
message: "Can't load this message",
}) as TileErrorViewModel;
render(<TileErrorView vm={vm} className="custom-tile-error" />);
expect(screen.getByRole("status").closest("li")).toHaveClass("custom-tile-error");
});
});
@@ -0,0 +1,98 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type MouseEventHandler } from "react";
import classNames from "classnames";
import { Button } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import styles from "./TileErrorView.module.css";
export type TileErrorViewLayout = "bubble" | "group" | "irc";
export interface TileErrorViewSnapshot {
/**
* Layout variant used by the host timeline.
*/
layout?: TileErrorViewLayout;
/**
* Primary fallback text shown when a tile fails to render.
*/
message: string;
/**
* Optional event type appended to the fallback text.
*/
eventType?: string;
/**
* Optional label for the bug-report action button.
*/
bugReportCtaLabel?: string;
/**
* Optional label for the view-source action.
*/
viewSourceCtaLabel?: string;
}
export interface TileErrorViewActions {
/**
* Invoked when the bug-report button is clicked.
*/
onBugReportClick?: MouseEventHandler<HTMLButtonElement>;
/**
* Invoked when the view-source action is clicked.
*/
onViewSourceClick?: MouseEventHandler<HTMLButtonElement>;
}
export type TileErrorViewModel = ViewModel<TileErrorViewSnapshot, TileErrorViewActions>;
interface TileErrorViewProps {
/**
* The view model for the tile error fallback.
*/
vm: TileErrorViewModel;
/**
* Optional host-level class names.
*/
className?: string;
}
/**
* Renders a timeline tile fallback when message content cannot be displayed.
*
* The component shows the fallback error message from the view model, optionally
* appends the event type in parentheses, and can render bug-report and view-source
* actions when their labels are provided. The layout in the view-model snapshot
* selects the timeline presentation variant.
*/
export function TileErrorView({ vm, className }: Readonly<TileErrorViewProps>): JSX.Element {
const { message, eventType, bugReportCtaLabel, viewSourceCtaLabel, layout = "group" } = useViewModel(vm);
return (
<li
className={classNames(styles.tileErrorView, className, { [styles.bubble]: layout === "bubble" })}
data-layout={layout}
>
<div className={styles.line} role="status">
<span className={styles.message}>
{message}
{eventType && ` (${eventType})`}
</span>
{bugReportCtaLabel && (
<Button kind="secondary" size="sm" onClick={vm.onBugReportClick}>
{bugReportCtaLabel}
</Button>
)}
{viewSourceCtaLabel && (
<button type="button" className={styles.viewSourceButton} onClick={vm.onViewSourceClick}>
{viewSourceCtaLabel}
</button>
)}
</div>
</li>
);
}
@@ -0,0 +1,77 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`TileErrorView > renders the bubble layout variant 1`] = `
<div>
<ul>
<li
class="tileErrorView bubble"
data-layout="bubble"
>
<div
class="line"
role="status"
>
<span
class="message"
>
Can't load this message
(m.room.message)
</span>
<button
class="_button_13vu4_8"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
Submit debug logs
</button>
<button
class="viewSourceButton"
type="button"
>
View source
</button>
</div>
</li>
</ul>
</div>
`;
exports[`TileErrorView > renders the default tile error state 1`] = `
<div>
<ul>
<li
class="tileErrorView"
data-layout="group"
>
<div
class="line"
role="status"
>
<span
class="message"
>
Can't load this message
(m.room.message)
</span>
<button
class="_button_13vu4_8"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
Submit debug logs
</button>
<button
class="viewSourceButton"
type="button"
>
View source
</button>
</div>
</li>
</ul>
</div>
`;
@@ -0,0 +1,14 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export {
TileErrorView,
type TileErrorViewActions,
type TileErrorViewLayout,
type TileErrorViewModel,
type TileErrorViewSnapshot,
} from "./TileErrorView";