From 89e52550997ff5b734c68e035adbcf8dc6fa70be Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Thu, 30 Apr 2026 15:43:17 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B(frontend)=20keep=20breadcrumb?= =?UTF-8?q?=20chain=20stable=20during=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The breadcrumb chain briefly disappeared when navigating between folders: the new item id triggered fresh queries for both the item and its breadcrumb, and while those were in flight the chain was empty and the wrapper short-circuited to null. Two changes keep the previous chain visible until the new one is fully resolved: - Add `placeholderData: previousData` to the breadcrumb query and introduce `useItemWithBreadcrumb`, which fetches the item and its breadcrumb together so the two never go out of sync. - Drop the early `return null` in AppExplorerBreadcrumbs so the wrapper renders even when `item` is momentarily undefined. An e2e test slows down the breadcrumb endpoint and verifies the old chain stays visible until the new fetch resolves. --- .../app-view/AppExplorerBreadcrumbs.tsx | 4 -- .../features/explorer/hooks/useBreadcrumb.tsx | 23 ++++++++ .../breadcrumbs-stable-navigation.spec.ts | 58 +++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/breadcrumbs-stable-navigation.spec.ts 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/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/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); +}); From 1f7e3a609100cef404df18f43f3c5cc29d8f429b Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Thu, 30 Apr 2026 15:43:32 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8(frontend)=20collapse=20breadcrumb?= =?UTF-8?q?s=20when=20they=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long folder chains used to overflow the explorer container or push the action buttons out of view. Breadcrumbs now collapse the middle items into an ellipsis dropdown when the chain doesn't fit, while keeping the root and the current folder always visible. - A hidden measurement layer renders the full chain off-screen so item, separator and ellipsis widths can be computed without affecting layout. A ResizeObserver re-runs the fit calculation when the container resizes. - Each breadcrumb item now carries `label` / `onClick` / `isActive` metadata so the ellipsis dropdown can navigate just like the inline buttons. - Bump @gouvfr-lasuite/ui-kit to 0.20.2 for the DropdownMenu API used by the ellipsis menu. - e2e: a new spec asserts collapse behaviour with long folder names. `expectDefaultRoute` is scoped to the visible breadcrumbs container because the measurement layer duplicates every test id. The move-modal helper now targets the "Search results" button rather than its text node, matching the new ui-kit rendering. --- src/frontend/apps/drive/package.json | 2 +- .../components/app-view/AppExplorer.scss | 4 +- .../EmbeddedExplorerGridBreadcrumbs.tsx | 84 +++--- .../drive/src/features/i18n/translations.json | 9 +- .../ui/components/breadcrumbs/Breadcrumbs.tsx | 251 +++++++++++++++--- .../ui/components/breadcrumbs/index.scss | 59 +++- .../app-drive/breadcrumbs-overflow.spec.ts | 103 +++++++ .../e2e/__tests__/app-drive/utils-explorer.ts | 7 +- .../__tests__/app-drive/utils/move-utils.ts | 4 +- src/frontend/yarn.lock | 8 +- 10 files changed, 457 insertions(+), 74 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/breadcrumbs-overflow.spec.ts 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/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/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/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"