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 && ( - - )} - - {items.map((item, index) => { - return ( - - {index > 0 && ( + chevron_right + +
+ … +
+
+ +
+ {displayBack && ( + + )} + + {visibleCells.map((cell, i) => ( + + {i > 0 && ( chevron_right )} - {React.cloneElement(item.content as ReactElement, { - className: `${ - ( - (item.content as ReactElement).props as { - className?: string; - } - ).className || "" - } ${index === items.length - 1 ? "active" : ""}`, - })} + {cell.kind === "item" ? ( +
+ {cell.item.content} +
+ ) : ( + + )}
- ); - })} -
+ ))} + + + ); +}; + +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 ( + + + ); }; 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"