feat: add bulk file actions (#7827)

* fixed styling

* Enabled header checkbox selection

* Changed styling of selection

* Implemented bulk downloading and deleting

* Added delete bulk hook

* Added download bulk hook

* Fix backend to send extension in download single file

* Fix hook to download single file directly

* Added header and selection handling

* Added delete confirmation

* [autofix.ci] apply automated fixes

* Fixed selection with shift

* Show disabled files

* Show disabled files as not clickable

* Changed color of icon when disabled

* Implemented pressed shift handling

* Fixed shift selection and disabled text selection when holding shift

* Created test for bulk selection on files modal

* add test of disabled components in file component

* Fixed files page test to include bulk editing test

* removed ring on focus visible

* Changed delete files having the right select

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Lucas Oliveira 2025-05-13 18:59:10 -03:00 • committed by GitHub
commit a72995c408
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1244 additions and 235 deletions

View file

@ -391,3 +391,390 @@ test(
}
},
);
test(
"should be able to select multiple files with shift-click",
{
tag: ["@release", "@workspace"],
},
async ({ page }) => {
// Generate unique filenames for this test run
const file1 = generateRandomFilename();
const file2 = generateRandomFilename();
const file3 = generateRandomFilename();
const file4 = generateRandomFilename();
const file5 = generateRandomFilename();
// Read the test file content
const testFilePath = path.join(__dirname, "../../assets/test_file.txt");
const fileContent = fs.readFileSync(testFilePath);
await awaitBootstrapTest(page);
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await addLegacyComponents(page);
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("file");
await page.waitForSelector('[data-testid="dataFile"]', {
timeout: 3000,
});
await page
.getByTestId("dataFile")
.first()
.dragTo(page.locator('//*[@id="react-flow-id"]'));
await page.mouse.up();
await page.mouse.down();
await adjustScreenView(page);
// Check if file management button is visible
const fileManagement = await page
.getByTestId("button_open_file_management")
?.isVisible();
if (fileManagement) {
// Open file management modal
await page.getByTestId("button_open_file_management").click();
// Upload 5 files for testing shift-click selection
// Upload file 1
const createFileTransfer = async (
filename: string,
content: string,
type: string,
) => {
return page.evaluateHandle(
(params) => {
const data = new DataTransfer();
const file = new File(
[params.content],
`${params.filename}.${params.type}`,
{ type: params.mimeType },
);
data.items.add(file);
return data;
},
{
filename,
content,
type,
mimeType: type === "txt" ? "text/plain" : "application/json",
},
);
};
// Upload five files
const files = [
{ name: file1, content: "file content 1", type: "txt" },
{ name: file2, content: "file content 2", type: "txt" },
{ name: file3, content: "file content 3", type: "txt" },
{ name: file4, content: "file content 4", type: "txt" },
{ name: file5, content: "file content 5", type: "txt" },
];
for (const file of files) {
const dataTransfer = await createFileTransfer(
file.name,
file.content,
file.type,
);
// Trigger drag events
await page.dispatchEvent(
'[data-testid="drag-files-component"]',
"dragover",
{ dataTransfer },
);
await page.dispatchEvent(
'[data-testid="drag-files-component"]',
"drop",
{ dataTransfer },
);
// Verify file was uploaded
await expect(
page.getByText(`${file.name}.${file.type}`).last(),
).toBeVisible({
timeout: 1000,
});
}
// Unselect all files first
for (const file of files) {
if (
(await page
.getByTestId(`checkbox-${file.name}`)
.last()
.getAttribute("data-state")) === "checked"
) {
await page.getByTestId(`checkbox-${file.name}`).last().click();
}
}
// Test 1: Select first file, then shift-click the third file
// First file
await page.getByTestId(`checkbox-${file1}`).last().click();
// Hold shift and click third file
await page.keyboard.down("Shift");
await page.getByTestId(`checkbox-${file3}`).last().click();
await page.keyboard.up("Shift");
// Verify files 1, 2, and 3 are selected
await expect(
page.getByTestId(`checkbox-${file1}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file2}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file3}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file4}`).last(),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId(`checkbox-${file5}`).last(),
).toHaveAttribute("data-state", "unchecked");
// Test 2: Shift-click to extend selection to file 5
await page.keyboard.down("Shift");
await page.getByTestId(`checkbox-${file5}`).last().click();
await page.keyboard.up("Shift");
// Verify all files are selected
await expect(
page.getByTestId(`checkbox-${file1}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file2}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file3}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file4}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file5}`).last(),
).toHaveAttribute("data-state", "checked");
// Test 3: Unselect a range with shift-click
// First select only file 2
for (const file of files) {
if (
(await page
.getByTestId(`checkbox-${file.name}`)
.last()
.getAttribute("data-state")) === "checked"
) {
await page.getByTestId(`checkbox-${file.name}`).last().click();
}
}
await page.getByTestId(`checkbox-${file2}`).last().click();
// Select file 2 through 4
await page.keyboard.down("Shift");
await page.getByTestId(`checkbox-${file4}`).last().click();
await page.keyboard.up("Shift");
// Verify files 2, 3, and 4 are selected
await expect(
page.getByTestId(`checkbox-${file1}`).last(),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId(`checkbox-${file2}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file3}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file4}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file5}`).last(),
).toHaveAttribute("data-state", "unchecked");
// Now use shift-click on an already selected range to deselect
await page.keyboard.down("Shift");
await page.getByTestId(`checkbox-${file2}`).last().click();
await page.keyboard.up("Shift");
// Verify the range is now deselected
await expect(
page.getByTestId(`checkbox-${file1}`).last(),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId(`checkbox-${file2}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file3}`).last(),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId(`checkbox-${file4}`).last(),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId(`checkbox-${file5}`).last(),
).toHaveAttribute("data-state", "unchecked");
// Close the modal
await page.getByTestId("select-files-modal-button").click();
}
},
);
test(
"should show PNG file as disabled in file component",
{
tag: ["@release", "@workspace"],
},
async ({ page }) => {
// Generate unique filenames for this test run
const pngFileName = generateRandomFilename();
const txtFileName = generateRandomFilename();
// Create PNG content (a simple 1x1 transparent PNG)
const pngFileContent = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
// Read the test file content for text file
const testFilePath = path.join(__dirname, "../../assets/test_file.txt");
const txtFileContent = fs.readFileSync(testFilePath);
// Step 1: First navigate to files page and upload both files
await awaitBootstrapTest(page, { skipModal: true });
// Navigate to My Files page
await page.getByText("My Files").first().click();
// Check if we're on the files page
await page.waitForSelector('[data-testid="mainpage_title"]');
const title = await page.getByTestId("mainpage_title");
expect(await title.textContent()).toContain("My Files");
// Upload the PNG file
const fileChooserPromisePng = page.waitForEvent("filechooser");
await page.getByTestId("upload-file-btn").click();
const fileChooserPng = await fileChooserPromisePng;
await fileChooserPng.setFiles([
{
name: `${pngFileName}.png`,
mimeType: "image/png",
buffer: pngFileContent,
},
]);
// Wait for upload success message
await expect(page.getByText("File uploaded successfully")).toBeVisible();
// Verify PNG file appears in the list
await expect(page.getByText(`${pngFileName}.png`)).toBeVisible();
// Upload the TXT file
const fileChooserPromiseTxt = page.waitForEvent("filechooser");
await page.getByTestId("upload-file-btn").click();
const fileChooserTxt = await fileChooserPromiseTxt;
await fileChooserTxt.setFiles([
{
name: `${txtFileName}.txt`,
mimeType: "text/plain",
buffer: txtFileContent,
},
]);
// Wait for upload success message
await expect(page.getByText("File uploaded successfully")).toBeVisible();
// Verify TXT file appears in the list
await expect(page.getByText(`${txtFileName}.txt`)).toBeVisible();
// Step 2: Create a flow with File component and check if PNG file is disabled
// Navigate to workspace page
await page.getByText("Starter Project").first().click();
await awaitBootstrapTest(page, { skipGoto: true });
// Create a new flow
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await addLegacyComponents(page);
// Add a file component to the flow
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("file");
await page.waitForSelector('[data-testid="dataFile"]', {
timeout: 3000,
});
await page
.getByTestId("dataFile")
.first()
.dragTo(page.locator('//*[@id="react-flow-id"]'));
await page.mouse.up();
await page.mouse.down();
await adjustScreenView(page);
// Open the file management modal
await page.getByTestId("button_open_file_management").click();
console.log(pngFileName);
// Check if the PNG file has the disabled class (greyed out)
await expect(page.getByTestId(`file-item-${pngFileName}`)).toHaveClass(
/pointer-events-none cursor-not-allowed opacity-50/,
);
// Check that the TXT file is not disabled
await expect(page.getByTestId(`file-item-${txtFileName}`)).not.toHaveClass(
/pointer-events-none cursor-not-allowed opacity-50/,
);
// Verify the tooltip for PNG file states it's not supported
await page
.locator(`[data-testid="file-item-${pngFileName}"]`)
.locator("..")
.hover();
await expect(
page.getByText("Type not supported by component"),
).toBeVisible();
// Try to select the PNG file (should not change its state)
await expect(page.getByTestId(`checkbox-${pngFileName}`)).toBeDisabled();
// Verify the PNG file checkbox remains unchecked
await expect(page.getByTestId(`checkbox-${pngFileName}`)).toHaveAttribute(
"data-state",
"unchecked",
);
// Select the TXT file (should work normally)
await page.getByTestId(`checkbox-${txtFileName}`).click();
// Verify the TXT file checkbox becomes checked
await expect(page.getByTestId(`checkbox-${txtFileName}`)).toHaveAttribute(
"data-state",
"checked",
);
// Submit the file selection
await page.getByTestId("select-files-modal-button").click();
// Verify that only the TXT file was selected in the component
await expect(page.getByText(`${txtFileName}.txt`)).toBeVisible();
await expect(page.getByText(`${pngFileName}.png`)).not.toBeVisible();
},
);

