change stuff for playwright test and remove test for drag and drop compare upload which no longer exists

This commit is contained in:
EthanHealy01
2026-04-21 21:26:19 +01:00
parent d2f28e07ea
commit 04c5fbc590
4 changed files with 326 additions and 350 deletions
@@ -5,8 +5,7 @@ import {
Popover,
ScrollArea,
Text,
Loader,
FileButton,
Loader
} from "@mantine/core";
import AddIcon from "@mui/icons-material/Add";
import { useTranslation } from "react-i18next";
@@ -96,6 +95,8 @@ export interface FileSelectorPickerProps {
/** FileIds to hide from both lists (e.g. the other slot's current selection) */
excludeIds?: string[];
disabled?: boolean;
/** Optional data-testid applied to the trigger box */
testId?: string;
/**
* Called with the stub (for display) and the ready-to-use StirlingFile (for processing).
* Files are NOT added to the workbench — data is loaded inline.
@@ -107,6 +108,7 @@ export function FileSelectorPicker({
placeholder,
excludeIds = [],
disabled = false,
testId,
onSelect,
}: FileSelectorPickerProps) {
const { t } = useTranslation();
@@ -131,6 +133,7 @@ export function FileSelectorPicker({
} | null>(null);
const [hoveredThumbnail, setHoveredThumbnail] = useState<string | null>(null);
const thumbCancelRef = useRef<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { fileStubs: workbenchStubs } = useAllFiles();
const indexedDB = useIndexedDB();
@@ -387,239 +390,249 @@ export function FileSelectorPicker({
.join(" ");
return (
<Popover
opened={isOpen}
onChange={setIsOpen}
onClose={() => {
setIsOpen(false);
setSearchQuery("");
}}
position="bottom-start"
withinPortal
shadow="md"
closeOnClickOutside
clickOutsideEvents={["mousedown", "touchstart"]}
>
<Popover.Target>
<Box
className={triggerClass}
style={{ width: "100%" }}
onClick={() => {
if (!disabled) setIsOpen((o) => !o);
}}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => {
if (!disabled && (e.key === "Enter" || e.key === " "))
setIsOpen((o) => !o);
}}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<Text
size="sm"
c="dimmed"
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
<>
<Popover
opened={isOpen}
onChange={setIsOpen}
onClose={() => {
setIsOpen(false);
setSearchQuery("");
}}
position="bottom-start"
withinPortal
shadow="md"
closeOnClickOutside
clickOutsideEvents={["mousedown", "touchstart"]}
>
<Popover.Target>
<Box
className={triggerClass}
style={{ width: "100%" }}
data-testid={testId}
onClick={() => {
if (!disabled) setIsOpen((o) => !o);
}}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => {
if (!disabled && (e.key === "Enter" || e.key === " "))
setIsOpen((o) => !o);
}}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
{placeholder || t("fileSelectorPicker.placeholder", "Select file")}
</Text>
<AddIcon
style={{
fontSize: 18,
color: "var(--mantine-color-dimmed)",
flexShrink: 0,
}}
/>
</Box>
</Popover.Target>
<Popover.Dropdown className={styles.dropdown}>
<div className={styles.header}>
<div className={styles.tabGroup}>
<div
className={styles.slimTabBar}
role="group"
aria-label={t(
"fileSelectorPicker.tabListLabel",
"Saved files, workbench, upload",
)}
>
<button
type="button"
aria-pressed={activeTab === "saved"}
className={
activeTab === "saved" ? styles.slimTabActive : styles.slimTab
}
onClick={() => handleTabChange("saved")}
>
{t("fileSelectorPicker.tabs.saved", "Saved files")}
</button>
<button
type="button"
aria-pressed={activeTab === "workbench"}
className={
activeTab === "workbench"
? styles.slimTabActive
: styles.slimTab
}
onClick={() => handleTabChange("workbench")}
>
{t("fileSelectorPicker.tabs.workbench", "Workbench")}
</button>
<FileButton
onChange={handleUpload}
accept=".pdf,application/pdf"
disabled={disabled || uploadBusy}
>
{(fbProps) => (
<button
{...fbProps}
type="button"
className={styles.slimTabUpload}
aria-label={t(
"fileSelectorPicker.upload",
"Upload from computer",
)}
title={t(
"fileSelectorPicker.upload",
"Upload from computer",
)}
disabled={disabled || uploadBusy}
>
{uploadBusy ? (
<Loader size={11} />
) : (
t("fileSelectorPicker.tabs.upload", "Upload")
)}
</button>
)}
</FileButton>
</div>
</div>
<div className={styles.sortGroup}>
<button
type="button"
className={[
styles.sortBtn,
sortBy === "date" ? styles.sortBtnActive : "",
].join(" ")}
onClick={() => handleSortChange("date")}
title={
sortDir === "desc"
? t("fileSelectorPicker.sort.dateDesc", "Newest first")
: t("fileSelectorPicker.sort.dateAsc", "Oldest first")
}
>
{t("fileSelectorPicker.sort.dateLabel", "Latest")}
{sortBy === "date" && (
<span className={styles.sortArrow}>
{sortDir === "desc" ? " ↓" : " ↑"}
</span>
)}
</button>
<button
type="button"
className={[
styles.sortBtn,
sortBy === "name" ? styles.sortBtnActive : "",
].join(" ")}
onClick={() => handleSortChange("name")}
title={
sortDir === "asc"
? t("fileSelectorPicker.sort.nameAsc", "A to Z")
: t("fileSelectorPicker.sort.nameDesc", "Z to A")
}
>
{t("fileSelectorPicker.sort.nameLabel", "A–Z")}
{sortBy === "name" && (
<span className={styles.sortArrow}>
{sortDir === "asc" ? " ↑" : " ↓"}
</span>
)}
</button>
</div>
</div>
<div className={styles.searchRow}>
<input
className={styles.searchInput}
type="search"
placeholder={t("fileSelectorPicker.search", "Filter files…")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
<ScrollArea h={260} className={styles.list}>
{savedLoading ? (
<div className={styles.emptyState}>
<Loader size="sm" />
</div>
) : displayStubs.length === 0 ? (
<div className={styles.emptyState}>
<Text size="sm">
{t("fileSelectorPicker.empty", "No files available")}
</Text>
</div>
) : (
displayStubs.map((stub) => {
const meta = buildMeta(stub);
const isItemLoading = loadingId === stub.id;
return (
<button
key={stub.id}
type="button"
className={styles.fileItem}
onClick={() => void loadAndSelect(stub)}
disabled={!!loadingId}
onMouseEnter={(e) =>
setHoveredStub({
rect: e.currentTarget.getBoundingClientRect(),
stub,
})
}
onMouseLeave={() => setHoveredStub(null)}
>
<div className={styles.fileItemContent}>
<span className={styles.fileName} title={stub.name}>
{truncateCenter(stub.name, 48)}
</span>
{meta && <span className={styles.fileMeta}>{meta}</span>}
</div>
{isItemLoading && <Loader size="xs" />}
</button>
);
})
)}
</ScrollArea>
{hoveredStub &&
hoveredThumbnail &&
createPortal(
<div
className="file-sidebar-thumb-tooltip"
<Text
size="sm"
c="dimmed"
style={{
top: hoveredStub.rect.top + hoveredStub.rect.height / 2,
left: hoveredStub.rect.left - 170,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
<img
src={hoveredThumbnail}
alt=""
className="file-sidebar-thumb-img"
/>
</div>,
document.body,
)}
</Popover.Dropdown>
</Popover>
{placeholder ||
t("fileSelectorPicker.placeholder", "Select file")}
</Text>
<AddIcon
style={{
fontSize: 18,
color: "var(--mantine-color-dimmed)",
flexShrink: 0,
}}
/>
</Box>
</Popover.Target>
<Popover.Dropdown className={styles.dropdown}>
<div className={styles.header}>
<div className={styles.tabGroup}>
<div
className={styles.slimTabBar}
role="group"
aria-label={t(
"fileSelectorPicker.tabListLabel",
"Saved files, workbench, upload",
)}
>
<button
type="button"
aria-pressed={activeTab === "saved"}
className={
activeTab === "saved"
? styles.slimTabActive
: styles.slimTab
}
onClick={() => handleTabChange("saved")}
>
{t("fileSelectorPicker.tabs.saved", "Saved files")}
</button>
<button
type="button"
aria-pressed={activeTab === "workbench"}
className={
activeTab === "workbench"
? styles.slimTabActive
: styles.slimTab
}
onClick={() => handleTabChange("workbench")}
>
{t("fileSelectorPicker.tabs.workbench", "Workbench")}
</button>
<button
type="button"
data-testid="file-selector-upload-btn"
className={styles.slimTabUpload}
aria-label={t(
"fileSelectorPicker.upload",
"Upload from computer",
)}
title={t("fileSelectorPicker.upload", "Upload from computer")}
disabled={disabled || uploadBusy}
onClick={() => fileInputRef.current?.click()}
>
{uploadBusy ? (
<Loader size={11} />
) : (
t("fileSelectorPicker.tabs.upload", "Upload")
)}
</button>
</div>
</div>
<div className={styles.sortGroup}>
<button
type="button"
className={[
styles.sortBtn,
sortBy === "date" ? styles.sortBtnActive : "",
].join(" ")}
onClick={() => handleSortChange("date")}
title={
sortDir === "desc"
? t("fileSelectorPicker.sort.dateDesc", "Newest first")
: t("fileSelectorPicker.sort.dateAsc", "Oldest first")
}
>
{t("fileSelectorPicker.sort.dateLabel", "Latest")}
{sortBy === "date" && (
<span className={styles.sortArrow}>
{sortDir === "desc" ? " ↓" : " ↑"}
</span>
)}
</button>
<button
type="button"
className={[
styles.sortBtn,
sortBy === "name" ? styles.sortBtnActive : "",
].join(" ")}
onClick={() => handleSortChange("name")}
title={
sortDir === "asc"
? t("fileSelectorPicker.sort.nameAsc", "A to Z")
: t("fileSelectorPicker.sort.nameDesc", "Z to A")
}
>
{t("fileSelectorPicker.sort.nameLabel", "A–Z")}
{sortBy === "name" && (
<span className={styles.sortArrow}>
{sortDir === "asc" ? " ↑" : " ↓"}
</span>
)}
</button>
</div>
</div>
<div className={styles.searchRow}>
<input
className={styles.searchInput}
type="search"
placeholder={t("fileSelectorPicker.search", "Filter files…")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
<ScrollArea h={260} className={styles.list}>
{savedLoading ? (
<div className={styles.emptyState}>
<Loader size="sm" />
</div>
) : displayStubs.length === 0 ? (
<div className={styles.emptyState}>
<Text size="sm">
{t("fileSelectorPicker.empty", "No files available")}
</Text>
</div>
) : (
displayStubs.map((stub) => {
const meta = buildMeta(stub);
const isItemLoading = loadingId === stub.id;
return (
<button
key={stub.id}
type="button"
className={styles.fileItem}
onClick={() => void loadAndSelect(stub)}
disabled={!!loadingId}
onMouseEnter={(e) =>
setHoveredStub({
rect: e.currentTarget.getBoundingClientRect(),
stub,
})
}
onMouseLeave={() => setHoveredStub(null)}
>
<div className={styles.fileItemContent}>
<span className={styles.fileName} title={stub.name}>
{truncateCenter(stub.name, 48)}
</span>
{meta && <span className={styles.fileMeta}>{meta}</span>}
</div>
{isItemLoading && <Loader size="xs" />}
</button>
);
})
)}
</ScrollArea>
{hoveredStub &&
hoveredThumbnail &&
createPortal(
<div
className="file-sidebar-thumb-tooltip"
style={{
top: hoveredStub.rect.top + hoveredStub.rect.height / 2,
left: hoveredStub.rect.left - 170,
}}
>
<img
src={hoveredThumbnail}
alt=""
className="file-sidebar-thumb-img"
/>
</div>,
document.body,
)}
</Popover.Dropdown>
</Popover>
{/* Hidden file input lives outside the Popover so it is always in the DOM.
Tests can target it directly with setInputFiles without opening the popover. */}
<input
ref={fileInputRef}
type="file"
data-testid={testId ? `${testId}-input` : "file-selector-upload-input"}
accept=".pdf,application/pdf"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0] ?? null;
void handleUpload(file);
e.target.value = "";
}}
/>
</>
);
}
@@ -368,6 +368,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
<>
<div
className="file-sidebar-action-row"
data-testid="files-button"
onClick={() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
openFilesModal();
@@ -78,16 +78,10 @@ async function uploadIntoSlot(
filePath: string,
expectedFilename: string,
) {
await page.locator(`[data-testid="compare-slot-${role}-add"]`).click();
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5000,
});
await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
await page.waitForSelector(".mantine-Modal-overlay", {
state: "hidden",
timeout: 10000,
});
// Set files directly on the hidden input (always in DOM, no popover needed)
await page
.getByTestId(`compare-slot-${role}-add-input`)
.setInputFiles(filePath);
const slot = page.locator(`[data-testid="compare-slot-${role}"]`);
await expect(slot).toHaveAttribute("data-slot-state", "filled", {
@@ -105,39 +99,6 @@ test.describe("Compare tool slot selection", () => {
});
});
test("Original slot fills when a PDF is dropped via the landing dropzone", async ({
page,
}) => {
// Regression guard for the "middle state" bug: the landing dropzone path
// (FileManager's hidden file-input used by the drop-prompt on the empty
// workbench) dispatches ADD_FILES and SET_SELECTED_FILES on a different
// cadence than the modal-based upload. During the window where files.ids
// has the new file but selectedFileIds is still empty, the slot would
// show the placeholder unless the auto-fill falls back to allIds.
await navigateToCompare(page);
const baseSlot = page.locator('[data-testid="compare-slot-base"]');
await expect(baseSlot).toHaveAttribute("data-slot-state", "empty");
// Target the visible landing-prompt file input on the empty Compare tool.
const visibleFileInput = page.locator(
'input[type="file"]:not([data-testid="file-input"])',
);
await visibleFileInput.first().setInputFiles(PDF_A);
// Assert the slot fills within a short window. Without the allIds
// fallback the slot stayed on the placeholder for 4+ seconds in manual
// testing because the SET_SELECTED_FILES dispatch lagged the ADD_FILES
// dispatch by many renders.
await expect(baseSlot).toHaveAttribute("data-slot-state", "filled", {
timeout: 2000,
});
await expect(baseSlot).toHaveAttribute(
"data-slot-filename",
"compare_sample_a.pdf",
);
});
test("uploading a PDF via the Original add button fills the Original slot", async ({
page,
}) => {
@@ -192,85 +153,74 @@ test.describe("Compare tool slot selection", () => {
await expect(compareButton).toBeEnabled();
});
test("picking a stored file from the recent-files modal unions it with the current selection", async ({
test("picking a saved file from the FileSelectorPicker popover fills the comparison slot", async ({
page,
}) => {
// This test specifically guards the handleRecentFileSelect code path in
// FilesModalContext. Setup:
// 1. Upload PDF_A so it lands in IndexedDB storage.
// 2. Reload the page — workbench resets but IndexedDB persists.
// 3. Upload PDF_B fresh so only PDF_B is in the workbench and selected.
// 4. Open the Files modal and pick PDF_A from "Recent".
// Without the fix, step 4 REPLACES the selection with [PDF_A] and the
// Compare Original slot jumps to PDF_A while Edited stays empty. With the
// fix, the selection becomes [PDF_B, PDF_A] and both slots populate.
// Guards the FileSelectorPicker "Saved files" tab flow. Steps:
// 1. Upload PDF_A into the base slot → stored in IndexedDB and slot fills.
// 2. Clear the base slot (X button) so both slots are empty again.
// 3. Upload PDF_B into the base slot.
// 4. Open the comparison slot's FileSelectorPicker and pick PDF_A from
// the Saved files tab.
// Result: base = PDF_B, comparison = PDF_A.
// Step 1 — upload PDF_A so it's persisted in IndexedDB.
await navigateToCompare(page);
// Step 1 — upload PDF_A and confirm base slot fills (also persists to IndexedDB).
await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf");
// Step 2 — reload. Workbench clears but IndexedDB retains PDF_A.
// page.goto reuses the mocked routes and re-applies bypassOnboarding.
await page.goto("/?bypassOnboarding=true");
await page.waitForSelector('[data-tour="tool-button-compare"]', {
timeout: 10000,
});
await navigateToCompare(page);
// Step 2 — clear the base slot via the X button so both slots reset.
await page
.locator('[data-testid="compare-slot-base"]')
.getByRole("button", { name: "Remove file" })
.click();
await expect(
page.locator('[data-testid="compare-slot-base"]'),
).toHaveAttribute("data-slot-state", "empty");
// Step 3 — upload PDF_B fresh. Now workbench = [PDF_B], IndexedDB = [A, B].
// Step 3 — upload PDF_B into base. IndexedDB now has both A and B.
await uploadIntoSlot(page, "base", PDF_B, "compare_sample_b.pdf");
await expect(
page.locator('[data-testid="compare-slot-comparison"]'),
).toHaveAttribute("data-slot-state", "empty");
// Step 4 — open the modal from the sidebar Files button and pick PDF_A
// from the recent list.
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", {
// Step 4 — open the comparison slot's FileSelectorPicker and pick PDF_A
// from the Saved files tab.
await page.locator('[data-testid="compare-slot-comparison-add"]').click();
// Wait for the popover dropdown to open (withinPortal → attaches to body).
await page.waitForSelector('[aria-pressed="true"]', {
state: "visible",
timeout: 5000,
});
// The recent-files list renders an entry per stored file. Click the
// checkbox for PDF_A (NOT the ACTIVE entry for PDF_B). We target the
// label text to disambiguate.
const pdfARow = page
.locator(".mantine-Modal-root")
.locator("text=compare_sample_a.pdf")
.first();
// The list should contain PDF_A (PDF_B is excluded as it's in the base slot).
const pdfARow = page.locator("text=compare_sample_a.pdf").first();
await pdfARow.click();
// Confirm selection via "Open File".
await page.getByRole("button", { name: "Open File" }).click();
await page.waitForSelector(".mantine-Modal-overlay", {
state: "hidden",
timeout: 10000,
});
// Both slots must be populated. Specifically, the Original slot must still
// be PDF_B (the pre-existing selection, not clobbered) and the Edited slot
// must be PDF_A (newly unioned in).
// Comparison slot must fill with PDF_A; base slot stays PDF_B.
const baseSlot = page.locator('[data-testid="compare-slot-base"]');
const comparisonSlot = page.locator(
'[data-testid="compare-slot-comparison"]',
);
await expect(baseSlot).toHaveAttribute("data-slot-state", "filled", {
timeout: 10000,
});
await expect(comparisonSlot).toHaveAttribute("data-slot-state", "filled", {
timeout: 10000,
});
await expect(comparisonSlot).toHaveAttribute(
"data-slot-filename",
"compare_sample_a.pdf",
);
await expect(baseSlot).toHaveAttribute("data-slot-state", "filled");
await expect(baseSlot).toHaveAttribute(
"data-slot-filename",
"compare_sample_b.pdf",
);
const compareButton = page.getByRole("button", { name: "Compare" });
await expect(compareButton).toBeEnabled();
});
test("Clear selected empties both slots, clears the selection banner, and removes files from the workbench", async ({
page,
}) => {
test("Clear selected empties both slots", async ({ page }) => {
await navigateToCompare(page);
await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf");
await uploadIntoSlot(page, "comparison", PDF_B, "compare_sample_b.pdf");
@@ -285,9 +235,17 @@ test.describe("Compare tool slot selection", () => {
// Open the Clear confirmation modal and confirm.
await page.getByRole("button", { name: "Clear selected" }).click();
await page.getByRole("button", { name: "Clear and return" }).click();
// Wait for the confirmation modal to appear before clicking confirm.
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5000,
});
await page
.locator('[role="dialog"]')
.getByRole("button", { name: "Clear Selected" })
.click();
// The tool re-mounts on workbench switch; wait for the empty-state base slot.
// Both slots must be empty after clearing.
await page.waitForSelector(
'[data-testid="compare-slot-base"][data-slot-state="empty"]',
{ timeout: 10000 },
@@ -295,11 +253,5 @@ test.describe("Compare tool slot selection", () => {
await expect(
page.locator('[data-testid="compare-slot-comparison"]'),
).toHaveAttribute("data-slot-state", "empty");
// The "N files selected" / "Selected: X" banner must be gone because the
// files have been removed from the workbench.
await expect(
page.getByText(/\d+\s+files?\s+selected|Selected:\s+/i),
).toHaveCount(0);
});
});
+32 -22
View File
@@ -399,28 +399,38 @@ const Compare = (props: BaseToolProps) => {
const isDisabled = role === "comparison" && !baseSlot;
return (
<FileSelectorPicker
placeholder={
isDisabled
? t("compare.edited.selectBaseFirst", "Select original PDF first")
: t(
role === "base"
? "compare.original.placeholder"
: "compare.edited.placeholder",
role === "base"
? "Select the original PDF"
: "Select the edited PDF",
)
}
excludeIds={
otherSlot ? [otherSlot.stirlingFile.fileId as string] : []
}
disabled={isDisabled}
onSelect={(result: FileSelectorResult) => {
if (role === "base") setBaseSlot(result);
else setCompSlot(result);
}}
/>
<div
data-testid={`compare-slot-${role}`}
data-slot-state="empty"
style={{ width: "100%" }}
>
<FileSelectorPicker
testId={`compare-slot-${role}-add`}
placeholder={
isDisabled
? t(
"compare.edited.selectBaseFirst",
"Select original PDF first",
)
: t(
role === "base"
? "compare.original.placeholder"
: "compare.edited.placeholder",
role === "base"
? "Select the original PDF"
: "Select the edited PDF",
)
}
excludeIds={
otherSlot ? [otherSlot.stirlingFile.fileId as string] : []
}
disabled={isDisabled}
onSelect={(result: FileSelectorResult) => {
if (role === "base") setBaseSlot(result);
else setCompSlot(result);
}}
/>
</div>
);
},
[baseSlot, compSlot, clearSlot, t],