state.flows);
const examples = useFlowsManagerStore((state) => state.examples);
- const handleFileDrop = useFileDrop("flow");
const setSuccessData = useAlertStore((state) => state.setSuccessData);
const setErrorData = useAlertStore((state) => state.setErrorData);
const folderToEdit = useFolderStore((state) => state.folderToEdit);
@@ -70,6 +67,9 @@ export default function CollectionPage(): JSX.Element {
setFolderToEdit(item);
setOpenDeleteFolderModal(true);
}}
+ handleFilesClick={() => {
+ navigate("files");
+ }}
/>
)}
@@ -77,16 +77,11 @@ export default function CollectionPage(): JSX.Element {
-
- {flows?.length !== examples?.length || folders?.length > 1 ? (
-
- ) : (
-
- )}
-
+ {flows?.length !== examples?.length || folders?.length > 1 ? (
+
+ ) : (
+
+ )}
) : (
diff --git a/src/frontend/src/pages/MainPage/utils/sort-flows.ts b/src/frontend/src/pages/MainPage/utils/sort-flows.ts
index 1b8825102..3b0fea193 100644
--- a/src/frontend/src/pages/MainPage/utils/sort-flows.ts
+++ b/src/frontend/src/pages/MainPage/utils/sort-flows.ts
@@ -1,19 +1,11 @@
export const sortFlows = (flows, type) => {
const isComponent = type === "component";
- const sortByDate = (a, b) => {
+ const sortByDateFn = (a, b) => {
const dateA = a?.updated_at || a?.date_created;
const dateB = b?.updated_at || b?.date_created;
- if (dateA && dateB) {
- return new Date(dateB).getTime() - new Date(dateA).getTime();
- } else if (dateA) {
- return 1;
- } else if (dateB) {
- return -1;
- } else {
- return 0;
- }
+ return sortByDate(dateA, dateB);
};
const filteredFlows =
@@ -21,5 +13,29 @@ export const sortFlows = (flows, type) => {
? flows
: flows?.filter((f) => (f?.is_component ?? false) === isComponent);
- return filteredFlows?.sort(sortByDate) ?? [];
+ return filteredFlows?.sort(sortByDateFn) ?? [];
+};
+
+export const sortByDate = (dateA: string, dateB: string) => {
+ if (dateA && dateB) {
+ return new Date(dateB).getTime() - new Date(dateA).getTime();
+ } else if (dateA) {
+ return 1;
+ } else if (dateB) {
+ return -1;
+ } else {
+ return 0;
+ }
+};
+
+export const sortByBoolean = (a: boolean, b: boolean) => {
+ if (a && b) {
+ return 0;
+ } else if (a && !b) {
+ return -1;
+ } else if (!a && b) {
+ return 1;
+ } else {
+ return 0;
+ }
};
diff --git a/src/frontend/src/routes.tsx b/src/frontend/src/routes.tsx
index 801ceed5e..0659310eb 100644
--- a/src/frontend/src/routes.tsx
+++ b/src/frontend/src/routes.tsx
@@ -13,7 +13,10 @@ import { StoreGuard } from "./components/authorization/storeGuard";
import ContextWrapper from "./contexts";
import { CustomNavigate } from "./customization/components/custom-navigate";
import { BASENAME } from "./customization/config-constants";
-import { ENABLE_CUSTOM_PARAM } from "./customization/feature-flags";
+import {
+ ENABLE_CUSTOM_PARAM,
+ ENABLE_FILE_MANAGEMENT,
+} from "./customization/feature-flags";
import { AppAuthenticatedPage } from "./pages/AppAuthenticatedPage";
import { AppInitPage } from "./pages/AppInitPage";
import { AppWrapperPage } from "./pages/AppWrapperPage";
@@ -21,6 +24,7 @@ import { DashboardWrapperPage } from "./pages/DashboardWrapperPage";
import FlowPage from "./pages/FlowPage";
import LoginPage from "./pages/LoginPage";
import CollectionPage from "./pages/MainPage/pages";
+import FilesPage from "./pages/MainPage/pages/filesPage";
import HomePage from "./pages/MainPage/pages/homePage";
import SettingsPage from "./pages/SettingsPage";
import ApiKeysPage from "./pages/SettingsPage/pages/ApiKeysPage";
@@ -76,6 +80,9 @@ const router = createBrowserRouter(
index
element={}
/>
+ {ENABLE_FILE_MANAGEMENT && (
+ } />
+ )}
}
diff --git a/src/frontend/src/shared/hooks/use-file-size-validator.ts b/src/frontend/src/shared/hooks/use-file-size-validator.ts
index 502ac5602..34dfb6232 100644
--- a/src/frontend/src/shared/hooks/use-file-size-validator.ts
+++ b/src/frontend/src/shared/hooks/use-file-size-validator.ts
@@ -1,17 +1,14 @@
import { INVALID_FILE_SIZE_ALERT } from "@/constants/alerts_constants";
import { useUtilityStore } from "@/stores/utilityStore";
-
-const useFileSizeValidator = (
- setErrorData: (newState: { title: string; list?: Array }) => void,
-) => {
+import { formatFileSize } from "@/utils/stringManipulation";
+const useFileSizeValidator = () => {
const maxFileSizeUpload = useUtilityStore((state) => state.maxFileSizeUpload);
const validateFileSize = (file) => {
if (file.size > maxFileSizeUpload) {
- setErrorData({
- title: INVALID_FILE_SIZE_ALERT(maxFileSizeUpload / 1024 / 1024),
- });
- return false;
+ throw new Error(
+ INVALID_FILE_SIZE_ALERT(formatFileSize(maxFileSizeUpload)),
+ );
}
return true;
};
diff --git a/src/frontend/src/style/ag-theme-shadcn.css b/src/frontend/src/style/ag-theme-shadcn.css
index 2c2b1f553..1e83eb1b0 100644
--- a/src/frontend/src/style/ag-theme-shadcn.css
+++ b/src/frontend/src/style/ag-theme-shadcn.css
@@ -12,7 +12,7 @@
--ag-selected-row-background-color: hsl(var(--accent)) !important;
--ag-menu-background-color: hsl(var(--accent)) !important;
--ag-panel-background-color: hsl(var(--accent)) !important;
- --ag-row-hover-color: hsl(var(--primary-foreground)) !important;
+ --ag-row-hover-color: hsl(var(--accent)) !important;
--ag-header-height: 2.5rem !important;
}
@@ -78,3 +78,24 @@
.ag-row {
cursor: pointer;
}
+
+.ag-no-border .ag-root-wrapper {
+ border: none !important;
+}
+.ag-no-border .ag-row {
+ border-bottom: none !important;
+}
+
+.ag-no-border .ag-header {
+ margin-bottom: 0.6rem !important;
+}
+
+.ag-no-border .ag-paging-panel {
+ border-top: none !important;
+}
+
+.ag-no-border .ag-cell-focus:not(.ag-cell-inline-editing) {
+ border: 1px solid transparent !important;
+ box-shadow: none !important;
+ outline: none !important;
+}
diff --git a/src/frontend/src/types/api/index.ts b/src/frontend/src/types/api/index.ts
index 98de61306..e3ec2b5bf 100644
--- a/src/frontend/src/types/api/index.ts
+++ b/src/frontend/src/types/api/index.ts
@@ -90,6 +90,7 @@ export type InputFieldType = {
[key: string]: any;
icon?: string;
text?: string;
+ temp_file?: boolean;
};
export type OutputFieldProxyType = {
diff --git a/src/frontend/src/types/file_management/index.ts b/src/frontend/src/types/file_management/index.ts
new file mode 100644
index 000000000..8316ef341
--- /dev/null
+++ b/src/frontend/src/types/file_management/index.ts
@@ -0,0 +1,13 @@
+export type FileType = {
+ id: string;
+ user_id: string;
+ provider: string;
+ name: string;
+ updated_at?: string;
+ path: string;
+ created_at: string;
+ size: number;
+ progress?: number;
+ file?: File;
+ type?: string;
+};
diff --git a/src/frontend/src/utils/stringManipulation.ts b/src/frontend/src/utils/stringManipulation.ts
index 115fee42f..70c37c89b 100644
--- a/src/frontend/src/utils/stringManipulation.ts
+++ b/src/frontend/src/utils/stringManipulation.ts
@@ -137,6 +137,16 @@ export const getStatusColor = (status: string): string => {
return "";
};
+export const formatFileSize = (bytes: number): string => {
+ if (bytes === 0) return "0 Bytes";
+
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
+};
+
export const convertStringToHTML = (htmlString: string): JSX.Element => {
return React.createElement("span", {
dangerouslySetInnerHTML: { __html: sanitizeHTML(htmlString) },
diff --git a/src/frontend/src/utils/styleUtils.ts b/src/frontend/src/utils/styleUtils.ts
index 43f99a801..2cc360ba4 100644
--- a/src/frontend/src/utils/styleUtils.ts
+++ b/src/frontend/src/utils/styleUtils.ts
@@ -1,11 +1,15 @@
import { AIMLIcon } from "@/icons/AIML";
+import { AWSInvertedIcon } from "@/icons/AWSInverted";
import { BWPythonIcon } from "@/icons/BW python";
+import { DropboxIcon } from "@/icons/Dropbox";
import { DuckDuckGoIcon } from "@/icons/DuckDuckGo";
import { ExaIcon } from "@/icons/Exa";
import { GleanIcon } from "@/icons/Glean";
+import { GoogleDriveIcon } from "@/icons/GoogleDrive";
import { JSIcon } from "@/icons/JSicon";
import { LangwatchIcon } from "@/icons/Langwatch";
import { MilvusIcon } from "@/icons/Milvus";
+import { OneDriveIcon } from "@/icons/OneDrive";
import Perplexity from "@/icons/Perplexity/Perplexity";
import { SearchAPIIcon } from "@/icons/SearchAPI";
import { SerpSearchIcon } from "@/icons/SerpSearch";
@@ -59,6 +63,7 @@ import {
CircleDot,
CircleOff,
Clipboard,
+ CloudDownload,
Code,
Code2,
CodeXml,
@@ -80,19 +85,24 @@ import {
DownloadCloud,
Edit,
Ellipsis,
+ EllipsisVertical,
Eraser,
ExternalLink,
Eye,
EyeOff,
File,
+ FileChartColumn,
FileClock,
FileCode2,
FileDown,
+ FileJson,
+ FilePen,
FileQuestion,
FileSearch,
FileSearch2,
FileSliders,
FileText,
+ FileType,
FileType2,
FileUp,
Filter,
@@ -131,6 +141,7 @@ import {
ListChecks,
ListFilter,
ListOrdered,
+ ListX,
Loader2,
Lock,
LockOpen,
@@ -179,6 +190,7 @@ import {
RefreshCcw,
RefreshCcwDot,
Repeat,
+ Replace,
RotateCcw,
Save,
SaveAll,
@@ -502,6 +514,25 @@ export const nodeColorsName: { [char: string]: string } = {
DataFrame: "pink",
};
+export const FILE_ICONS = {
+ json: {
+ icon: "FileJson",
+ color: "text-datatype-indigo dark:text-datatype-indigo-foreground",
+ },
+ csv: {
+ icon: "FileChartColumn",
+ color: "text-datatype-emerald dark:text-datatype-emerald-foreground",
+ },
+ txt: {
+ icon: "FileType",
+ color: "text-datatype-purple dark:text-datatype-purple-foreground",
+ },
+ pdf: {
+ icon: "File",
+ color: "text-datatype-red dark:text-datatype-red-foreground",
+ },
+};
+
export const SIDEBAR_CATEGORIES = [
{ display_name: "Saved", name: "saved_components", icon: "GradientSave" },
{ display_name: "Inputs", name: "inputs", icon: "Download" },
@@ -647,6 +678,7 @@ export const nodeIconsLucide: iconsType = {
AirbyteJSONLoader: AirbyteIcon,
AmazonBedrockEmbeddings: AWSIcon,
Amazon: AWSIcon,
+ AWSInverted: AWSInvertedIcon,
Anthropic: AnthropicIcon,
ArXiv: ArXivIcon,
ChatAnthropic: AnthropicIcon,
@@ -656,6 +688,7 @@ export const nodeIconsLucide: iconsType = {
AstraDB: AstraDBIcon,
BingSearchAPIWrapper: BingIcon,
BingSearchRun: BingIcon,
+ CloudDownload,
Olivya: OlivyaIcon,
Bing: BingIcon,
Cohere: CohereIcon,
@@ -788,6 +821,10 @@ export const nodeIconsLucide: iconsType = {
XCircle,
Info,
CheckCircle2,
+ FileJson,
+ FileChartColumn,
+ FileType,
+ File,
SquarePen,
Zap,
MessagesSquare,
@@ -802,6 +839,9 @@ export const nodeIconsLucide: iconsType = {
AlertTriangle,
ChevronLeft,
SlidersHorizontal,
+ GoogleDrive: GoogleDriveIcon,
+ OneDrive: OneDriveIcon,
+ Dropbox: DropboxIcon,
Palette,
RefreshCcwDot,
FolderUp,
@@ -841,9 +881,11 @@ export const nodeIconsLucide: iconsType = {
Snowflake,
Store,
Download,
+ Replace,
Eraser,
Lock,
LockOpen,
+ ListX,
Newspaper,
Tags,
CodeXml,
@@ -851,11 +893,11 @@ export const nodeIconsLucide: iconsType = {
LucideSend,
Sparkles,
DownloadCloud,
- File,
FileText,
FolderPlus,
GitFork,
FileDown,
+ FilePen,
FileUp,
Menu,
Save,
@@ -925,6 +967,7 @@ export const nodeIconsLucide: iconsType = {
FlaskConical,
AlertCircle,
Bot,
+ EllipsisVertical,
Delete,
Command,
ArrowBigUp,
diff --git a/src/frontend/tests/assets/test-file.json b/src/frontend/tests/assets/test-file.json
new file mode 100644
index 000000000..cf6f1a1ef
--- /dev/null
+++ b/src/frontend/tests/assets/test-file.json
@@ -0,0 +1,5 @@
+{
+ "name": "Test JSON File",
+ "type": "json",
+ "purpose": "testing"
+}
diff --git a/src/frontend/tests/assets/test-file.py b/src/frontend/tests/assets/test-file.py
new file mode 100644
index 000000000..c6438b899
--- /dev/null
+++ b/src/frontend/tests/assets/test-file.py
@@ -0,0 +1,3 @@
+def test_function():
+ print("This is a test Python file")
+ return True
diff --git a/src/frontend/tests/assets/test-file.txt b/src/frontend/tests/assets/test-file.txt
new file mode 100644
index 000000000..41ac13afa
--- /dev/null
+++ b/src/frontend/tests/assets/test-file.txt
@@ -0,0 +1 @@
+This is a test file for upload functionality testing.
\ No newline at end of file
diff --git a/src/frontend/tests/core/integrations/Document QA.spec.ts b/src/frontend/tests/core/integrations/Document QA.spec.ts
index 12521bf34..c267a5588 100644
--- a/src/frontend/tests/core/integrations/Document QA.spec.ts
+++ b/src/frontend/tests/core/integrations/Document QA.spec.ts
@@ -3,6 +3,7 @@ import * as dotenv from "dotenv";
import path from "path";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
import { initialGPTsetup } from "../../utils/initialGPTsetup";
+import { uploadFile } from "../../utils/upload-file";
import { withEventDeliveryModes } from "../../utils/withEventDeliveryModes";
withEventDeliveryModes(
@@ -24,13 +25,7 @@ withEventDeliveryModes(
await page.getByRole("heading", { name: "Document Q&A" }).click();
await initialGPTsetup(page);
- const fileChooserPromise = page.waitForEvent("filechooser");
- await page.getByTestId("button_upload_file").click();
- const fileChooser = await fileChooserPromise;
- await fileChooser.setFiles(
- path.join(__dirname, "../../assets/test_file.txt"),
- );
- await page.getByText("test_file.txt").isVisible();
+ await uploadFile(page, "test_file.txt");
await page.waitForSelector('[data-testid="button_run_chat output"]', {
timeout: 3000,
diff --git a/src/frontend/tests/core/integrations/Meeting Summary.spec.ts b/src/frontend/tests/core/integrations/Meeting Summary.spec.ts
index afc048359..b76b5f4e2 100644
--- a/src/frontend/tests/core/integrations/Meeting Summary.spec.ts
+++ b/src/frontend/tests/core/integrations/Meeting Summary.spec.ts
@@ -1,11 +1,9 @@
import { expect, test } from "@playwright/test";
import * as dotenv from "dotenv";
-import { readFileSync } from "fs";
import path from "path";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
-import { getAllResponseMessage } from "../../utils/get-all-response-message";
import { initialGPTsetup } from "../../utils/initialGPTsetup";
-import { waitForOpenModalWithChatInput } from "../../utils/wait-for-open-modal";
+import { uploadFile } from "../../utils/upload-file";
import { withEventDeliveryModes } from "../../utils/withEventDeliveryModes";
withEventDeliveryModes(
@@ -48,18 +46,7 @@ withEventDeliveryModes(
.nth(3)
.fill(process.env.ASSEMBLYAI_API_KEY ?? "");
- const audioFilePath = path.join(
- __dirname,
- "../../assets/test_audio_file.wav",
- );
- await page.getByTestId("button_upload_file").click();
-
- const fileChooserPromise = page.waitForEvent("filechooser");
- await page.getByTestId("button_upload_file").click();
- const fileChooser = await fileChooserPromise;
- await fileChooser.setFiles(audioFilePath);
-
- await page.waitForTimeout(2000);
+ await uploadFile(page, "test_audio_file.wav");
await page.getByTestId("button_run_chat output").last().click();
diff --git a/src/frontend/tests/core/integrations/Portfolio Website Code Generator.spec.ts b/src/frontend/tests/core/integrations/Portfolio Website Code Generator.spec.ts
index 458e8afa9..e0187472a 100644
--- a/src/frontend/tests/core/integrations/Portfolio Website Code Generator.spec.ts
+++ b/src/frontend/tests/core/integrations/Portfolio Website Code Generator.spec.ts
@@ -1,9 +1,9 @@
import { expect, test } from "@playwright/test";
import * as dotenv from "dotenv";
-import { readFileSync } from "fs";
import path from "path";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
import { initialGPTsetup } from "../../utils/initialGPTsetup";
+import { uploadFile } from "../../utils/upload-file";
import { withEventDeliveryModes } from "../../utils/withEventDeliveryModes";
withEventDeliveryModes(
@@ -46,15 +46,7 @@ withEventDeliveryModes(
.first()
.fill(process.env.ANTHROPIC_API_KEY ?? "");
- const filePath = path.join(__dirname, "../../assets/test_file.txt");
- await page.getByTestId("button_upload_file").click();
-
- const fileChooserPromise = page.waitForEvent("filechooser");
- await page.getByTestId("button_upload_file").click();
- const fileChooser = await fileChooserPromise;
- await fileChooser.setFiles(filePath);
-
- await page.waitForTimeout(2000);
+ await uploadFile(page, "test_file.txt");
await page.getByTestId("playground-btn-flow-io").click();
diff --git a/src/frontend/tests/core/unit/fileUploadComponent.spec.ts b/src/frontend/tests/core/unit/fileUploadComponent.spec.ts
index d21c24845..e301869eb 100644
--- a/src/frontend/tests/core/unit/fileUploadComponent.spec.ts
+++ b/src/frontend/tests/core/unit/fileUploadComponent.spec.ts
@@ -1,7 +1,9 @@
import { expect, test } from "@playwright/test";
+import fs from "fs";
import path from "path";
import { adjustScreenView } from "../../utils/adjust-screen-view";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
+import { generateRandomFilename } from "../../utils/generate-filename";
test(
"should be able to upload a file",
@@ -9,6 +11,17 @@ test(
tag: ["@release", "@workspace"],
},
async ({ page }) => {
+ // Generate unique filenames for this test run
+ const sourceFileName = generateRandomFilename();
+ const jsonFileName = generateRandomFilename();
+ const renamedJsonFile = generateRandomFilename();
+ const renamedTxtFile = generateRandomFilename();
+ const newTxtFile = 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"]', {
@@ -30,13 +43,199 @@ test(
await page.mouse.up();
await page.mouse.down();
await adjustScreenView(page);
- const fileChooserPromise = page.waitForEvent("filechooser");
- await page.getByTestId("button_upload_file").click();
- const fileChooser = await fileChooserPromise;
- await fileChooser.setFiles(
- path.join(__dirname, "../../assets/test_file.txt"),
- );
- await page.getByText("test_file.txt").isVisible();
+ const fileManagement = await page
+ .getByTestId("button_open_file_management")
+ ?.isVisible();
+ if (fileManagement) {
+ // Test upload file
+ await page.getByTestId("button_open_file_management").click();
+ const drag = await page.getByTestId("drag-files-component");
+ const fileChooserPromise = page.waitForEvent("filechooser");
+ await drag.click();
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles([
+ {
+ name: `${sourceFileName}.txt`,
+ mimeType: "text/plain",
+ buffer: fileContent,
+ },
+ ]);
+
+ await expect(page.getByText(`${sourceFileName}.txt`).last()).toBeVisible({
+ timeout: 1000,
+ });
+
+ await expect(
+ page.getByTestId(`checkbox-${sourceFileName}`).last(),
+ ).toHaveAttribute("data-state", "checked", { timeout: 1000 });
+
+ // Create DataTransfer object and file
+ const dataTransfer = await page.evaluateHandle((jsonFileName) => {
+ const data = new DataTransfer();
+ const file = new File(
+ ['{ "test": "content" }'],
+ `${jsonFileName}.json`,
+ {
+ type: "application/json",
+ },
+ );
+ data.items.add(file);
+ return data;
+ }, jsonFileName);
+
+ // Trigger drag events
+ await page.dispatchEvent(
+ '[data-testid="drag-files-component"]',
+ "dragover",
+ {
+ dataTransfer,
+ },
+ );
+ await page.dispatchEvent('[data-testid="drag-files-component"]', "drop", {
+ dataTransfer,
+ });
+
+ await expect(page.getByText(`${jsonFileName}.json`).last()).toBeVisible({
+ timeout: 1000,
+ });
+
+ await expect(
+ page.getByTestId(`checkbox-${sourceFileName}`).last(),
+ ).toHaveAttribute("data-state", "checked", { timeout: 1000 });
+
+ // Test checkbox
+
+ await expect(
+ page.getByTestId(`checkbox-${sourceFileName}`).last(),
+ ).toHaveAttribute("data-state", "checked");
+ await expect(
+ page.getByTestId(`checkbox-${jsonFileName}`).last(),
+ ).toHaveAttribute("data-state", "checked");
+ await page.getByTestId(`checkbox-${sourceFileName}`).last().click();
+ await page.getByTestId(`checkbox-${jsonFileName}`).last().click();
+
+ await expect(
+ page.getByTestId(`checkbox-${sourceFileName}`).last(),
+ ).toHaveAttribute("data-state", "unchecked");
+ await expect(
+ page.getByTestId(`checkbox-${jsonFileName}`).last(),
+ ).toHaveAttribute("data-state", "unchecked");
+
+ // Test search
+
+ await page.getByTestId("search-files-input").fill(jsonFileName);
+ await expect(page.getByText(`${jsonFileName}.json`).first()).toBeVisible({
+ timeout: 1000,
+ });
+ await expect(page.getByText(`${sourceFileName}.txt`).first()).toBeHidden({
+ timeout: 1000,
+ });
+
+ await page.getByTestId("search-files-input").fill(sourceFileName);
+ await expect(page.getByText(`${sourceFileName}.txt`).first()).toBeVisible(
+ {
+ timeout: 1000,
+ },
+ );
+ await expect(page.getByText(`${jsonFileName}.json`).first()).toBeHidden({
+ timeout: 1000,
+ });
+
+ await page.getByTestId("search-files-input").fill("txt");
+ await expect(page.getByText(`${sourceFileName}.txt`).first()).toBeVisible(
+ {
+ timeout: 1000,
+ },
+ );
+ await expect(page.getByText(`${jsonFileName}.json`).first()).toBeHidden({
+ timeout: 1000,
+ });
+
+ await page.getByTestId("search-files-input").fill("json");
+ await expect(page.getByText(`${jsonFileName}.json`).first()).toBeVisible({
+ timeout: 1000,
+ });
+ await expect(page.getByText(`${sourceFileName}.txt`).first()).toBeHidden({
+ timeout: 1000,
+ });
+
+ await page.getByTestId("search-files-input").fill("");
+ await expect(page.getByText(`${sourceFileName}.txt`).first()).toBeVisible(
+ {
+ timeout: 1000,
+ },
+ );
+ await expect(page.getByText(`${jsonFileName}.json`).first()).toBeVisible({
+ timeout: 1000,
+ });
+
+ await page
+ .getByText(`${jsonFileName}.json`)
+ .first()
+ .click({ clickCount: 2 });
+ await page
+ .getByTestId(`rename-input-${jsonFileName}`)
+ .fill(renamedJsonFile);
+ await page.getByTestId(`rename-input-${jsonFileName}`).blur();
+ await expect(
+ page.getByText(`${renamedJsonFile}.json`).first(),
+ ).toBeVisible({
+ timeout: 1000,
+ });
+ await expect(page.getByText(`${jsonFileName}.json`).first()).toBeHidden({
+ timeout: 1000,
+ });
+
+ await page.getByTestId(`context-menu-button-${sourceFileName}`).click();
+ await page.getByTestId("btn-rename-file").click();
+ await page
+ .getByTestId(`rename-input-${sourceFileName}`)
+ .fill(renamedTxtFile);
+ await page.getByTestId(`rename-input-${sourceFileName}`).blur();
+ await expect(page.getByText(`${renamedTxtFile}.txt`).first()).toBeVisible(
+ {
+ timeout: 1000,
+ },
+ );
+ await expect(page.getByText(`${sourceFileName}.txt`).first()).toBeHidden({
+ timeout: 1000,
+ });
+
+ await page.getByTestId(`checkbox-${renamedTxtFile}`).last().click();
+ await page.getByTestId(`checkbox-${renamedJsonFile}`).last().click();
+
+ await expect(
+ page.getByTestId(`checkbox-${renamedTxtFile}`).last(),
+ ).toHaveAttribute("data-state", "checked");
+ await expect(
+ page.getByTestId(`checkbox-${renamedJsonFile}`).last(),
+ ).toHaveAttribute("data-state", "checked");
+
+ await page.getByTestId("select-files-modal-button").click();
+
+ await expect(page.getByText(`${renamedTxtFile}.txt`).first()).toBeVisible(
+ {
+ timeout: 1000,
+ },
+ );
+ await expect(
+ page.getByText(`${renamedJsonFile}.json`).first(),
+ ).toBeVisible({
+ timeout: 1000,
+ });
+ } else {
+ const fileChooserPromise = page.waitForEvent("filechooser");
+ await page.getByTestId("button_upload_file").click();
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles([
+ {
+ name: `${sourceFileName}.txt`,
+ mimeType: "text/plain",
+ buffer: fileContent,
+ },
+ ]);
+ await page.getByText(`${sourceFileName}.txt`).isVisible();
+ }
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("chat output");
@@ -114,5 +313,80 @@ test(
await expect(page.getByText("this is a test file")).toBeVisible({
timeout: 3000,
});
+
+ if (fileManagement) {
+ await expect(page.getByText('{"test":"content"}')).toBeVisible({
+ timeout: 3000,
+ });
+ await page.getByText("Close", { exact: true }).last().click();
+ await page.getByTestId("button_open_file_management").click();
+ await page.getByTestId(`context-menu-button-${renamedJsonFile}`).click();
+ await page.getByTestId("btn-delete-file").click();
+ await page.getByTestId("replace-button").click();
+ await expect(page.getByText(`${renamedJsonFile}.txt`).first()).toBeHidden(
+ {
+ timeout: 1000,
+ },
+ );
+
+ const dataTransfer = await page.evaluateHandle((newTxtFile) => {
+ const data = new DataTransfer();
+ const file = new File(["this is a new test"], `${newTxtFile}.txt`, {
+ type: "text/plain",
+ });
+ data.items.add(file);
+ return data;
+ }, newTxtFile);
+
+ // Trigger drag events
+ await page.dispatchEvent(
+ '[data-testid="drag-files-component"]',
+ "dragover",
+ {
+ dataTransfer,
+ },
+ );
+ await page.dispatchEvent('[data-testid="drag-files-component"]', "drop", {
+ dataTransfer,
+ });
+
+ await expect(page.getByText(`${newTxtFile}.txt`).last()).toBeVisible({
+ timeout: 1000,
+ });
+
+ await expect(
+ page.getByTestId(`checkbox-${newTxtFile}`).last(),
+ ).toHaveAttribute("data-state", "checked", { timeout: 1000 });
+
+ await page.getByTestId("select-files-modal-button").click();
+ await expect(page.getByText(`${renamedJsonFile}.txt`).first()).toBeHidden(
+ {
+ timeout: 1000,
+ },
+ );
+ await expect(page.getByText(`${newTxtFile}.txt`).first()).toBeVisible({
+ timeout: 1000,
+ });
+ await page.getByTestId(`remove-file-button-${renamedTxtFile}`).click();
+ await page.getByText("Playground", { exact: true }).last().click();
+ await page.getByTestId("icon-MoreHorizontal").last().click();
+ await page.getByText("Delete", { exact: true }).last().click();
+
+ await page.waitForSelector("text=Run Flow", {
+ timeout: 30000,
+ });
+
+ await page.getByText("Run Flow", { exact: true }).last().click();
+
+ await expect(page.getByText("this is a test file")).toBeHidden({
+ timeout: 3000,
+ });
+ await expect(page.getByText('{ "test": "content" }')).toBeHidden({
+ timeout: 3000,
+ });
+ await expect(page.getByText("this is a new test")).toBeVisible({
+ timeout: 3000,
+ });
+ }
},
);
diff --git a/src/frontend/tests/extended/features/files-page.spec.ts b/src/frontend/tests/extended/features/files-page.spec.ts
new file mode 100644
index 000000000..4e3dad2ca
--- /dev/null
+++ b/src/frontend/tests/extended/features/files-page.spec.ts
@@ -0,0 +1,281 @@
+import { expect, test } from "@playwright/test";
+import fs from "fs";
+import path from "path";
+import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
+import { generateRandomFilename } from "../../utils/generate-filename";
+
+// Function to generate random 10-character filename
+
+test(
+ "should navigate to files page and show empty state",
+ { tag: ["@release", "@files"] },
+ async ({ page }) => {
+ await awaitBootstrapTest(page, { skipModal: true });
+
+ // Wait for the sidebar to be visible
+ await page.waitForSelector('[data-testid="folder-sidebar"]', {
+ timeout: 30000,
+ });
+
+ // Click on the files button
+ 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");
+
+ // Check for empty state when no files are present
+ const noFilesText = await page.getByText("No files");
+ expect(noFilesText).toBeTruthy();
+
+ const uploadMessage = await page.getByText(
+ "Upload files or import from your preferred cloud.",
+ );
+ expect(uploadMessage).toBeTruthy();
+
+ // Check if upload buttons are present
+ const uploadButton = await page.getByText("Upload");
+ expect(uploadButton).toBeTruthy();
+ },
+);
+
+test(
+ "should upload file using upload button",
+ { tag: ["@release", "@files"] },
+ async ({ page }) => {
+ const fileName = generateRandomFilename();
+ const testFilePath = path.join(__dirname, "../../assets/test-file.txt");
+ const fileContent = fs.readFileSync(testFilePath);
+
+ await awaitBootstrapTest(page, { skipModal: true });
+
+ // Navigate to files page
+ await page.waitForSelector('[data-testid="folder-sidebar"]', {
+ 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: `${fileName}.txt`,
+ mimeType: "text/plain",
+ buffer: fileContent,
+ },
+ ]);
+
+ // Wait for upload success message
+ const successMessage = await page.getByText("File uploaded successfully");
+ expect(successMessage).toBeTruthy();
+
+ // Verify file appears in the list
+ const uploadedFileName = await page.getByText(fileName + ".txt");
+ expect(await uploadedFileName.isVisible()).toBeTruthy();
+ },
+);
+
+test(
+ "should upload file using drag and drop",
+ { tag: ["@release", "@files"] },
+ async ({ page }) => {
+ const fileName = generateRandomFilename();
+
+ await awaitBootstrapTest(page, { skipModal: true });
+
+ // Navigate to files page
+ await page.waitForSelector('[data-testid="folder-sidebar"]', {
+ timeout: 30000,
+ });
+ await page.getByText("My Files").first().click();
+
+ // Create DataTransfer object and file
+ const dataTransfer = await page.evaluateHandle((fileName) => {
+ const data = new DataTransfer();
+ const file = new File(["test content"], `${fileName}.txt`, {
+ type: "text/plain",
+ });
+ data.items.add(file);
+ return data;
+ }, fileName);
+
+ // Trigger drag events
+ await page.dispatchEvent(
+ '[data-testid="drag-wrap-component"]',
+ "dragover",
+ {
+ dataTransfer,
+ },
+ );
+ await page.dispatchEvent('[data-testid="drag-wrap-component"]', "drop", {
+ dataTransfer,
+ });
+
+ // Wait for upload success message
+ const successMessage = await page.getByText("File uploaded successfully");
+ expect(successMessage).toBeTruthy();
+
+ // Verify file appears in the list
+ const uploadedFileName = await page.getByText(fileName + ".txt").last();
+ await expect(uploadedFileName).toBeVisible({
+ timeout: 1000,
+ });
+ },
+);
+
+test(
+ "should upload multiple files with different types",
+ { 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 });
+
+ // Navigate to files page
+ await page.waitForSelector('[data-testid="folder-sidebar"]', {
+ timeout: 30000,
+ });
+ await page.getByText("My Files").first().click();
+ const fileChooserPromise = page.waitForEvent("filechooser");
+ await page.getByTestId("upload-file-btn").click();
+
+ // Create a file input for upload
+ const fileChooser = await fileChooserPromise;
+
+ // Upload multiple test files
+ 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,
+ });
+ }
+ },
+);
+
+test(
+ "should search uploaded 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 });
+
+ // Navigate to files page
+ await page.waitForSelector('[data-testid="folder-sidebar"]', {
+ 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],
+ },
+ ]);
+
+ const successMessage = await page.getByText("Files uploaded successfully");
+ expect(successMessage).toBeTruthy();
+
+ // Test search by file name
+ const searchInput = await page.getByTestId("search-store-input");
+ await searchInput.fill(fileNames.json);
+ await page.waitForTimeout(100);
+
+ // Verify only JSON file is visible
+ expect(
+ await page.getByText(fileNames.json + ".json").isVisible(),
+ ).toBeTruthy();
+
+ // Verify other files are not visible
+ expect(
+ await page.getByText(fileNames.txt + ".txt").isVisible(),
+ ).toBeFalsy();
+ expect(await page.getByText(fileNames.py + ".py").isVisible()).toBeFalsy();
+
+ // Test search by file type
+ await searchInput.fill("py");
+ await page.waitForTimeout(100);
+
+ // Verify only Python file is visible
+ expect(await page.getByText(fileNames.py + ".py").isVisible()).toBeTruthy();
+
+ expect(
+ await page.getByText(fileNames.json + ".json").isVisible(),
+ ).toBeFalsy();
+ expect(
+ await page.getByText(fileNames.txt + ".txt").isVisible(),
+ ).toBeFalsy();
+
+ // Clear search and verify all files are visible again
+ await searchInput.fill("");
+ await page.waitForTimeout(100);
+
+ for (const name of Object.values(fileNames)) {
+ expect(await page.getByText(name).isVisible()).toBeTruthy();
+ }
+ },
+);
diff --git a/src/frontend/tests/extended/features/limit-file-size-upload.spec.ts b/src/frontend/tests/extended/features/limit-file-size-upload.spec.ts
index d2986e66c..70428cb11 100644
--- a/src/frontend/tests/extended/features/limit-file-size-upload.spec.ts
+++ b/src/frontend/tests/extended/features/limit-file-size-upload.spec.ts
@@ -84,7 +84,7 @@ test(
await expect(
page.getByText(
- `The file size is too large. Please select a file smaller than ${maxFileSizeUpload}MB`,
+ `The file size is too large. Please select a file smaller than ${(maxFileSizeUpload * 1024).toFixed(2)} KB`,
),
).toBeVisible();
},
diff --git a/src/frontend/tests/extended/regression/general-bugs-shard-3836.spec.ts b/src/frontend/tests/extended/regression/general-bugs-shard-3836.spec.ts
index ee2491734..f71655413 100644
--- a/src/frontend/tests/extended/regression/general-bugs-shard-3836.spec.ts
+++ b/src/frontend/tests/extended/regression/general-bugs-shard-3836.spec.ts
@@ -3,6 +3,7 @@ import * as dotenv from "dotenv";
import path from "path";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
import { initialGPTsetup } from "../../utils/initialGPTsetup";
+import { uploadFile } from "../../utils/upload-file";
test(
"user must be able to send an image on chat using advanced tool on ChatInputComponent",
@@ -34,18 +35,7 @@ test(
const userQuestion = "What is this image?";
await page.getByTestId("textarea_str_input_value").fill(userQuestion);
- const filePath = "tests/assets/chain.png";
-
- await page.click('[data-testid="button_upload_file"]');
-
- const [fileChooser] = await Promise.all([
- page.waitForEvent("filechooser"),
- page.click('[data-testid="button_upload_file"]'),
- ]);
-
- await fileChooser.setFiles(filePath);
-
- await page.keyboard.press("Escape");
+ await uploadFile(page, "chain.png");
await page.getByTestId("button_run_chat output").click();
await page.getByText("built successfully").last().click({
diff --git a/src/frontend/tests/utils/await-bootstrap-test.ts b/src/frontend/tests/utils/await-bootstrap-test.ts
index d718d1cc6..9deb17e0a 100644
--- a/src/frontend/tests/utils/await-bootstrap-test.ts
+++ b/src/frontend/tests/utils/await-bootstrap-test.ts
@@ -4,6 +4,7 @@ export const awaitBootstrapTest = async (
page: Page,
options?: {
skipGoto?: boolean;
+ skipModal?: boolean;
},
) => {
if (!options?.skipGoto) {
@@ -18,21 +19,23 @@ export const awaitBootstrapTest = async (
timeout: 30000,
});
- let modalCount = 0;
- try {
- const modalTitleElement = await page?.getByTestId("modal-title");
- if (modalTitleElement) {
- modalCount = await modalTitleElement.count();
+ if (!options?.skipModal) {
+ let modalCount = 0;
+ try {
+ const modalTitleElement = await page?.getByTestId("modal-title");
+ if (modalTitleElement) {
+ modalCount = await modalTitleElement.count();
+ }
+ } catch (error) {
+ modalCount = 0;
}
- } catch (error) {
- modalCount = 0;
- }
- while (modalCount === 0) {
- await page.getByText("New Flow", { exact: true }).click();
- await page.waitForSelector('[data-testid="modal-title"]', {
- timeout: 3000,
- });
- modalCount = await page.getByTestId("modal-title")?.count();
+ while (modalCount === 0) {
+ await page.getByText("New Flow", { exact: true }).click();
+ await page.waitForSelector('[data-testid="modal-title"]', {
+ timeout: 3000,
+ });
+ modalCount = await page.getByTestId("modal-title")?.count();
+ }
}
};
diff --git a/src/frontend/tests/utils/generate-filename.ts b/src/frontend/tests/utils/generate-filename.ts
new file mode 100644
index 000000000..be0e8243b
--- /dev/null
+++ b/src/frontend/tests/utils/generate-filename.ts
@@ -0,0 +1,7 @@
+export function generateRandomFilename() {
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
+ return Array.from(
+ { length: 10 },
+ () => chars[Math.floor(Math.random() * chars.length)],
+ ).join("");
+}
diff --git a/src/frontend/tests/utils/upload-file.ts b/src/frontend/tests/utils/upload-file.ts
new file mode 100644
index 000000000..019222230
--- /dev/null
+++ b/src/frontend/tests/utils/upload-file.ts
@@ -0,0 +1,82 @@
+import path from "path";
+
+import { Page, expect } from "@playwright/test";
+import fs from "fs";
+import { generateRandomFilename } from "./generate-filename";
+
+// Function to get the correct mimeType based on file extension
+function getMimeType(extension: string): string {
+ const mimeTypes: Record = {
+ pdf: "application/pdf",
+ json: "application/json",
+ txt: "text/plain",
+ csv: "text/csv",
+ xml: "application/xml",
+ html: "text/html",
+ htm: "text/html",
+ js: "text/javascript",
+ css: "text/css",
+ png: "image/png",
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ gif: "image/gif",
+ svg: "image/svg+xml",
+ ico: "image/x-icon",
+ yaml: "application/x-yaml",
+ yml: "application/x-yaml",
+ py: "text/x-python",
+ md: "text/markdown",
+ };
+
+ return mimeTypes[extension.toLowerCase()] || "application/octet-stream";
+}
+
+export async function uploadFile(page: Page, fileName: string) {
+ const fileManagement = await page
+ .getByTestId("button_open_file_management")
+ ?.isVisible();
+
+ if (!fileManagement) {
+ const fileChooserPromise = page.waitForEvent("filechooser");
+ await page.getByTestId("button_upload_file").click();
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles(path.join(__dirname, `../assets/${fileName}`));
+ await page.getByText(fileName).isVisible();
+ return;
+ }
+ await page.getByTestId("button_open_file_management").first().click();
+ const drag = await page.getByTestId("drag-files-component");
+ const sourceFileName = generateRandomFilename();
+ const testFilePath = path.join(__dirname, `../assets/${fileName}`);
+ const testFileType = fileName.split(".").pop() || "";
+ const fileContent = fs.readFileSync(testFilePath);
+
+ const fileChooserPromise = page.waitForEvent("filechooser");
+ await drag.click();
+
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles([
+ {
+ name: `${sourceFileName}.${testFileType}`,
+ mimeType: getMimeType(testFileType),
+ buffer: fileContent,
+ },
+ ]);
+
+ await page
+ .getByText(sourceFileName + `.${testFileType}`)
+ .last()
+ .waitFor({ state: "visible", timeout: 3000 });
+
+ const checkbox = page.getByTestId(`checkbox-${sourceFileName}`).last();
+ await expect(checkbox).toHaveAttribute("data-state", "checked", {
+ timeout: 3000,
+ });
+
+ await page.getByTestId("select-files-modal-button").click();
+
+ await page
+ .getByText(sourceFileName + `.${testFileType}`)
+ .first()
+ .waitFor({ state: "visible", timeout: 1000 });
+}
diff --git a/src/frontend/vite.config.mts b/src/frontend/vite.config.mts
index 3634e122a..006e11cc2 100644
--- a/src/frontend/vite.config.mts
+++ b/src/frontend/vite.config.mts
@@ -20,7 +20,7 @@ export default defineConfig(({ mode }) => {
const envLangflow = envLangflowResult.parsed || {};
- const apiRoutes = API_ROUTES || ["^/api/v1/", "/health"];
+ const apiRoutes = API_ROUTES || ["^/api/v1/", "^/api/v2/", "/health"];
const target =
env.VITE_PROXY_TARGET || PROXY_TARGET || "http://127.0.0.1:7860";