View file

@ -318,3 +318,148 @@ test(
}
},
);
test(
"should handle bulk actions for multiple files",
{ tag: ["@release", "@files"] },
async ({ page }) => {
const fileNames = {
txt: generateRandomFilename(),
json: generateRandomFilename(),
py: generateRandomFilename(),
};
const testFiles = [
path.join(__dirname, "../../assets/test-file.txt"),
path.join(__dirname, "../../assets/test-file.json"),
path.join(__dirname, "../../assets/test-file.py"),
];
const fileContents = testFiles.map((file) => fs.readFileSync(file));
await awaitBootstrapTest(page, { skipModal: true });
const firstRunLangflow = await page
.getByTestId("empty-project-description")
.count();
if (firstRunLangflow > 0) {
await addFlowToTestOnEmptyLangflow(page);
}
await page.waitForSelector('[data-testid="mainpage_title"]', {
timeout: 30000,
});
await page.getByText("My Files").first().click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByTestId("upload-file-btn").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles([
{
name: `${fileNames.txt}.txt`,
mimeType: "text/plain",
buffer: fileContents[0],
},
{
name: `${fileNames.json}.json`,
mimeType: "application/json",
buffer: fileContents[1],
},
{
name: `${fileNames.py}.py`,
mimeType: "text/x-python",
buffer: fileContents[2],
},
]);
// Wait for upload success message
const successMessage = await page.getByText("Files uploaded successfully");
expect(successMessage).toBeTruthy();
// Verify all files appear in the list
for (const name of Object.values(fileNames)) {
const file = await page.getByText(name).last();
await expect(file).toBeVisible({
timeout: 1000,
});
}
// Select files with shift (checkbox on the grid)
await page.keyboard.down("Shift");
await page.locator('input[data-ref="eInput"]').nth(5).click();
await page.locator('input[data-ref="eInput"]').nth(7).click();
await page.keyboard.up("Shift");
expect(
await page.locator('input[data-ref="eInput"]').nth(5).isChecked(),
).toBe(true);
expect(
await page.locator('input[data-ref="eInput"]').nth(6).isChecked(),
).toBe(true);
expect(
await page.locator('input[data-ref="eInput"]').nth(7).isChecked(),
).toBe(true);
// Check if the bulk actions toolbar appears
const selectedCountText = await page.getByText("3 selected");
await expect(selectedCountText).toBeVisible();
// Check if download button is visible
const downloadButton = await page.getByTestId("bulk-download-btn");
await expect(downloadButton).toBeVisible();
// Set up download listener
const downloadPromise = page.waitForEvent("download");
// Click download button
await downloadButton.click();
// Wait for download to start
const download = await downloadPromise;
// Verify the download was initiated
expect(download).toBeTruthy();
// Check for success message
const downloadSuccessMessage = await page.getByText(
/Files? downloaded successfully/,
);
expect(downloadSuccessMessage).toBeTruthy();
// Select both files (checkbox on the grid)
await page.locator('input[data-ref="eInput"]').nth(7).click();
// Check if the bulk actions toolbar appears
const selectedCountTextDelete = await page.getByText("2 selected");
await expect(selectedCountTextDelete).toBeVisible();
const deleteButton = await page.getByTestId("bulk-delete-btn");
await expect(deleteButton).toBeVisible();
// Test delete functionality
await deleteButton.click();
// Confirm the delete in the modal
const confirmDeleteButton = await page.getByRole("button", {
name: "Delete",
});
await confirmDeleteButton.click();
// Check for success message
const deleteSuccessMessage = await page.getByText(
"Files deleted successfully",
);
expect(deleteSuccessMessage).toBeTruthy();
// Verify the deleted files are no longer visible
const remainingFileCount =
(await page.getByText(fileNames.py + ".py").count()) +
(await page.getByText(fileNames.txt + ".txt").count()) +
(await page.getByText(fileNames.json + ".json").count());
expect(remainingFileCount).toBe(1);
},
);