♻️(frontend) unify preview messages into a shared component

The error, not-supported, suspicious and WOPI placeholder viewers all
rendered the same "icon + title + description + action" layout with
near-duplicate scss. Extract a PreviewMessage component so styling and
backdrop-close behaviour live in one place, and update the e2e
selectors to match the new markup.
This commit is contained in:
Nathan Vasse
2026-04-20 12:56:19 +02:00
parent 788cdba513
commit 3030f71189
12 changed files with 149 additions and 155 deletions

View File

@@ -193,6 +193,23 @@
padding: 2rem;
text-align: center;
color: var(--c--contextuals--content--semantic--neutral--secondary);
gap: var(--c--globals--spacings--xs);
&__title {
color: var(--c--contextuals--content--semantic--neutral--primary);
text-align: center;
font-size: 16px;
font-weight: 700;
line-height: 22px;
}
&__description {
color: var(--c--contextuals--content--semantic--neutral--secondary);
text-align: center;
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
p {
margin: 0.5rem 0;

View File

@@ -1,18 +1,19 @@
.file-preview-error {
.preview-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--c--globals--spacings--sm);
height: 100%;
gap: 8px;
&__title {
color: var(--c--contextuals--content--semantic--error--primary);
color: var(--c--contextuals--content--semantic--neutral--primary);
text-align: center;
font-size: 16px;
font-style: normal;
font-weight: 700;
line-height: 22px;
margin: 0;
}
&__description {
@@ -22,9 +23,14 @@
font-style: normal;
font-weight: 400;
line-height: 16px;
margin: 0;
}
&__download-button {
margin-top: var(--c--globals--spacings--xs);
&__action {
margin-top: 16px;
}
&--error &__title {
color: var(--c--contextuals--content--semantic--error--primary);
}
}

View File

@@ -0,0 +1,37 @@
import clsx from "clsx";
import React from "react";
interface PreviewMessageProps {
icon: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
action?: React.ReactNode;
variant?: "neutral" | "error";
closeOnBackdrop?: boolean;
}
export const PreviewMessage = ({
icon,
title,
description,
action,
variant = "neutral",
closeOnBackdrop = true,
}: PreviewMessageProps) => {
return (
<div
className={clsx(
"preview-message",
variant === "error" && "preview-message--error",
)}
data-preview-backdrop={closeOnBackdrop ? "true" : undefined}
>
<div className="preview-message__icon">{icon}</div>
<p className="preview-message__title">{title}</p>
{description && (
<p className="preview-message__description">{description}</p>
)}
{action && <div className="preview-message__action">{action}</div>}
</div>
);
};

View File

@@ -2,10 +2,8 @@
@use "./components/duration-bar/DurationBar.scss";
@use "./components/volume-bar/VolumeBar.scss";
@use "./components/controls/PreviewControls.scss";
@use "./viewers/not-supported/NotSupportedPreview.scss";
@use "./viewers/suspicious/SuspiciousPreview.scss";
@use "./components/preview-message/PreviewMessage.scss";
@use "./viewers/wopi/WopiEditorFrame.scss";
@use "./viewers/error/ErrorPreview.scss";
@use "./viewers/audio-player/AudioPlayer.scss";
@use "./viewers/image-viewer/ImageViewer.scss";
@use "./viewers/video-player/VideoPlayer.scss";

View File

@@ -6,6 +6,7 @@ import { FileIcon } from "@/features/explorer/components/icons/ItemIcon";
import { downloadFile } from "@/features/items/utils";
import { useCallback } from "react";
import { FilePreviewType } from "../../FilesPreview";
import { PreviewMessage } from "../../components/preview-message/PreviewMessage";
interface ErrorPreviewProps {
file: FilePreviewType;
@@ -19,25 +20,22 @@ export const ErrorPreview = ({ file }: ErrorPreviewProps) => {
}, [file]);
return (
<div className="file-preview-error">
<div className="file-preview-error__icon">
<FileIcon file={file} size="xlarge" />
</div>
<div className="file-preview-error__title">
{t("file_preview.error.title")}
</div>
<div className="file-preview-error__description">
{t("file_preview.error.description")}
</div>
<Button
variant="bordered"
className="file-preview-error__download-button"
icon={<Icon name="file_download" type={IconType.OUTLINED} size={16} />}
onClick={handleDownload}
>
{t("file_preview.unsupported.download")}
</Button>
</div>
<PreviewMessage
variant="error"
icon={<FileIcon file={file} size="xlarge" />}
title={t("file_preview.error.title")}
description={t("file_preview.error.description")}
action={
<Button
variant="bordered"
icon={
<Icon name="file_download" type={IconType.OUTLINED} size={16} />
}
onClick={handleDownload}
>
{t("file_preview.unsupported.download")}
</Button>
}
/>
);
};

View File

@@ -1,29 +0,0 @@
.file-preview-unsupported {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--c--globals--spacings--xs);
&__title {
color: var(--c--contextuals--content--semantic--neutral--primary);
text-align: center;
font-size: 16px;
font-style: normal;
font-weight: 700;
line-height: 22px;
}
&__description {
color: var(--c--contextuals--content--semantic--neutral--secondary);
text-align: center;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}
&__download-button {
margin-top: var(--c--globals--spacings--md);
}
}

