diff --git a/src/frontend/apps/drive/package.json b/src/frontend/apps/drive/package.json
index 20f10d77c..e4f31f720 100644
--- a/src/frontend/apps/drive/package.json
+++ b/src/frontend/apps/drive/package.json
@@ -17,7 +17,7 @@
},
"dependencies": {
"@gouvfr-lasuite/cunningham-react": "4.3.0",
- "@gouvfr-lasuite/ui-kit": "0.20.1",
+ "@gouvfr-lasuite/ui-kit": "0.20.2",
"@tanstack/react-query": "5.90.10",
"@tanstack/react-table": "8.21.3",
"@viselect/react": "3.9.0",
diff --git a/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorer.scss b/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorer.scss
index 6804a3ddc..68c0a861d 100644
--- a/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorer.scss
+++ b/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorer.scss
@@ -21,7 +21,8 @@ $tablet: map.get($themes, "default", "globals", "breakpoints", "tablet");
}
.explorer__container {
- width: 992px;
+ max-width: 992px;
+ width: 100%;
}
.explorer__content {
@@ -125,6 +126,7 @@ $tablet: map.get($themes, "default", "globals", "breakpoints", "tablet");
display: flex;
align-items: center;
gap: var(--c--globals--spacings--2xs);
+ flex-shrink: 0;
}
&--mobile {
diff --git a/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorerBreadcrumbs.tsx b/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorerBreadcrumbs.tsx
index 9a966af83..cdb2e04bb 100644
--- a/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorerBreadcrumbs.tsx
+++ b/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorerBreadcrumbs.tsx
@@ -40,10 +40,6 @@ export const AppExplorerBreadcrumbs = () => {
(onDefaultRoute && defaultRouteId === DefaultRoute.MY_FILES) ||
(!onDefaultRoute && item?.abilities?.children_create);
- if (!item && !onDefaultRoute) {
- return null;
- }
-
return (
<>
diff --git a/src/frontend/apps/drive/src/features/explorer/components/embedded-explorer/EmbeddedExplorerGridBreadcrumbs.tsx b/src/frontend/apps/drive/src/features/explorer/components/embedded-explorer/EmbeddedExplorerGridBreadcrumbs.tsx
index 6fecf19cd..d7db3cc1c 100644
--- a/src/frontend/apps/drive/src/features/explorer/components/embedded-explorer/EmbeddedExplorerGridBreadcrumbs.tsx
+++ b/src/frontend/apps/drive/src/features/explorer/components/embedded-explorer/EmbeddedExplorerGridBreadcrumbs.tsx
@@ -15,8 +15,7 @@ import { Icon, IconSize } from "@gouvfr-lasuite/ui-kit";
import { NavigationItem } from "../GlobalExplorerContext";
import { ItemActionDropdown } from "../item-actions/ItemActionDropdown";
import clsx from "clsx";
-import { useBreadcrumbQuery } from "../../hooks/useBreadcrumb";
-import { useItem } from "../../hooks/useQueries";
+import { useItemWithBreadcrumb } from "../../hooks/useBreadcrumb";
import { useRouter } from "next/router";
import { Button, useModal } from "@gouvfr-lasuite/cunningham-react";
import { ItemShareModal } from "../modals/share/ItemShareModal";
@@ -67,13 +66,12 @@ const BaseBreadcrumbs = ({
const { user } = useAuth();
const defaultRouteData = getDefaultRoute(router.pathname);
- const { data: breadcrumb } = useBreadcrumbQuery(currentItemId);
-
- const { data: fetchedItem } = useItem(currentItemId!, {
- enabled: !!currentItemId && !itemFromProps,
- });
-
- const item = itemFromProps ?? fetchedItem;
+ const { data } = useItemWithBreadcrumb(currentItemId);
+ // Prefer itemFromProps when provided: it comes from the surrounding
+ // explorer context which may have applied an optimistic mutation (rename,
+ // share toggle…) that the cached query data hasn't reflected yet.
+ const item = itemFromProps ?? data?.item;
+ const breadcrumb = data?.breadcrumb;
const handleGoBack = (item: Item | ItemBreadcrumb) => {
onGoBack?.(item);
@@ -92,7 +90,9 @@ const BaseBreadcrumbs = ({
>
{defaultRouteData.icon({ size: IconSize.MEDIUM })}
- {t(defaultRouteData.label)}
+
+ {t(defaultRouteData.label)}
+
);
};
@@ -180,32 +180,53 @@ const BaseBreadcrumbs = ({
};
const breadcrumbsItems = useMemo(() => {
+ const markLastActive = (entries: BreadcrumbItem[]): BreadcrumbItem[] => {
+ if (entries.length === 0) return entries;
+ const lastIdx = entries.length - 1;
+ return entries.map((entry, idx) =>
+ idx === lastIdx ? { ...entry, isActive: true } : entry,
+ );
+ };
+
if (forcedBreadcrumbsItems) {
- return forcedBreadcrumbsItems.map((item) => ({
- content: (
- handleGoBack(item)}
- />
- ),
- }));
+ return markLastActive(
+ forcedBreadcrumbsItems.map((item) => ({
+ content: (
+ handleGoBack(item)}
+ />
+ ),
+ label: item.title,
+ onClick: () => handleGoBack(item),
+ })),
+ );
}
const breadcrumbsItems: BreadcrumbItem[] = [];
if (defaultRouteData && !showAllFolderItem) {
breadcrumbsItems.push({
content: getDefaultRouteButton(defaultRouteData),
+ label: t(defaultRouteData.label),
+ onClick: () => router.push(defaultRouteData.route),
});
}
const fromRouteButton = getFromRouteButton();
if (fromRouteButton && !showAllFolderItem) {
+ const fromRouteData =
+ getFromRouteManualDefaultRouteData() ?? getGuessedDefaultRouteData();
breadcrumbsItems.push({
content: fromRouteButton,
+ label: fromRouteData ? t(fromRouteData.label) : "",
+ onClick: fromRouteData
+ ? () => router.push(fromRouteData.route)
+ : undefined,
});
}
if (showAllFolderItem) {
+ const allFoldersLabel = t("explorer.breadcrumbs.all_folders");
breadcrumbsItems.push({
content: (
- {t("explorer.breadcrumbs.all_folders")}
+
+ {allFoldersLabel}
+
),
+ label: allFoldersLabel,
+ onClick: () => goToSpaces?.(),
});
}
@@ -224,28 +249,27 @@ const BaseBreadcrumbs = ({
? (breadcrumb ?? []).slice(0, -1)
: (breadcrumb ?? []);
- const lastItem = item;
-
- breadcrumbsData.forEach((item) => {
- const isActive = item.id === lastItem?.id;
+ breadcrumbsData.forEach((crumb) => {
breadcrumbsItems.push({
content: (
handleGoBack(item)}
- isActive={isActive}
+ item={crumb}
+ onClick={() => handleGoBack(crumb)}
/>
),
+ label: crumb.title,
+ onClick: () => handleGoBack(crumb),
});
});
- if (showMenuLastItem && lastItem) {
+ if (showMenuLastItem && item) {
breadcrumbsItems.push({
- content: ,
+ content: ,
+ label: item.title,
});
}
- return breadcrumbsItems;
+ return markLastActive(breadcrumbsItems);
}, [
showAllFolderItem,
currentItemId,
@@ -278,7 +302,7 @@ export const BreadcrumbItemButton = ({
data-testid="breadcrumb-button"
onClick={onClick}
>
- {item.title}
+ {item.title}
{rightIcon}
);
diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useBreadcrumb.tsx b/src/frontend/apps/drive/src/features/explorer/hooks/useBreadcrumb.tsx
index 79eb8d18f..f437630c9 100644
--- a/src/frontend/apps/drive/src/features/explorer/hooks/useBreadcrumb.tsx
+++ b/src/frontend/apps/drive/src/features/explorer/hooks/useBreadcrumb.tsx
@@ -7,5 +7,28 @@ export const useBreadcrumbQuery = (id?: string | null) => {
queryKey: ["breadcrumb", id],
queryFn: () => driver.getItemBreadcrumb(id!),
enabled: !!id,
+ placeholderData: (previousData) => previousData,
+ });
+};
+
+/**
+ * Fetches the item and its breadcrumb in a single atomic query, so the two
+ * pieces never go out of sync during navigation. Combined with
+ * `placeholderData: previousData`, the previous chain remains visible until
+ * the new pair has resolved together — preventing the empty-middle flicker.
+ */
+export const useItemWithBreadcrumb = (id?: string | null) => {
+ const driver = getDriver();
+ return useQuery({
+ queryKey: ["itemWithBreadcrumb", id],
+ queryFn: async () => {
+ const [item, breadcrumb] = await Promise.all([
+ driver.getItem(id!),
+ driver.getItemBreadcrumb(id!),
+ ]);
+ return { item, breadcrumb };
+ },
+ enabled: !!id,
+ placeholderData: (previousData) => previousData,
});
};
diff --git a/src/frontend/apps/drive/src/features/i18n/translations.json b/src/frontend/apps/drive/src/features/i18n/translations.json
index 882f62e56..6735f45e5 100644
--- a/src/frontend/apps/drive/src/features/i18n/translations.json
+++ b/src/frontend/apps/drive/src/features/i18n/translations.json
@@ -171,7 +171,8 @@
"explorer": {
"breadcrumbs": {
"spaces": "Spaces",
- "all_folders": "All folders"
+ "all_folders": "All folders",
+ "show_hidden_folders": "Show hidden folders"
},
"modal": {
"move": {
@@ -772,7 +773,8 @@
"explorer": {
"breadcrumbs": {
"spaces": "Espaces",
- "all_folders": "Tous les dossiers"
+ "all_folders": "Tous les dossiers",
+ "show_hidden_folders": "Afficher les dossiers masqués"
},
"modal": {
"move": {
@@ -1382,7 +1384,8 @@
"explorer": {
"breadcrumbs": {
"spaces": "Werkruimten",
- "all_folders": "Alle mappen"
+ "all_folders": "Alle mappen",
+ "show_hidden_folders": "Toon verborgen mappen"
},
"modal": {
"move": {
diff --git a/src/frontend/apps/drive/src/features/ui/components/breadcrumbs/Breadcrumbs.tsx b/src/frontend/apps/drive/src/features/ui/components/breadcrumbs/Breadcrumbs.tsx
index 08518c5c4..f25373cea 100644
--- a/src/frontend/apps/drive/src/features/ui/components/breadcrumbs/Breadcrumbs.tsx
+++ b/src/frontend/apps/drive/src/features/ui/components/breadcrumbs/Breadcrumbs.tsx
@@ -1,9 +1,19 @@
import { Button } from "@gouvfr-lasuite/cunningham-react";
-import React, { ReactElement, ReactNode } from "react";
+import { DropdownMenu } from "@gouvfr-lasuite/ui-kit";
+import React, {
+ ReactNode,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { useTranslation } from "react-i18next";
export type BreadcrumbItem = {
content: ReactNode;
+ label?: string;
+ onClick?: () => void;
+ isActive?: boolean;
};
export interface BreadcrumbsProps {
@@ -12,47 +22,228 @@ export interface BreadcrumbsProps {
displayBack?: boolean;
}
+type Cell =
+ | { kind: "item"; item: BreadcrumbItem; index: number }
+ | { kind: "ellipsis" };
+
export const Breadcrumbs = ({
items,
onBack,
displayBack = false,
}: BreadcrumbsProps) => {
const { t } = useTranslation();
+ const containerRef = useRef(null);
+ const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
+ const separatorRef = useRef(null);
+ const ellipsisRef = useRef(null);
+ const itemWidthsRef = useRef([]);
+ const separatorWidthRef = useRef(0);
+ const ellipsisWidthRef = useRef(0);
+
+ // null = no collapse needed; otherwise items in [1, firstVisibleMiddle) are
+ // hidden behind the ellipsis. Item 0 and the last item are always visible.
+ const [firstVisibleMiddle, setFirstVisibleMiddle] = useState(
+ null,
+ );
+ const [isEllipsisOpen, setIsEllipsisOpen] = useState(false);
+
+ const lastIndex = items.length - 1;
+
+ useLayoutEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ // Measure once per items change. All separators are identical chevron
+ // icons, so a single reference width is enough.
+ itemWidthsRef.current = items.map(
+ (_, i) => itemRefs.current[i]?.getBoundingClientRect().width ?? 0,
+ );
+ separatorWidthRef.current =
+ separatorRef.current?.getBoundingClientRect().width ?? 0;
+ ellipsisWidthRef.current =
+ ellipsisRef.current?.getBoundingClientRect().width ?? 0;
+
+ const compute = (containerWidth: number): number | null => {
+ if (items.length <= 2) return null;
+ const widths = itemWidthsRef.current;
+ const sep = separatorWidthRef.current;
+ const ellipsis = ellipsisWidthRef.current;
+
+ const totalWidth =
+ widths.reduce((sum, w) => sum + w, 0) + sep * lastIndex;
+ if (totalWidth <= containerWidth) return null;
+
+ // Reserve: root + last + ellipsis + the two separators framing the
+ // ellipsis chain. Then walk middle items right→left, keeping each one
+ // that still fits the budget.
+ let budget =
+ containerWidth - widths[0] - widths[lastIndex] - ellipsis - sep * 2;
+ let firstVisible = lastIndex;
+ for (let i = lastIndex - 1; i >= 1; i--) {
+ const cost = widths[i] + sep;
+ if (cost > budget) break;
+ budget -= cost;
+ firstVisible = i;
+ }
+ return firstVisible;
+ };
+
+ setFirstVisibleMiddle(compute(container.getBoundingClientRect().width));
+
+ if (typeof ResizeObserver === "undefined") return;
+ let frame: number | null = null;
+ const observer = new ResizeObserver((entries) => {
+ if (frame !== null) cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => {
+ frame = null;
+ const width = entries[0]?.contentRect.width ?? 0;
+ setFirstVisibleMiddle(compute(width));
+ });
+ });
+ observer.observe(container);
+ return () => {
+ if (frame !== null) cancelAnimationFrame(frame);
+ observer.disconnect();
+ };
+ }, [items, lastIndex]);
+
+ const showEllipsis = firstVisibleMiddle !== null && firstVisibleMiddle > 1;
+
+ const visibleCells = useMemo(() => {
+ if (items.length === 0) return [];
+ const cells: Cell[] = [{ kind: "item", item: items[0], index: 0 }];
+ if (showEllipsis) {
+ cells.push({ kind: "ellipsis" });
+ for (let i = firstVisibleMiddle!; i < lastIndex; i++) {
+ cells.push({ kind: "item", item: items[i], index: i });
+ }
+ } else {
+ for (let i = 1; i < lastIndex; i++) {
+ cells.push({ kind: "item", item: items[i], index: i });
+ }
+ }
+ if (lastIndex > 0) {
+ cells.push({ kind: "item", item: items[lastIndex], index: lastIndex });
+ }
+ return cells;
+ }, [items, showEllipsis, firstVisibleMiddle, lastIndex]);
+
+ const hiddenItems = useMemo(
+ () => (showEllipsis ? items.slice(1, firstVisibleMiddle!) : []),
+ [items, showEllipsis, firstVisibleMiddle],
+ );
+
return (
-
- {displayBack && (
-
+ >
+ );
+};
+
+type EllipsisDropdownProps = {
+ items: BreadcrumbItem[];
+ isOpen: boolean;
+ setIsOpen: (open: boolean) => void;
+ ariaLabel: string;
+};
+
+const EllipsisDropdown = ({
+ items,
+ isOpen,
+ setIsOpen,
+ ariaLabel,
+}: EllipsisDropdownProps) => {
+ const options = items.map((item, idx) => ({
+ label: item.label ?? "",
+ value: String(idx),
+ callback: () => {
+ item.onClick?.();
+ setIsOpen(false);
+ },
+ }));
+
+ return (
+
+ setIsOpen(true)}
+ >
+ …
+
+
);
};
diff --git a/src/frontend/apps/drive/src/features/ui/components/breadcrumbs/index.scss b/src/frontend/apps/drive/src/features/ui/components/breadcrumbs/index.scss
index 4652fdce8..b00463fb7 100644
--- a/src/frontend/apps/drive/src/features/ui/components/breadcrumbs/index.scss
+++ b/src/frontend/apps/drive/src/features/ui/components/breadcrumbs/index.scss
@@ -1,15 +1,55 @@
.c__breadcrumbs {
display: flex;
align-items: center;
- overflow: auto;
+ flex-wrap: nowrap;
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ position: relative;
&__separator {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
+ flex-shrink: 0;
}
- > * {
+ &__item {
+ display: flex;
+ align-items: center;
+ min-width: 0;
flex-shrink: 0;
}
+
+ &__measure {
+ position: fixed;
+ top: -9999px;
+ left: -9999px;
+ display: flex;
+ align-items: center;
+ visibility: hidden;
+ pointer-events: none;
+ white-space: nowrap;
+ }
+
+ &__ellipsis {
+ height: 32px;
+ padding: 4px 8px;
+ background-color: transparent;
+ border: none;
+ border-radius: 4px;
+ color: var(--c--contextuals--content--semantic--neutral--secondary);
+ font-size: 16px;
+ font-family: var(--c--globals--font--families--base);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+
+ &:hover {
+ background-color: var(
+ --c--contextuals--background--semantic--neutral--tertiary
+ );
+ }
+ }
}
.c__breadcrumbs__button {
@@ -27,6 +67,8 @@
align-items: center;
gap: 8px;
text-decoration: none;
+ max-width: 320px;
+ min-width: 0;
&:hover {
background-color: var(
@@ -34,8 +76,19 @@
);
}
- &.active {
+ // Active styling kicks in either when the button itself carries the class
+ // (legacy: BreadcrumbItemButton.isActive) or when its breadcrumb-cell
+ // wrapper does (new: BreadcrumbItem.isActive). Both selectors are needed.
+ &.active,
+ .c__breadcrumbs__item.active & {
font-weight: 600;
color: var(--c--contextuals--content--semantic--neutral--primary);
}
+
+ &__label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+ }
}
diff --git a/src/frontend/apps/e2e/__tests__/app-drive/breadcrumbs-overflow.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/breadcrumbs-overflow.spec.ts
new file mode 100644
index 000000000..ddfb2d99e
--- /dev/null
+++ b/src/frontend/apps/e2e/__tests__/app-drive/breadcrumbs-overflow.spec.ts
@@ -0,0 +1,103 @@
+import test, { expect, Page } from "@playwright/test";
+
+import { clearDb, login } from "./utils-common";
+import { createFolderInCurrentFolder } from "./utils-item";
+import { getRowItem } from "./utils-embedded-grid";
+import { expectExplorerBreadcrumbs } from "./utils-explorer";
+import { clickToMyFiles } from "./utils-navigate";
+
+// Navigate into a folder without asserting the breadcrumb chain. The shared
+// `navigateToFolder` helper calls `expectExplorerBreadcrumbs`, which expects
+// every chain item to be a visible button — but with names long enough to
+// saturate the breadcrumb-button max-width, the collapse logic kicks in
+// mid-setup and breaks that count assertion.
+const dblClickIntoFolder = async (page: Page, folderName: string) => {
+ const previousUrl = page.url();
+ const folderRow = await getRowItem(page, folderName);
+ await folderRow.dblclick();
+ await page.waitForURL((url) => url.toString() !== previousUrl);
+};
+
+// Names long enough to saturate the c__breadcrumbs__button max-width (320px),
+// so the chain reliably overflows the breadcrumbs container at the narrow
+// test viewport.
+const longFolderNames = [
+ "A Really Really Quite Extremely Long Folder Name Number 1",
+ "A Really Really Quite Extremely Long Folder Name Number 2",
+ "A Really Really Quite Extremely Long Folder Name Number 3",
+ "A Really Really Quite Extremely Long Folder Name Number 4",
+];
+
+const setupDeepFolderTree = async (page: Page) => {
+ await clearDb();
+ await login(page, "drive@example.com");
+ // Stay in the desktop layout: the app collapses the sidebar behind a
+ // hamburger below ~1024px, and switching back and forth between layouts
+ // mid-test would hide the breadcrumbs entirely. At 1280 the sidebar
+ // (~250px) plus the right panel (~300px) already squeeze the breadcrumbs
+ // container down to ~630px, which is narrower than our chain of long
+ // folder names — so the collapse logic triggers naturally without ever
+ // touching the viewport again.
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.goto("/");
+ await clickToMyFiles(page);
+
+ for (const name of longFolderNames) {
+ await createFolderInCurrentFolder(page, name);
+ await dblClickIntoFolder(page, name);
+ }
+};
+
+test("breadcrumbs collapse middle items into an ellipsis when the container is too narrow", async ({
+ page,
+}) => {
+ await setupDeepFolderTree(page);
+
+ const breadcrumbs = page.getByTestId("explorer-breadcrumbs");
+ const ellipsis = breadcrumbs.getByTestId("breadcrumb-ellipsis");
+ await expect(ellipsis).toBeVisible();
+
+ // Root and the leaf must always remain visible.
+ await expect(breadcrumbs.getByTestId("default-route-button")).toBeVisible();
+ const lastDynamic = breadcrumbs.getByTestId("breadcrumb-button").last();
+ await expect(lastDynamic).toContainText(
+ longFolderNames[longFolderNames.length - 1],
+ );
+
+ // At least one middle item should be collapsed into the ellipsis — i.e.
+ // not present as a button in the visible breadcrumbs container.
+ await expect(
+ breadcrumbs.getByRole("button", { name: longFolderNames[0] }),
+ ).toHaveCount(0);
+});
+
+test("clicking the ellipsis opens a dropdown listing the collapsed items", async ({
+ page,
+}) => {
+ await setupDeepFolderTree(page);
+
+ const breadcrumbs = page.getByTestId("explorer-breadcrumbs");
+ const ellipsis = breadcrumbs.getByTestId("breadcrumb-ellipsis");
+ await expect(ellipsis).toBeVisible();
+ await ellipsis.click();
+
+ // The first long folder is the deepest hidden ancestor and should be in
+ // the dropdown.
+ await expect(
+ page.getByRole("menuitem", { name: longFolderNames[0] }),
+ ).toBeVisible();
+});
+
+test("selecting a collapsed item from the dropdown navigates to that folder", async ({
+ page,
+}) => {
+ await setupDeepFolderTree(page);
+
+ const breadcrumbs = page.getByTestId("explorer-breadcrumbs");
+ await breadcrumbs.getByTestId("breadcrumb-ellipsis").click();
+ await page
+ .getByRole("menuitem", { name: longFolderNames[0] })
+ .click();
+
+ await expectExplorerBreadcrumbs(page, ["My files", longFolderNames[0]]);
+});
diff --git a/src/frontend/apps/e2e/__tests__/app-drive/breadcrumbs-stable-navigation.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/breadcrumbs-stable-navigation.spec.ts
new file mode 100644
index 000000000..39bcdfe6f
--- /dev/null
+++ b/src/frontend/apps/e2e/__tests__/app-drive/breadcrumbs-stable-navigation.spec.ts
@@ -0,0 +1,58 @@
+import test, { expect } from "@playwright/test";
+
+import { clearDb, login } from "./utils-common";
+import { createFolderInCurrentFolder } from "./utils-item";
+import { expectExplorerBreadcrumbs } from "./utils-explorer";
+import { clickToMyFiles, navigateToFolder } from "./utils-navigate";
+
+test("breadcrumbs keep previous chain visible while new breadcrumb fetch is in flight", async ({
+ page,
+}) => {
+ await clearDb();
+ await login(page, "drive@example.com");
+ await page.goto("/");
+ await clickToMyFiles(page);
+
+ // Build "My files > L1 > L2 > L3" by walking down through the UI.
+ await createFolderInCurrentFolder(page, "L1");
+ await navigateToFolder(page, "L1", ["My files", "L1"]);
+ await createFolderInCurrentFolder(page, "L2");
+ await navigateToFolder(page, "L2", ["My files", "L1", "L2"]);
+ await createFolderInCurrentFolder(page, "L3");
+ await navigateToFolder(page, "L3", ["My files", "L1", "L2", "L3"]);
+
+ // Reload on L3 to drop the in-memory react-query cache. After the reload
+ // only L3's breadcrumb is fetched and cached — L1 / L2 are uncached, so
+ // navigating to one of them later triggers a real network fetch we can
+ // intercept.
+ const l3Url = page.url();
+ await page.goto(l3Url, { waitUntil: "domcontentloaded" });
+ await expectExplorerBreadcrumbs(page, ["My files", "L1", "L2", "L3"]);
+
+ // Block subsequent breadcrumb responses on a promise we control.
+ let release!: () => void;
+ const blocker = new Promise((resolve) => {
+ release = resolve;
+ });
+ await page.route("**/api/v1.0/items/*/breadcrumb/", async (route) => {
+ await blocker;
+ await route.continue();
+ });
+
+ // Click "L1" in the breadcrumb. URL changes and a fresh breadcrumb fetch is
+ // triggered, but it is held back by our route handler.
+ const breadcrumbs = page.getByTestId("explorer-breadcrumbs");
+ await breadcrumbs.getByTestId("breadcrumb-button").first().click();
+ await page.waitForURL(/\/explorer\/items\/[^/]+/);
+
+ // While the request is paused, the committed snapshot must remain visible.
+ // The empty-middle bug would render ["My files", "L1"] here.
+ await expectExplorerBreadcrumbs(page, ["My files", "L1", "L2", "L3"]);
+
+ // Unblock and assert the atomic swap to L1's chain.
+ release();
+ await expectExplorerBreadcrumbs(page, ["My files", "L1"]);
+ await expect(
+ breadcrumbs.getByTestId("breadcrumb-button"),
+ ).toHaveCount(1);
+});
diff --git a/src/frontend/apps/e2e/__tests__/app-drive/utils-explorer.ts b/src/frontend/apps/e2e/__tests__/app-drive/utils-explorer.ts
index aedff30cc..54664511a 100644
--- a/src/frontend/apps/e2e/__tests__/app-drive/utils-explorer.ts
+++ b/src/frontend/apps/e2e/__tests__/app-drive/utils-explorer.ts
@@ -46,7 +46,12 @@ export const expectDefaultRoute = async (
breadcrumbLabel: string,
route: string,
) => {
- const defaultRouteButton = page.getByTestId("default-route-button");
+ // Scope to the visible breadcrumbs container — the Breadcrumbs measurement
+ // layer is a hidden sibling that renders a duplicate of every test id it
+ // contains, so a page-level lookup would be ambiguous in strict mode.
+ const defaultRouteButton = page
+ .getByTestId("explorer-breadcrumbs")
+ .getByTestId("default-route-button");
await expect(defaultRouteButton).toBeVisible();
await expect(defaultRouteButton).toContainText(breadcrumbLabel);
await page.waitForURL((url) => url.toString().includes(route));
diff --git a/src/frontend/apps/e2e/__tests__/app-drive/utils/move-utils.ts b/src/frontend/apps/e2e/__tests__/app-drive/utils/move-utils.ts
index f3d032536..1bdc1c313 100644
--- a/src/frontend/apps/e2e/__tests__/app-drive/utils/move-utils.ts
+++ b/src/frontend/apps/e2e/__tests__/app-drive/utils/move-utils.ts
@@ -40,7 +40,9 @@ export const searchAndSelectItem = async (
const moveFolderModal = await getMoveFolderModal(page);
await moveFolderModal.getByPlaceholder("Search for a folder").click();
await moveFolderModal.getByPlaceholder("Search for a folder").fill(itemName);
- await expect(moveFolderModal.getByText("Search results")).toBeVisible();
+ await expect(
+ moveFolderModal.getByRole("button", { name: "Search results" })
+ ).toBeVisible();
const folderToSelect = await getRowItem(moveFolderModal, itemName);
await folderToSelect.dblclick();
};
diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock
index 65613dcb8..88fd5c311 100644
--- a/src/frontend/yarn.lock
+++ b/src/frontend/yarn.lock
@@ -707,10 +707,10 @@
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/integration/-/integration-1.0.2.tgz#ed0000f4b738c5a19bb60f5b80a9a2f5d9414234"
integrity sha512-npOotZQSyu6SffHiPP+jQVOkJ3qW2KE2cANhEK92sNLX9uZqQaCqljO5GhzsBmh0lB76fiXnrr9i8SIpnDUSZg==
-"@gouvfr-lasuite/ui-kit@0.20.1":
- version "0.20.1"
- resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.20.1.tgz#9967c84646c8024665775a1c4d567a5139fd625b"
- integrity sha512-E1MBsBAjBpTPXiJLpPFscZ16lwO5SBHizejsREPHE6ISTV6Pg4+++20Y4PKgGhgjKPGWCI64N/zfwaesabVioA==
+"@gouvfr-lasuite/ui-kit@0.20.2":
+ version "0.20.2"
+ resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.20.2.tgz#43b5210bb14d4ac1a5784b6caa4b0860f50dd772"
+ integrity sha512-9r/xgr6Zmec2ighnc/KO2rbiGmhtBKFBiVv2J/1J+/xlnkw/rO6OlRPVsNijP7//FsWzwbomSrf3YbFIiwFUKA==
dependencies:
"@dnd-kit/core" "6.3.1"
"@dnd-kit/modifiers" "9.0.0"
|