View File

@@ -5,6 +5,7 @@ import { Button } from "@gouvfr-lasuite/cunningham-react";
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import { FileIcon } from "@/features/explorer/components/icons/ItemIcon";
import { useCallback } from "react";
import { PreviewMessage } from "../../components/preview-message/PreviewMessage";
interface NotSupportedPreviewProps {
file: FilePreviewType;
@@ -24,33 +25,31 @@ export const NotSupportedPreview = ({
}, [onDownload, file]);
return (
<div className="file-preview-unsupported" data-preview-backdrop="true">
<div className="file-preview-unsupported__icon">
<FileIcon file={file} size="xlarge" />
</div>
<p className="file-preview-unsupported__title">{file.title}</p>
<p className="file-preview-unsupported__description">
{title || (
<PreviewMessage
icon={<FileIcon file={file} size="xlarge" />}
title={file.title}
description={
title ?? (
<>
<strong>{t("file_preview.unsupported.disclaimer")}</strong>{" "}
{t("file_preview.unsupported.description")}
</>
)}
</p>
{onDownload && (
<Button
variant="secondary"
color="neutral"
className="file-preview-unsupported__download-button"
icon={
<Icon name="file_download" type={IconType.OUTLINED} size={16} />
}
onClick={handleDownload}
>
{t("file_preview.suspicious.download")}
</Button>
)}
</div>
)
}
action={
onDownload && (
<Button
variant="secondary"
color="neutral"
icon={
<Icon name="file_download" type={IconType.OUTLINED} size={16} />
}
onClick={handleDownload}
>
{t("file_preview.suspicious.download")}
</Button>
)
}
/>
);
};

View File

@@ -1,26 +0,0 @@
.file-preview-suspicious {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--c--globals--spacings--sm);
&__title {
color: var(--c--contextuals--content--semantic--neutral--primary);
text-align: center;
font-size: 16px;
font-style: normal;
font-weight: 700;
line-height: 22px;
}
&__description {
color: var(--c--contextuals--content--semantic--neutral--secondary);
text-align: center;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}
}

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import mimeSuspicious from "@/assets/files/icons/suspicious_file.svg";
import { PreviewMessage } from "../../components/preview-message/PreviewMessage";
interface SuspiciousPreviewProps {
handleDownload?: () => void;
@@ -14,30 +15,26 @@ export const SuspiciousPreview = ({
const { t } = useTranslation();
return (
<div className="file-preview-suspicious" data-preview-backdrop="true">
<div className="file-preview-suspicious__icon">
<PreviewMessage
icon={
<img src={mimeSuspicious.src} alt="" className="item-icon xlarge" />
</div>
<span className="file-preview-suspicious__title">
{t("file_preview.suspicious.title")}
</span>
<span className="file-preview-suspicious__description">
{t("file_preview.suspicious.description")}
</span>
{handleDownload && (
<Button
variant="secondary"
color="warning"
className="file-preview-suspicious__download-button"
icon={
<Icon name="file_download" type={IconType.OUTLINED} size={16} />
}
onClick={handleDownload}
>
{t("file_preview.unsupported.download")}
</Button>
)}
</div>
}
title={t("file_preview.suspicious.title")}
description={t("file_preview.suspicious.description")}
action={
handleDownload && (
<Button
variant="secondary"
color="warning"
icon={
<Icon name="file_download" type={IconType.OUTLINED} size={16} />
}
onClick={handleDownload}
>
{t("file_preview.unsupported.download")}
</Button>
)
}
/>
);
};

View File

@@ -4,6 +4,7 @@ import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import { FileIcon } from "@/features/explorer/components/icons/ItemIcon";
import { FilePreviewType } from "../../FilesPreview";
import { openWopiInNewTab } from "./openWopi";
import { PreviewMessage } from "../../components/preview-message/PreviewMessage";
interface WopiOpenInEditorProps {
file: FilePreviewType;
@@ -13,22 +14,18 @@ export const WopiOpenInEditor = ({ file }: WopiOpenInEditorProps) => {
const { t } = useTranslation();
return (
<div className="file-preview-unsupported" data-preview-backdrop="true">
<div className="file-preview-unsupported__icon">
<FileIcon file={file} size="xlarge" />
</div>
<p className="file-preview-unsupported__title">{file.title}</p>
<p className="file-preview-unsupported__description">
{t("file_preview.wopi.open_in_editor_description")}
</p>
<Button
className="file-preview-unsupported__download-button"
icon={<Icon name="open_in_new" type={IconType.OUTLINED} size={16} />}
onClick={() => openWopiInNewTab(file.id)}
>
{t("file_preview.wopi.open_in_editor")}
</Button>
</div>
<PreviewMessage
icon={<FileIcon file={file} size="xlarge" />}
title={file.title}
description={t("file_preview.wopi.open_in_editor_description")}
action={
<Button
icon={<Icon name="open_in_new" type={IconType.OUTLINED} size={16} />}
onClick={() => openWopiInNewTab(file.id)}
>
{t("file_preview.wopi.open_in_editor")}
</Button>
}
/>
);
};

View File

@@ -34,20 +34,20 @@ test.describe("Unsupported File Preview", () => {
test("Renders the NotSupportedPreview for an unknown binary file", async ({
page,
}) => {
const unsupported = page.locator(".file-preview-unsupported");
const unsupported = page.locator(".preview-message");
await expect(unsupported).toBeVisible();
await expect(
unsupported.locator(".file-preview-unsupported__title"),
unsupported.locator(".preview-message__title"),
).toHaveText("test-unsupported.bin");
});
test("Download button in the unsupported view triggers a download", async ({
page,
}) => {
const downloadButton = page.locator(
".file-preview-unsupported__download-button",
);
const downloadButton = page
.locator(".preview-message__action")
.getByRole("button");
await expect(downloadButton).toBeVisible();
const downloadPromise = page.waitForEvent("download");

View File

@@ -69,10 +69,10 @@ test("Navigating the previewer onto a WOPI file shows the Open in editor placeho
// test-image.png, so ArrowLeft navigates from the image to the docx.
await page.keyboard.press("ArrowLeft");
const unsupported = filePreview.locator(".file-preview-unsupported");
const unsupported = filePreview.locator(".preview-message");
await expect(unsupported).toBeVisible();
await expect(
unsupported.locator(".file-preview-unsupported__title"),
unsupported.locator(".preview-message__title"),
).toHaveText("empty_doc.docx");
const openButton = unsupported.getByRole("button", {