From a8ae17b86d7afad7de02825ce4c0476bd118f4ad Mon Sep 17 00:00:00 2001 From: Cristhian Zanforlin Lousa Date: Tue, 22 Apr 2025 20:26:33 -0300 Subject: [PATCH] feat: add observable UX for community interaction tracking (#7512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ (model.py): add UserOptin class to manage user opt-in actions for better organization and extensibility ♻️ (model.py): refactor User model to include user_optin field as a dictionary to store opt-in actions for users * [autofix.ci] apply automated fixes * change name optins * [autofix.ci] apply automated fixes * ✨ (add_optins_column_to_user.py): Add optins column to the user table to store user preferences ♻️ (model.py): Refactor UserOptin class to BaseModel for better type hinting and add optins field to User model with default values and proper typing * [autofix.ci] apply automated fixes * 🐛 (add_optins_column_to_user.py): fix an issue where the optins column was not being added if it already existed in the user table * ✨ (empty-page.tsx): Add new page EmptyPageCommunity to display community information and actions ✨ (main-page.tsx): Add new page CollectionPage to manage collections and folders 🔧 (routes.tsx): Update import path for CollectionPage to point to the new main-page file * ✨ (background-gradient.tsx): Add a new component BackgroundGradient to create a visually appealing background gradient effect for UI elements 📝 (empty-page.tsx): Refactor EmptyPageCommunity component to use the newly added BackgroundGradient component for GitHub and Discord sections to enhance visual appeal and consistency * 📝 (tasks.mdc): Add concise task management protocol for sequential mode to improve task organization and execution ✨ (frontend): Introduce DotBackgroundDemo component for creating a visually appealing dot background effect ♻️ (frontend): Refactor BackgroundGradient component to improve gradient styling and border consistency 🔧 (frontend): Update motion import in background-gradient.tsx to use framer-motion instead of motion/react 🔧 (icons): Add missing newline at the end of Anthropic icon file 🔧 (empty-page.tsx): Adjust styling classes and z-index to improve layout and visual hierarchy in EmptyPageCommunity component * ✨ (empty-page.tsx): add githubBg image import to use as background image for GitHub link ♻️ (empty-page.tsx): refactor positioning and styling of GitHub link elements for better alignment and readability * 🔧 refactor(server.ts): change port variable case from lowercase port to uppercase PORT to improve semantics ✨ refactor(server.ts): add support for process.env.PORT environment variable to be able to run app on a configurable port * 📝 (AccountMenu/index.tsx): Update imports and remove unused code for better organization and performance 🔧 (use-get-version.ts): Add functionality to refresh the latest version in darkStore after fetching version data ♻️ (darkStore.ts): Add refreshLatestVersion function to update the latest version in darkStore 📝 (dark/index.ts): Add latestVersion field and refreshLatestVersion function to DarkStoreType for better state management * ✨ (AccountMenu/index.tsx): Add constants for Discord, Docs, GitHub, and Twitter URLs for better maintainability and reusability 📝 (constants.ts): Update Twitter URL to a new value for consistency with other URLs 📝 (TwitterX): Add new TwitterX icon and component for use in the application 📝 (styleUtils.ts): Import and use the new TwitterXIcon in the list of node icons 📝 (utils.ts): Update formatNumber function to handle undefined input values for better error handling * [autofix.ci] apply automated fixes * 📝 (add_optins_column_to_user.py): Update down_revision to '1b8b740a6fa3' for consistency 🔧 (AccountMenu/index.tsx): Adjust classNameSize prop value to 'w-[272px]' for styling consistency 🔧 (HeaderMenu/index.tsx): Update HeaderMenuItems component to accept classNameSize prop for dynamic styling 🔧 (langflow-counts.tsx): Adjust styling for better visual consistency and spacing 🔧 (index.tsx): Update className for Bell icon to include text-muted-foreground and strokeWidth 🔧 (get-started-progress.tsx): Update styling and spacing for better visual consistency 🔧 (header-buttons.tsx): Add Separator component for visual separation in HeaderButtons component * 🔧 (AccountMenu/index.tsx): Adjust padding in AccountMenu component for better alignment and spacing. Fix ThemeButtons positioning for improved layout. * ✨ (appHeaderComponent/index.tsx): Add support for managing flows and folders in the app header component 📝 (get-started-progress.tsx): Update heading tag to improve semantics 📝 (empty-page.tsx): Update text content in empty page to provide clearer instructions and information * ✨ (background-gradient.tsx): Add support for dynamic border radius in BackgroundGradient component 🔧 (empty-page.tsx): Remove BackgroundGradient import and replace it with EnhancedBeamEffect component 🔧 (empty-page.tsx): Update styles and classes for EnhancedBeamEffect component and adjust layout ✨ (enchanced-beam-effect.tsx): Create EnhancedBeamEffect component to add enhanced beam effect to UI components * ✨ (frontend): update text content and button labels in empty page component for better user experience 📝 (frontend): add data-testid attributes for testing purposes in various components 🔧 (frontend): add new test file for user progress tracking feature with Playwright tests * ✨ (AccountMenu/index.tsx): Add Admin Page button for admin users in the account menu component 🔧 (user-progress-track.spec.ts): Add utility function addNewUserAndLogin to facilitate adding and logging in new users for testing purposes * [autofix.ci] apply automated fixes * 🐛 (get-started-progress.tsx): fix calculation of percentage to ensure it does not exceed 100% * ✨ (empty-page.tsx): Add Lucide ExternalLink component for external links and update styling for external link icons ♻️ (empty-page.tsx): Refactor CSS classes for external link icons to improve readability and maintainability 📝 (index.css): Add custom CSS variable for Discord color 📝 (tailwind.config.mjs): Add Discord color to Tailwind CSS custom colors * [autofix.ci] apply automated fixes * add logo png * ✨ (index.tsx): Add z-50 class to improve stacking context in CardsWrapComponent ✨ (empty-page.tsx): Add text-center class to center text elements in EmptyPageCommunity ✨ (empty-page.tsx): Adjust spacing and alignment in EmptyPageCommunity for better layout and readability * 🐛 (AccountMenu/index.tsx): fix condition to show admin options only when isAdmin is true and autoLogin is false * 🔧 (alertDropDown/index.tsx): update z-index value in PopoverContent class to z-50 for proper stacking order * 🔧 (index.tsx): update z-index value to improve the stacking order of the component on the page * ♻️ (index.tsx): refactor classNames in CardsWrapComponent to improve readability and maintainability * 🐛 (empty-page.tsx): fix data-testid attribute value to match the updated element name for better consistency and clarity 🐛 (user-progress-track.spec.ts): fix test cases to match the updated data-testid attribute value for the main page title element to ensure accurate testing and assertions --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .cursor/rules/tasks.mdc | 51 ++++ .../e56d87f8994a_add_optins_column_to_user.py | 38 +++ .../services/database/models/user/model.py | 19 +- .../RenderInputParameters/index.tsx | 2 - .../src/alerts/alertDropDown/index.tsx | 2 +- src/frontend/src/assets/github-bg.png | Bin 0 -> 22326 bytes src/frontend/src/assets/logo_dark.png | Bin 0 -> 11343 bytes src/frontend/src/assets/logo_light.png | Bin 0 -> 11204 bytes .../components/AccountMenu/index.tsx | 211 +++++++-------- .../components/GithubStarButton/index.tsx | 21 -- .../components/HeaderMenu/index.tsx | 14 +- .../components/ThemeButtons/index.tsx | 14 +- .../components/langflow-counts.tsx | 39 +++ .../core/appHeaderComponent/index.tsx | 58 ++--- .../core/cardsWrapComponent/index.tsx | 2 +- .../components/get-started-progress.tsx | 234 +++++++++++++++++ .../components/header-buttons.tsx | 73 ++++-- .../src/components/ui/background-gradient.tsx | 99 +++++++ .../src/components/ui/dot-background.tsx | 36 +++ src/frontend/src/constants/constants.ts | 5 + src/frontend/src/controllers/API/index.ts | 11 + .../API/queries/auth/use-get-user.ts | 4 +- .../API/queries/version/use-get-version.ts | 3 + src/frontend/src/icons/Twitter X/TwitterX.jsx | 46 ++++ src/frontend/src/icons/Twitter X/iconX.svg | 1 + src/frontend/src/icons/Twitter X/icons8-x.svg | 1 + src/frontend/src/icons/Twitter X/index.tsx | 11 + src/frontend/src/icons/lucideIcons.ts | 2 + .../modals/deleteConfirmationModal/index.tsx | 2 + src/frontend/src/pages/AppInitPage/index.tsx | 4 + .../MainPage/components/dropdown/index.tsx | 1 + .../src/pages/MainPage/pages/empty-page.tsx | 190 ++++++++++++++ .../MainPage/pages/enchanced-beam-effect.tsx | 43 ++++ .../pages/{index.tsx => main-page.tsx} | 5 +- src/frontend/src/routes.tsx | 2 +- src/frontend/src/stores/darkStore.ts | 12 +- src/frontend/src/style/index.css | 5 + src/frontend/src/types/api/index.ts | 10 + src/frontend/src/types/zustand/dark/index.ts | 4 + src/frontend/src/utils/styleUtils.ts | 1 + src/frontend/src/utils/utils.ts | 12 + src/frontend/tailwind.config.mjs | 2 + .../core/features/user-progress-track.spec.ts | 242 ++++++++++++++++++ .../tests/utils/add-new-user-and-loggin.ts | 103 ++++++++ ...5500_background_gradient_border_styling.md | 56 ++++ 45 files changed, 1482 insertions(+), 209 deletions(-) create mode 100644 .cursor/rules/tasks.mdc create mode 100644 src/backend/base/langflow/alembic/versions/e56d87f8994a_add_optins_column_to_user.py create mode 100644 src/frontend/src/assets/github-bg.png create mode 100644 src/frontend/src/assets/logo_dark.png create mode 100644 src/frontend/src/assets/logo_light.png delete mode 100644 src/frontend/src/components/core/appHeaderComponent/components/GithubStarButton/index.tsx create mode 100644 src/frontend/src/components/core/appHeaderComponent/components/langflow-counts.tsx create mode 100644 src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/get-started-progress.tsx create mode 100644 src/frontend/src/components/ui/background-gradient.tsx create mode 100644 src/frontend/src/components/ui/dot-background.tsx create mode 100644 src/frontend/src/icons/Twitter X/TwitterX.jsx create mode 100644 src/frontend/src/icons/Twitter X/iconX.svg create mode 100644 src/frontend/src/icons/Twitter X/icons8-x.svg create mode 100644 src/frontend/src/icons/Twitter X/index.tsx create mode 100644 src/frontend/src/pages/MainPage/pages/empty-page.tsx create mode 100644 src/frontend/src/pages/MainPage/pages/enchanced-beam-effect.tsx rename src/frontend/src/pages/MainPage/pages/{index.tsx => main-page.tsx} (95%) create mode 100644 src/frontend/tests/core/features/user-progress-track.spec.ts create mode 100644 src/frontend/tests/utils/add-new-user-and-loggin.ts create mode 100644 tasks/20240610_165500_background_gradient_border_styling.md diff --git a/.cursor/rules/tasks.mdc b/.cursor/rules/tasks.mdc new file mode 100644 index 000000000..34db87c93 --- /dev/null +++ b/.cursor/rules/tasks.mdc @@ -0,0 +1,51 @@ +--- +description: +globs: +alwaysApply: true +--- +# Concise Task Management Protocol - Sequential Mode + +## File Creation +- Filename: `YYYYMMDD_HHMMSS_task_name.md` +- IMPORTANT: Always include a descriptive task name after the timestamp (e.g., `20250404_135621_api_integration.md`) +- Never create files with timestamp only +- Store in `tasks/` directory + +## Task Structure + +``` +# Task: [Task Name] +**Status**: [Not Started | In Progress | Completed] + +## Analysis +- [ ] Requirements + - [ ] Subtask 1 +- [ ] Challenges +- [ ] Dependencies + +## Plan +- [ ] Step 1: [Description] + - [ ] Subtask 1.1 +- [ ] Step 2: [Description] + +## Execution +- [ ] Implementation 1 + - [ ] Details +- [ ] Implementation 2 + - [ ] Files modified: [files] + +## Summary +- [ ] Files modified: `filename.ext` (lines X-Y) +- [ ] Dependencies added/changed +- [ ] Edge cases considered +- [ ] Known limitations +- [ ] Future impact points +``` + +## Execution Rules +- Execute one subtask at a time in sequence +- Update the task file after EACH subtask is completed +- Mark completed subtasks with [x] as they are finished +- Update main task status throughout execution +- Document work incrementally, not all at once +- Never proceed to the next section until all subtasks in the current section are completed and marked \ No newline at end of file diff --git a/src/backend/base/langflow/alembic/versions/e56d87f8994a_add_optins_column_to_user.py b/src/backend/base/langflow/alembic/versions/e56d87f8994a_add_optins_column_to_user.py new file mode 100644 index 000000000..599c8138d --- /dev/null +++ b/src/backend/base/langflow/alembic/versions/e56d87f8994a_add_optins_column_to_user.py @@ -0,0 +1,38 @@ +"""add_optins_column_to_user + +Revision ID: e56d87f8994a +Revises: 1b8b740a6fa3 +Create Date: 2025-04-09 15:57:46.904977 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.engine.reflection import Inspector +from langflow.utils import migration + + +# revision identifiers, used by Alembic. +revision: str = 'e56d87f8994a' +down_revision: Union[str, None] = '1b8b740a6fa3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + # ### commands auto generated by Alembic - please adjust! ### + if not migration.column_exists(table_name='user', column_name='optins', conn=conn): + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('optins', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + conn = op.get_bind() + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('optins') + # ### end Alembic commands ### diff --git a/src/backend/base/langflow/services/database/models/user/model.py b/src/backend/base/langflow/services/database/models/user/model.py index 2f420d882..7bd4606d2 100644 --- a/src/backend/base/langflow/services/database/models/user/model.py +++ b/src/backend/base/langflow/services/database/models/user/model.py @@ -1,7 +1,9 @@ from datetime import datetime, timezone -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from uuid import UUID, uuid4 +from pydantic import BaseModel +from sqlalchemy import JSON, Column from sqlmodel import Field, Relationship, SQLModel from langflow.schema.serialize import UUIDstr @@ -13,6 +15,13 @@ if TYPE_CHECKING: from langflow.services.database.models.variable import Variable +class UserOptin(BaseModel): + github_starred: bool = Field(default=False) + dialog_dismissed: bool = Field(default=False) + discord_clicked: bool = Field(default=False) + # Add more opt-in actions as needed + + class User(SQLModel, table=True): # type: ignore[call-arg] id: UUIDstr = Field(default_factory=uuid4, primary_key=True, unique=True) username: str = Field(index=True, unique=True) @@ -37,11 +46,17 @@ class User(SQLModel, table=True): # type: ignore[call-arg] back_populates="user", sa_relationship_kwargs={"cascade": "delete"}, ) + optins: dict[str, Any] | None = Field( + sa_column=Column(JSON, default=lambda: UserOptin().model_dump(), nullable=True) + ) class UserCreate(SQLModel): username: str = Field() password: str = Field() + optins: dict[str, Any] | None = Field( + default={"github_starred": False, "dialog_dismissed": False, "discord_clicked": False} + ) class UserRead(SQLModel): @@ -54,6 +69,7 @@ class UserRead(SQLModel): create_at: datetime = Field() updated_at: datetime = Field() last_login_at: datetime | None = Field(nullable=True) + optins: dict[str, Any] | None = Field(default=None) class UserUpdate(SQLModel): @@ -63,3 +79,4 @@ class UserUpdate(SQLModel): is_active: bool | None = None is_superuser: bool | None = None last_login_at: datetime | None = None + optins: dict[str, Any] | None = None diff --git a/src/frontend/src/CustomNodes/GenericNode/components/RenderInputParameters/index.tsx b/src/frontend/src/CustomNodes/GenericNode/components/RenderInputParameters/index.tsx index e361718d3..c0ab05610 100644 --- a/src/frontend/src/CustomNodes/GenericNode/components/RenderInputParameters/index.tsx +++ b/src/frontend/src/CustomNodes/GenericNode/components/RenderInputParameters/index.tsx @@ -74,8 +74,6 @@ const RenderInputParameters = ({ return keyMap; }, [templateFields, data.id, data.node?.template]); - console.log({ data }); - const renderInputParameter = templateFields.map( (templateField: string, idx) => { const template = data.node?.template[templateField]; diff --git a/src/frontend/src/alerts/alertDropDown/index.tsx b/src/frontend/src/alerts/alertDropDown/index.tsx index 87e2fac56..d9746046f 100644 --- a/src/frontend/src/alerts/alertDropDown/index.tsx +++ b/src/frontend/src/alerts/alertDropDown/index.tsx @@ -47,7 +47,7 @@ const AlertDropdown = forwardRef(
Notifications diff --git a/src/frontend/src/assets/github-bg.png b/src/frontend/src/assets/github-bg.png new file mode 100644 index 0000000000000000000000000000000000000000..3b130fd7283478fe81727ee6988a7f3c3bb445f6 GIT binary patch literal 22326 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 R?%DtV002ovPDHLkV1h=u;g|pb literal 0 HcmV?d00001 diff --git a/src/frontend/src/assets/logo_light.png b/src/frontend/src/assets/logo_light.png new file mode 100644 index 0000000000000000000000000000000000000000..d457a6116a01ce67a49910e2baf97d8b5c4163c7 GIT binary patch literal 11204 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 i4s@Uc9q7O_1%C-I9Ek0000 { - const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); - const [isCustomFeatureFlagsOpen, setIsCustomFeatureFlagsOpen] = - useState(false); const { customParam: id } = useParams(); const version = useDarkStore((state) => state.version); + const latestVersion = useDarkStore((state) => state.latestVersion); const navigate = useCustomNavigate(); const { mutate: mutationLogout } = useLogout(); @@ -40,6 +38,8 @@ export const AccountMenu = () => { mutationLogout(); }; + const isLatestVersion = version === latestVersion; + return ( <> @@ -48,34 +48,37 @@ export const AccountMenu = () => { className="h-7 w-7 rounded-lg focus-visible:outline-0" data-testid="user-profile-settings" > - {ENABLE_DATASTAX_LANGFLOW ? : } +

- - {ENABLE_DATASTAX_LANGFLOW && ( - - - - )} - -
-
- - Version {version} - + +
+
+
+
+ + Version + +
+ {version}{" "} + {isLatestVersion ? "(latest)" : "(update available)"} +
+
- {!ENABLE_DATASTAX_LANGFLOW && }
- {ENABLE_DATASTAX_LANGFLOW ? ( - - Account Settings - - ) : ( + +
{ navigate("/settings"); }} @@ -87,100 +90,84 @@ export const AccountMenu = () => { Settings - )} - {!ENABLE_DATASTAX_LANGFLOW && ( - <> - {isAdmin && !autoLogin && ( - navigate("/admin")}> + + {isAdmin && !autoLogin && ( +
+ { + navigate("/admin"); + }} + > Admin Page - )} - - )} - {ENABLE_DATASTAX_LANGFLOW ? ( - <> - setIsFeedbackOpen(true)}> - - Feedback - - - setIsCustomFeatureFlagsOpen(true)} - /> - - ) : ( - +
+ )} + Docs - )} - - - {ENABLE_DATASTAX_LANGFLOW ? ( - -
-
Star the repo
- -
-
- ) : ( - - - Share Feedback on Github +
+ +
+ + + + GitHub - )} - - - Follow Langflow on X - - - - - Join the Langflow Discord - - - - {ENABLE_DATASTAX_LANGFLOW ? ( - - - Logout + + + + Discord + - - ) : ( - !autoLogin && ( - + + + + X + + +
+ +
+ Theme +
+ +
+
+ + {!autoLogin && ( +
Logout - - ) - )} +
+ )} +
- - ); }; diff --git a/src/frontend/src/components/core/appHeaderComponent/components/GithubStarButton/index.tsx b/src/frontend/src/components/core/appHeaderComponent/components/GithubStarButton/index.tsx deleted file mode 100644 index 08d1f2ed4..000000000 --- a/src/frontend/src/components/core/appHeaderComponent/components/GithubStarButton/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import ShadTooltip from "@/components/common/shadTooltipComponent"; -import { useDarkStore } from "@/stores/darkStore"; -import { FaGithub } from "react-icons/fa"; - -export const GithubStarComponent = () => { - const stars: number | undefined = useDarkStore((state) => state.stars); - - return ( - -
- -
Star
-
- {stars?.toLocaleString() ?? 0} -
-
-
- ); -}; - -export default GithubStarComponent; diff --git a/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/index.tsx b/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/index.tsx index 414e687bf..cbff24a0e 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/index.tsx @@ -22,11 +22,7 @@ export const HeaderMenuToggle = ({ children }) => ( >
{children} - +
); @@ -72,10 +68,14 @@ export const HeaderMenuItemButton = ({ icon = "", onClick, children }) => ( export const HeaderMenuItems = ({ position = "left", children, -}: React.PropsWithChildren<{ position?: "left" | "right" }>) => { + classNameSize = "w-[20rem]", +}: React.PropsWithChildren<{ + position?: "left" | "right"; + classNameSize?: string; +}>) => { const positionClass = position === "left" ? "left-0" : "right-0"; return ( - + {children} ); diff --git a/src/frontend/src/components/core/appHeaderComponent/components/ThemeButtons/index.tsx b/src/frontend/src/components/core/appHeaderComponent/components/ThemeButtons/index.tsx index 92c8c223d..e2b5aa3ff 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/ThemeButtons/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/ThemeButtons/index.tsx @@ -60,7 +60,7 @@ export const ThemeButtons = () => { data-testid="menu_light_button" id="menu_light_button" > - + {/* Dark Theme Button */} @@ -68,14 +68,14 @@ export const ThemeButtons = () => { unstyled className={`relative z-10 mx-1 inline-flex items-center rounded-full px-1 ${ selectedTheme === "dark" - ? "text-background dark:hover:bg-purple-400" - : "text-foreground hover:bg-purple-400 hover:text-background" + ? "bg-indigo-foreground text-primary hover:bg-indigo-foreground" + : "text-foreground hover:bg-indigo-foreground hover:text-background" }`} onClick={() => handleThemeChange("dark")} data-testid="menu_dark_button" id="menu_dark_button" > - + {/* System Theme Button */} @@ -90,7 +90,11 @@ export const ThemeButtons = () => { data-testid="menu_system_button" id="menu_system_button" > - +
); diff --git a/src/frontend/src/components/core/appHeaderComponent/components/langflow-counts.tsx b/src/frontend/src/components/core/appHeaderComponent/components/langflow-counts.tsx new file mode 100644 index 000000000..94d2c6706 --- /dev/null +++ b/src/frontend/src/components/core/appHeaderComponent/components/langflow-counts.tsx @@ -0,0 +1,39 @@ +import ShadTooltip from "@/components/common/shadTooltipComponent"; +import { useDarkStore } from "@/stores/darkStore"; +import { formatNumber } from "@/utils/utils"; +import { FaDiscord, FaGithub } from "react-icons/fa"; + +export const LangflowCounts = () => { + const stars: number | undefined = useDarkStore((state) => state.stars); + const discordCount: number = useDarkStore((state) => state.discordCount); + + return ( +
+ +
+ + {formatNumber(stars)} +
+
+ + +
+ + + {formatNumber(discordCount)} + +
+
+
+ ); +}; + +export default LangflowCounts; diff --git a/src/frontend/src/components/core/appHeaderComponent/index.tsx b/src/frontend/src/components/core/appHeaderComponent/index.tsx index 34c8a69f5..da95a823f 100644 --- a/src/frontend/src/components/core/appHeaderComponent/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/index.tsx @@ -12,10 +12,12 @@ import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate"; import useTheme from "@/customization/hooks/use-custom-theme"; import { useResetDismissUpdateAll } from "@/hooks/use-reset-dismiss-update-all"; import useAlertStore from "@/stores/alertStore"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import { useFolderStore } from "@/stores/foldersStore"; import { useEffect, useRef, useState } from "react"; import { AccountMenu } from "./components/AccountMenu"; import FlowMenu from "./components/FlowMenu"; -import GithubStarComponent from "./components/GithubStarButton"; +import LangflowCounts from "./components/langflow-counts"; export default function AppHeader(): JSX.Element { const notificationCenter = useAlertStore((state) => state.notificationCenter); @@ -46,9 +48,17 @@ export default function AppHeader(): JSX.Element { useResetDismissUpdateAll(); + const flows = useFlowsManagerStore((state) => state.flows); + const examples = useFlowsManagerStore((state) => state.examples); + const folders = useFolderStore((state) => state.folders); + + const isEmpty = flows?.length !== examples?.length || folders?.length > 1; + return (
{/* Left Section */} @@ -83,7 +93,7 @@ export default function AppHeader(): JSX.Element { {/* Right Section */}
{!ENABLE_DATASTAX_LANGFLOW && ( @@ -95,7 +105,7 @@ export default function AppHeader(): JSX.Element { window.open("https://github.com/langflow-ai/langflow", "_blank") } > - + )} @@ -111,8 +121,7 @@ export default function AppHeader(): JSX.Element { setActiveState(null)}> - {!ENABLE_DATASTAX_LANGFLOW && ( - <> - - - - - - )} + {ENABLE_DATASTAX_LANGFLOW && ( <> diff --git a/src/frontend/src/components/core/cardsWrapComponent/index.tsx b/src/frontend/src/components/core/cardsWrapComponent/index.tsx index e5853a788..18d149259 100644 --- a/src/frontend/src/components/core/cardsWrapComponent/index.tsx +++ b/src/frontend/src/components/core/cardsWrapComponent/index.tsx @@ -74,7 +74,7 @@ export default function CardsWrapComponent({ className={cn( "h-full w-full", isDragging - ? "mb-36 flex flex-col items-center justify-center gap-4 text-2xl font-light" + ? "z-10 mb-36 flex flex-col items-center justify-center gap-4 text-2xl font-light" : "", )} > diff --git a/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/get-started-progress.tsx b/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/get-started-progress.tsx new file mode 100644 index 000000000..3de854e16 --- /dev/null +++ b/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/get-started-progress.tsx @@ -0,0 +1,234 @@ +import IconComponent from "@/components/common/genericIconComponent"; +import { Button } from "@/components/ui/button"; +import { DISCORD_URL, GITHUB_URL } from "@/constants/constants"; +import { useGetUserData, useUpdateUser } from "@/controllers/API/queries/auth"; +import ModalsComponent from "@/pages/MainPage/components/modalsComponent"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import { Users } from "@/types/api"; +import { cn } from "@/utils/utils"; +import { FC, useEffect, useMemo, useState } from "react"; +import { FaDiscord, FaGithub } from "react-icons/fa"; + +export const GetStartedProgress: FC<{ + userData: Users; + isGithubStarred: boolean; + isDiscordJoined: boolean; + handleDismissDialog: () => void; +}> = ({ userData, isGithubStarred, isDiscordJoined, handleDismissDialog }) => { + const [isGithubStarredChild, setIsGithubStarredChild] = + useState(isGithubStarred); + const [isDiscordJoinedChild, setIsDiscordJoinedChild] = + useState(isDiscordJoined); + const [newProjectModal, setNewProjectModal] = useState(false); + + const flows = useFlowsManagerStore((state) => state.flows); + + const { mutate: mutateLoggedUser } = useGetUserData(); + const { mutate: updateUser } = useUpdateUser(); + + useEffect(() => { + if (!userData) { + mutateLoggedUser(null); + } + }, [userData, mutateLoggedUser]); + + const hasFlows = flows && flows?.length > 0; + + const percentageGetStarted = useMemo(() => { + const totalSteps = 3; + let hasFlowsCount = 0; + const completedSteps = Object.keys(userData?.optins ?? {}).filter( + (key) => userData?.optins?.[key], + )?.length; + + if (hasFlows) { + hasFlowsCount = 33; + } + + const percentage = + Math.round((completedSteps / totalSteps) * 100) + hasFlowsCount; + + if (percentage > 100) { + return 100; + } + + return percentage; + }, [userData?.optins, isGithubStarredChild, isDiscordJoinedChild, hasFlows]); + + const handleUserTrack = (key: string) => { + const optins = userData?.optins ?? {}; + optins[key] = true; + + updateUser( + { + user_id: userData?.id!, + user: { optins }, + }, + { + onSuccess: () => { + mutateLoggedUser({}); + if (key === "github_starred") { + setIsGithubStarredChild(true); + window.open(GITHUB_URL, "_blank", "noopener,noreferrer"); + } else if (key === "discord_clicked") { + setIsDiscordJoinedChild(true); + window.open(DISCORD_URL, "_blank", "noopener,noreferrer"); + } else if (key === "dialog_dismissed") { + handleDismissDialog(); + } + }, + }, + ); + }; + + return ( +
+
+ Get started + +
+ +
+
+
+
+ + {percentageGetStarted}% + +
+ +
+ + + + + +
+ + {}} + handleDeleteFolder={() => {}} + /> +
+ ); +}; diff --git a/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/header-buttons.tsx b/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/header-buttons.tsx index 56c9b23bd..0421303df 100644 --- a/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/header-buttons.tsx +++ b/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/header-buttons.tsx @@ -1,5 +1,9 @@ import IconComponent from "@/components/common/genericIconComponent"; +import { GetStartedProgress } from "@/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/get-started-progress"; import { SidebarTrigger } from "@/components/ui/sidebar"; +import useAuthStore from "@/stores/authStore"; +import { Separator } from "@radix-ui/react-separator"; +import { useState } from "react"; import { AddFolderButton } from "./add-folder-button"; import { UploadFolderButton } from "./upload-folder-button"; @@ -13,23 +17,54 @@ export const HeaderButtons = ({ isUpdatingFolder: boolean; isPending: boolean; addNewFolder: () => void; -}) => ( -
- - - +}) => { + const userData = useAuthStore((state) => state.userData); + const userDismissedDialog = userData?.optins?.dialog_dismissed; + const isGithubStarred = userData?.optins?.github_starred; + const isDiscordJoined = userData?.optins?.discord_clicked; -
Folders
-
- - -
-
-); + const [isDismissedDialog, setIsDismissedDialog] = + useState(userDismissedDialog); + + const handleDismissDialog = () => { + setIsDismissedDialog(true); + }; + + return ( + <> + {!isDismissedDialog && ( + <> + + +
+
+
+ + )} + +
+ + + + +
Folders
+
+ + +
+
+ + ); +}; diff --git a/src/frontend/src/components/ui/background-gradient.tsx b/src/frontend/src/components/ui/background-gradient.tsx new file mode 100644 index 000000000..e324362f0 --- /dev/null +++ b/src/frontend/src/components/ui/background-gradient.tsx @@ -0,0 +1,99 @@ +import { cn } from "@/utils/utils"; +import { motion } from "framer-motion"; +import React from "react"; + +export const BackgroundGradient = ({ + children, + className, + containerClassName, + animate = true, + borderColor, + borderRadius, +}: { + children?: React.ReactNode; + className?: string; + containerClassName?: string; + animate?: boolean; + borderColor?: string; + borderRadius?: string; +}) => { + const variants = { + initial: { + backgroundPosition: "0 50%", + }, + animate: { + backgroundPosition: ["0, 50%", "100% 50%", "0 50%"], + }, + }; + + const defaultGradient = + "radial-gradient(circle farthest-side at 0 100%,#00ccb1,transparent),radial-gradient(circle farthest-side at 100% 0,#7b61ff,transparent),radial-gradient(circle farthest-side at 100% 100%,#ffc414,transparent),radial-gradient(circle farthest-side at 0 0,#1ca0fb,#141316)"; + + return ( +
+ + + +
{children}
+
+ ); +}; diff --git a/src/frontend/src/components/ui/dot-background.tsx b/src/frontend/src/components/ui/dot-background.tsx new file mode 100644 index 000000000..08873fbc8 --- /dev/null +++ b/src/frontend/src/components/ui/dot-background.tsx @@ -0,0 +1,36 @@ +import { cn } from "@/utils/utils"; + +export function DotBackgroundDemo({ + children, + className, + containerClassName, +}: { + children: React.ReactNode; + className?: string; + containerClassName?: string; +}) { + return ( +
+
+
+ {children} +
+ ); +} diff --git a/src/frontend/src/constants/constants.ts b/src/frontend/src/constants/constants.ts index 7ec483bec..bea80f179 100644 --- a/src/frontend/src/constants/constants.ts +++ b/src/frontend/src/constants/constants.ts @@ -1005,6 +1005,7 @@ export const GRADIENT_CLASS_DISABLED = "linear-gradient(to right, hsl(var(--muted) / 0.3), hsl(var(--muted)))"; export const RECEIVING_INPUT_VALUE = "Receiving input"; +export const SELECT_AN_OPTION = "Select an option..."; export const ICON_STROKE_WIDTH = 1.5; @@ -1068,5 +1069,9 @@ export const OPENAI_VOICES = [ export const DEFAULT_POLLING_INTERVAL = 5000; export const DEFAULT_TIMEOUT = 30000; export const DEFAULT_FILE_PICKER_TIMEOUT = 60000; +export const DISCORD_URL = "https://discord.com/invite/EqksyE2EX9"; +export const GITHUB_URL = "https://github.com/langflow-ai/langflow"; +export const TWITTER_URL = "https://x.com/langflow_ai"; +export const DOCS_URL = "https://docs.langflow.org"; export const UUID_PARSING_ERROR = "uuid_parsing"; diff --git a/src/frontend/src/controllers/API/index.ts b/src/frontend/src/controllers/API/index.ts index 28289cce8..495ba4e2f 100644 --- a/src/frontend/src/controllers/API/index.ts +++ b/src/frontend/src/controllers/API/index.ts @@ -10,6 +10,8 @@ import { FlowStyleType, FlowType } from "../../types/flow"; import { StoreComponentResponse } from "../../types/store"; const GITHUB_API_URL = "https://api.github.com"; +const DISCORD_API_URL = + "https://discord.com/api/v9/invites/EqksyE2EX9?with_counts=true"; export async function getRepoStars(owner: string, repo: string) { try { @@ -21,6 +23,15 @@ export async function getRepoStars(owner: string, repo: string) { } } +export async function getDiscordCount() { + try { + const response = await api.get(DISCORD_API_URL); + return response?.data.approximate_member_count; + } catch (error) { + console.error("Error fetching repository data:", error); + return null; + } +} export async function createApiKey(name: string) { try { const res = await api.post(`${BASE_URL_API}api_key/`, { name }); diff --git a/src/frontend/src/controllers/API/queries/auth/use-get-user.ts b/src/frontend/src/controllers/API/queries/auth/use-get-user.ts index 1f5c73ab9..cf7bb1866 100644 --- a/src/frontend/src/controllers/API/queries/auth/use-get-user.ts +++ b/src/frontend/src/controllers/API/queries/auth/use-get-user.ts @@ -1,16 +1,18 @@ +import useAuthStore from "@/stores/authStore"; import { UseMutationResult } from "@tanstack/react-query"; import { useMutationFunctionType, Users } from "../../../../types/api"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; import { UseRequestProcessor } from "../../services/request-processor"; - export const useGetUserData: useMutationFunctionType = ( options?, ) => { + const setUserData = useAuthStore((state) => state.setUserData); const { mutate } = UseRequestProcessor(); const getUserData = async () => { const response = await api.get(`${getURL("USERS")}/whoami`); + setUserData(response["data"]); return response["data"]; }; diff --git a/src/frontend/src/controllers/API/queries/version/use-get-version.ts b/src/frontend/src/controllers/API/queries/version/use-get-version.ts index 0282f99f0..134c74e08 100644 --- a/src/frontend/src/controllers/API/queries/version/use-get-version.ts +++ b/src/frontend/src/controllers/API/queries/version/use-get-version.ts @@ -7,6 +7,7 @@ import { UseRequestProcessor } from "../../services/request-processor"; interface versionQueryResponse { version: string; package: string; + main_version: string; } export const useGetVersionQuery: useQueryFunctionType< @@ -22,7 +23,9 @@ export const useGetVersionQuery: useQueryFunctionType< const responseFn = async () => { const { data } = await getVersionFn(); const refreshVersion = useDarkStore.getState().refreshVersion; + const refreshLatestVersion = useDarkStore.getState().refreshLatestVersion; refreshVersion(data.version); + refreshLatestVersion(data.main_version); return data; }; diff --git a/src/frontend/src/icons/Twitter X/TwitterX.jsx b/src/frontend/src/icons/Twitter X/TwitterX.jsx new file mode 100644 index 000000000..ec46eb97b --- /dev/null +++ b/src/frontend/src/icons/Twitter X/TwitterX.jsx @@ -0,0 +1,46 @@ +const TwitterXSVG = (props) => { + return props.isdark === "true" ? ( + + + + + + + + ) : ( + + + + ); +}; + +export default TwitterXSVG; diff --git a/src/frontend/src/icons/Twitter X/iconX.svg b/src/frontend/src/icons/Twitter X/iconX.svg new file mode 100644 index 000000000..0ede3f359 --- /dev/null +++ b/src/frontend/src/icons/Twitter X/iconX.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/icons/Twitter X/icons8-x.svg b/src/frontend/src/icons/Twitter X/icons8-x.svg new file mode 100644 index 000000000..69cf5fec6 --- /dev/null +++ b/src/frontend/src/icons/Twitter X/icons8-x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/icons/Twitter X/index.tsx b/src/frontend/src/icons/Twitter X/index.tsx new file mode 100644 index 000000000..57c9b7e43 --- /dev/null +++ b/src/frontend/src/icons/Twitter X/index.tsx @@ -0,0 +1,11 @@ +import { useDarkStore } from "@/stores/darkStore"; +import React, { forwardRef } from "react"; +import TwitterXSVG from "./TwitterX.jsx"; + +export const TwitterXIcon = forwardRef< + SVGSVGElement, + React.PropsWithChildren<{}> +>((props, ref) => { + const isdark = useDarkStore((state) => state.dark).toString(); + return ; +}); diff --git a/src/frontend/src/icons/lucideIcons.ts b/src/frontend/src/icons/lucideIcons.ts index 7b03a979e..25231459e 100644 --- a/src/frontend/src/icons/lucideIcons.ts +++ b/src/frontend/src/icons/lucideIcons.ts @@ -231,6 +231,7 @@ import { ZoomIn, ZoomOut, } from "lucide-react"; +import { TwitterXIcon } from "./Twitter X"; // Create a map of eagerly loaded Lucide icons export const lucideIcons = { @@ -464,4 +465,5 @@ export const lucideIcons = { Zap, ZoomIn, ZoomOut, + TwitterXIcon, }; diff --git a/src/frontend/src/modals/deleteConfirmationModal/index.tsx b/src/frontend/src/modals/deleteConfirmationModal/index.tsx index a0eb1200f..0ac8e1bf3 100644 --- a/src/frontend/src/modals/deleteConfirmationModal/index.tsx +++ b/src/frontend/src/modals/deleteConfirmationModal/index.tsx @@ -61,6 +61,7 @@ export default function DeleteConfirmationModal({ onClick={(e) => e.stopPropagation()} className="mr-1" variant="outline" + data-testid="btn_cancel_delete_confirmation_modal" > Cancel @@ -72,6 +73,7 @@ export default function DeleteConfirmationModal({ onClick={(e) => { onConfirm(e); }} + data-testid="btn_delete_delete_confirmation_modal" > Delete diff --git a/src/frontend/src/pages/AppInitPage/index.tsx b/src/frontend/src/pages/AppInitPage/index.tsx index 708caaea3..ea2303b5e 100644 --- a/src/frontend/src/pages/AppInitPage/index.tsx +++ b/src/frontend/src/pages/AppInitPage/index.tsx @@ -15,6 +15,9 @@ import { LoadingPage } from "../LoadingPage"; export function AppInitPage() { const refreshStars = useDarkStore((state) => state.refreshStars); + const refreshDiscordCount = useDarkStore( + (state) => state.refreshDiscordCount, + ); const isLoading = useFlowsManagerStore((state) => state.isLoading); const { isFetched: isLoaded } = useCustomPrimaryLoading(); @@ -31,6 +34,7 @@ export function AppInitPage() { useEffect(() => { if (isFetched) { refreshStars(); + refreshDiscordCount(); } if (isConfigFetched) { diff --git a/src/frontend/src/pages/MainPage/components/dropdown/index.tsx b/src/frontend/src/pages/MainPage/components/dropdown/index.tsx index 907128bee..1b1144c43 100644 --- a/src/frontend/src/pages/MainPage/components/dropdown/index.tsx +++ b/src/frontend/src/pages/MainPage/components/dropdown/index.tsx @@ -93,6 +93,7 @@ const DropdownComponent = ({ setOpenDelete(true); }} className="cursor-pointer text-destructive" + data-testid="btn_delete_dropdown_menu" > +  Create first flow; + +const EXTERNAL_LINK_ICON_CLASS = + "absolute right-6 top-[35px] h-4 w-4 shrink-0 translate-x-0 opacity-0 transition-all duration-300 group-hover:translate-x-1 group-hover:opacity-100"; + +export const EmptyPageCommunity = ({ + setOpenModal, +}: { + setOpenModal: (open: boolean) => void; +}) => { + const handleFileDrop = useFileDrop(undefined); + const folders = useFolderStore((state) => state.folders); + const userData = useAuthStore(useShallow((state) => state.userData)); + const stars: number | undefined = useDarkStore((state) => state.stars); + const discordCount: number = useDarkStore((state) => state.discordCount); + const { mutate: updateUser } = useUpdateUser(); + const { mutate: mutateLoggedUser } = useGetUserData(); + + const handleUserTrack = (key: string) => () => { + const optins = userData?.optins ?? {}; + optins[key] = true; + updateUser( + { + user_id: userData?.id!, + user: { optins }, + }, + { + onSuccess: () => { + mutateLoggedUser({}); + }, + }, + ); + }; + + return ( + + +
+
+
+
+ Langflow Logo Light +
+
+ Langflow Logo Dark +
+ + {EMPTY_PAGE_TITLE} + + + + {folders?.length > 1 + ? EMPTY_PAGE_FOLDER_DESCRIPTION + : EMPTY_PAGE_DESCRIPTION} + +
+ +
+ + + + + +
+
+
+

+ {EMPTY_PAGE_DRAG_AND_DROP_TEXT} +

+
+
+ ); +}; + +export default EmptyPageCommunity; diff --git a/src/frontend/src/pages/MainPage/pages/enchanced-beam-effect.tsx b/src/frontend/src/pages/MainPage/pages/enchanced-beam-effect.tsx new file mode 100644 index 000000000..c52a62510 --- /dev/null +++ b/src/frontend/src/pages/MainPage/pages/enchanced-beam-effect.tsx @@ -0,0 +1,43 @@ +import { cn } from "@/utils/utils"; +import { ReactNode } from "react"; +import { BorderBeam } from "../../../components/ui/border-beams"; + +interface EnhancedBeamEffectProps { + children: ReactNode; + className?: string; + primaryColor?: string; + secondaryColor?: string; + size?: number; +} + +export const EnhancedBeamEffect = ({ + children, + className, + primaryColor = "#C661B8", + secondaryColor = "#61C6B8", + size = 200, +}: EnhancedBeamEffectProps) => { + return ( +
+ {children} + + {/* Primary beam - larger, slower rotation */} + +
+ ); +}; + +export default EnhancedBeamEffect; diff --git a/src/frontend/src/pages/MainPage/pages/index.tsx b/src/frontend/src/pages/MainPage/pages/main-page.tsx similarity index 95% rename from src/frontend/src/pages/MainPage/pages/index.tsx rename to src/frontend/src/pages/MainPage/pages/main-page.tsx index 0c3470476..a4e85987b 100644 --- a/src/frontend/src/pages/MainPage/pages/index.tsx +++ b/src/frontend/src/pages/MainPage/pages/main-page.tsx @@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { Outlet } from "react-router-dom"; import ModalsComponent from "../components/modalsComponent"; +import EmptyPageCommunity from "./empty-page"; import EmptyPage from "./emptyPage"; export default function CollectionPage(): JSX.Element { @@ -80,7 +81,9 @@ export default function CollectionPage(): JSX.Element { {flows?.length !== examples?.length || folders?.length > 1 ? ( ) : ( - + // + + )}
) : ( diff --git a/src/frontend/src/routes.tsx b/src/frontend/src/routes.tsx index 0659310eb..b87a63d0d 100644 --- a/src/frontend/src/routes.tsx +++ b/src/frontend/src/routes.tsx @@ -23,9 +23,9 @@ import { AppWrapperPage } from "./pages/AppWrapperPage"; 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 CollectionPage from "./pages/MainPage/pages/main-page"; import SettingsPage from "./pages/SettingsPage"; import ApiKeysPage from "./pages/SettingsPage/pages/ApiKeysPage"; import GeneralPage from "./pages/SettingsPage/pages/GeneralPage"; diff --git a/src/frontend/src/stores/darkStore.ts b/src/frontend/src/stores/darkStore.ts index 6e3c28afa..444a76af2 100644 --- a/src/frontend/src/stores/darkStore.ts +++ b/src/frontend/src/stores/darkStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { getRepoStars } from "../controllers/API"; +import { getDiscordCount, getRepoStars } from "../controllers/API"; import { DarkStoreType } from "../types/zustand/dark"; const startedStars = Number(window.localStorage.getItem("githubStars")) ?? 0; @@ -8,6 +8,10 @@ export const useDarkStore = create((set, get) => ({ dark: JSON.parse(window.localStorage.getItem("isDark")!) ?? false, stars: startedStars, version: "", + latestVersion: "", + refreshLatestVersion: (v: string) => { + set(() => ({ latestVersion: v })); + }, setDark: (dark) => { set(() => ({ dark: dark })); window.localStorage.setItem("isDark", dark.toString()); @@ -40,4 +44,10 @@ export const useDarkStore = create((set, get) => ({ }); } }, + discordCount: 0, + refreshDiscordCount: () => { + getDiscordCount().then((res) => { + set(() => ({ discordCount: res })); + }); + }, })); diff --git a/src/frontend/src/style/index.css b/src/frontend/src/style/index.css index 2f2d0fef2..d522ff4b8 100644 --- a/src/frontend/src/style/index.css +++ b/src/frontend/src/style/index.css @@ -69,6 +69,7 @@ --round-btn-shadow: #00000063; --ice: #31a3cc; --selected: #2196f3; + --discord-color: #5765f2; --error-background: #fef2f2; --error-foreground: #991b1b; @@ -194,6 +195,7 @@ --accent-amber: 48, 96%, 89%; --accent-amber-foreground: 26 90.5% 37.1%; --red-foreground: 0 90.6% 70.8%; + --indigo-foreground: 243.7 54.5% 41.4%; } .dark { @@ -344,6 +346,8 @@ --hover: #1a202e; --disabled-run: #6366f1; --selected: #0369a1; + --discord-color: #5765f2; + --filter-foreground: #eef2ff; --filter-background: #4e46e599; @@ -450,5 +454,6 @@ --accent-amber: 22, 78%, 26%; --accent-amber-foreground: 45.9 96.7% 64.5%; --red-foreground: 0 72.2% 50.6%; + --indigo-foreground: 234.5 89.5% 73.9%; } } diff --git a/src/frontend/src/types/api/index.ts b/src/frontend/src/types/api/index.ts index 651717c3e..ebbe5115e 100644 --- a/src/frontend/src/types/api/index.ts +++ b/src/frontend/src/types/api/index.ts @@ -157,6 +157,11 @@ export type changeUser = { is_superuser?: boolean; password?: string; profile_image?: string; + optins?: { + github_starred?: boolean; + discord_clicked?: boolean; + dialog_dismissed?: boolean; + }; }; export type resetPasswordType = { @@ -172,6 +177,11 @@ export type Users = { profile_image: string; create_at: Date; updated_at: Date; + optins?: { + github_starred?: boolean; + discord_clicked?: boolean; + dialog_dismissed?: boolean; + }; }; export type Component = { diff --git a/src/frontend/src/types/zustand/dark/index.ts b/src/frontend/src/types/zustand/dark/index.ts index f71b98bde..ee696af83 100644 --- a/src/frontend/src/types/zustand/dark/index.ts +++ b/src/frontend/src/types/zustand/dark/index.ts @@ -2,7 +2,11 @@ export type DarkStoreType = { dark: boolean; stars: number; version: string; + latestVersion: string; setDark: (dark: boolean) => void; refreshVersion: (v: string) => void; + refreshLatestVersion: (v: string) => void; refreshStars: () => void; + discordCount: number; + refreshDiscordCount: () => void; }; diff --git a/src/frontend/src/utils/styleUtils.ts b/src/frontend/src/utils/styleUtils.ts index 9535ac028..cb7a2d8bf 100644 --- a/src/frontend/src/utils/styleUtils.ts +++ b/src/frontend/src/utils/styleUtils.ts @@ -421,6 +421,7 @@ export const nodeIconToDisplayIconMap: Record = { ScrapeGraphMarkdownifyApi: "ScrapeGraph", Unlink: "UnlinkIcon", note: "StickyNote", + TwitterXIcon: "TwitterXIcon", }; export const getLucideIconName = (name: string): string => { diff --git a/src/frontend/src/utils/utils.ts b/src/frontend/src/utils/utils.ts index 0152ed73d..df9ec1f03 100644 --- a/src/frontend/src/utils/utils.ts +++ b/src/frontend/src/utils/utils.ts @@ -872,3 +872,15 @@ export const convertUTCToLocalTimezone = (timestamp: string) => { const localTimezone = moment.tz.guess(); return moment.utc(timestamp).tz(localTimezone).format("MM/DD/YYYY HH:mm:ss"); }; + +export const formatNumber = (num: number | undefined): string => { + if (num === undefined) return "0"; + + if (num >= 1000000) { + return (num / 1000000).toFixed(0) + "M"; + } + if (num >= 1000) { + return (num / 1000).toFixed(0) + "k"; + } + return num?.toString(); +}; diff --git a/src/frontend/tailwind.config.mjs b/src/frontend/tailwind.config.mjs index ee310be15..424eec9c5 100644 --- a/src/frontend/tailwind.config.mjs +++ b/src/frontend/tailwind.config.mjs @@ -309,6 +309,8 @@ const config = { "slider-input-border": "var(--slider-input-border)", "zinc-foreground": "hsl(var(--zinc-foreground))", "red-foreground": "hsl(var(--red-foreground))", + "indigo-foreground": "hsl(var(--indigo-foreground))", + "discord-color": "var(--discord-color)", }, borderRadius: { lg: `var(--radius)`, diff --git a/src/frontend/tests/core/features/user-progress-track.spec.ts b/src/frontend/tests/core/features/user-progress-track.spec.ts new file mode 100644 index 000000000..6bcd1c11c --- /dev/null +++ b/src/frontend/tests/core/features/user-progress-track.spec.ts @@ -0,0 +1,242 @@ +import { expect, test } from "@playwright/test"; +import { DISCORD_URL, GITHUB_URL } from "../../../src/constants/constants"; +import { addNewUserAndLogin } from "../../utils/add-new-user-and-loggin"; +import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; + +test( + "admin user must be able to track their progress in getting started", + { tag: ["@release", "@api"] }, + async ({ page, context }) => { + await page.goto("/"); + + // Wait for any loading text to disappear + await page.waitForSelector('text="Loading"', { + state: "hidden", + timeout: 30000, + }); + + await page.waitForTimeout(2000); + + let emptyButton = page.getByTestId("new_project_btn_empty_page"); + while ((await emptyButton.count()) === 0) { + await page.getByTestId("home-dropdown-menu").first().click(); + await page.getByTestId("btn_delete_dropdown_menu").first().click(); + await page + .getByTestId("btn_delete_delete_confirmation_modal") + .first() + .click(); + await page.waitForTimeout(1000); + emptyButton = page.getByTestId("new_project_btn_empty_page"); + } + + await expect(emptyButton).toBeVisible(); + await expect(page.getByTestId("mainpage_title")).toBeVisible(); + await expect(page.getByTestId("empty_page_description")).toBeVisible(); + await expect(page.getByTestId("empty_page_github_button")).toBeVisible(); + await expect(page.getByTestId("empty_page_discord_button")).toBeVisible(); + await expect( + page.getByTestId("empty_page_drag_and_drop_text"), + ).toBeVisible(); + await expect(page.getByTestId("app-header")).not.toBeVisible(); + + await page.getByTestId("empty_page_github_button").click(); + + const pagePromiseGithub = context.waitForEvent("page"); + + const newPageGithub = await pagePromiseGithub; + await newPageGithub.waitForTimeout(3000); + const newUrlGithub = newPageGithub.url(); + + await expect(newUrlGithub).toContain(GITHUB_URL); + + await newPageGithub.close(); + + await expect(page.getByTestId("mainpage_title")).toBeVisible(); + await expect(page.getByTestId("empty_page_description")).toBeVisible(); + + await page.getByTestId("new_project_btn_empty_page").click(); + + await page.getByTestId("side_nav_options_all-templates").click(); + await page.getByRole("heading", { name: "Basic Prompting" }).click(); + + await page.waitForSelector('[data-testid="icon-ChevronLeft"]', { + timeout: 100000, + }); + + await page.getByTestId("icon-ChevronLeft").first().click(); + + await page.waitForSelector('[data-testid="home-dropdown-menu"]', { + timeout: 100000, + }); + + await expect(page.getByTestId("app-header")).toBeVisible(); + await expect( + page.getByTestId("github_starred_icon_get_started"), + ).toBeVisible(); + await expect( + page.getByTestId("create_flow_icon_get_started"), + ).toBeVisible(); + await expect( + page.getByTestId("discord_joined_icon_get_started"), + ).not.toBeVisible(); + await expect( + page.getByTestId("get_started_progress_percentage").first(), + ).toHaveText("66%"); + + await page.getByTestId("discord_joined_btn_get_started").click(); + const pagePromiseDiscord = context.waitForEvent("page"); + + const newPageDiscord = await pagePromiseDiscord; + await newPageDiscord.waitForTimeout(3000); + const newUrlDiscord = newPageDiscord.url(); + + await expect(newUrlDiscord).toContain(DISCORD_URL); + + await newPageDiscord.close(); + + await expect(page.getByTestId("app-header")).toBeVisible(); + await expect( + page.getByTestId("discord_joined_icon_get_started"), + ).toBeVisible(); + await expect( + page.getByTestId("create_flow_icon_get_started"), + ).toBeVisible(); + await expect( + page.getByTestId("github_starred_icon_get_started"), + ).toBeVisible(); + await expect( + page.getByTestId("get_started_progress_percentage").first(), + ).toHaveText("100%"); + + await page.getByTestId("close_get_started_dialog").click(); + + await expect( + page.getByTestId("discord_joined_icon_get_started"), + ).not.toBeVisible(); + await expect( + page.getByTestId("create_flow_icon_get_started"), + ).not.toBeVisible(); + await expect( + page.getByTestId("github_starred_icon_get_started"), + ).not.toBeVisible(); + }, +); + +test( + "normal user must be able to track their progress in getting started", + { tag: ["@release", "@api"] }, + async ({ page, context }) => { + await addNewUserAndLogin(page); + + // Wait for any loading text to disappear + await page.waitForSelector('text="Loading"', { + state: "hidden", + timeout: 30000, + }); + + await page.waitForTimeout(2000); + + let emptyButton = page.getByTestId("new_project_btn_empty_page"); + while ((await emptyButton.count()) === 0) { + await page.getByTestId("home-dropdown-menu").first().click(); + await page.getByTestId("btn_delete_dropdown_menu").first().click(); + await page + .getByTestId("btn_delete_delete_confirmation_modal") + .first() + .click(); + await page.waitForTimeout(1000); + emptyButton = page.getByTestId("new_project_btn_empty_page"); + } + + await expect(emptyButton).toBeVisible(); + await expect(page.getByTestId("mainpage_title")).toBeVisible(); + await expect(page.getByTestId("empty_page_description")).toBeVisible(); + await expect(page.getByTestId("empty_page_github_button")).toBeVisible(); + await expect(page.getByTestId("empty_page_discord_button")).toBeVisible(); + await expect( + page.getByTestId("empty_page_drag_and_drop_text"), + ).toBeVisible(); + await expect(page.getByTestId("app-header")).not.toBeVisible(); + + await page.getByTestId("empty_page_github_button").click(); + + const pagePromiseGithub = context.waitForEvent("page"); + + const newPageGithub = await pagePromiseGithub; + await newPageGithub.waitForTimeout(3000); + const newUrlGithub = newPageGithub.url(); + + await expect(newUrlGithub).toContain(GITHUB_URL); + + await newPageGithub.close(); + + await expect(page.getByTestId("mainpage_title")).toBeVisible(); + await expect(page.getByTestId("empty_page_description")).toBeVisible(); + + await page.getByTestId("new_project_btn_empty_page").click(); + + await page.getByTestId("side_nav_options_all-templates").click(); + await page.getByRole("heading", { name: "Basic Prompting" }).click(); + + await page.waitForSelector('[data-testid="icon-ChevronLeft"]', { + timeout: 100000, + }); + + await page.getByTestId("icon-ChevronLeft").first().click(); + + await page.waitForSelector('[data-testid="home-dropdown-menu"]', { + timeout: 100000, + }); + + await expect(page.getByTestId("app-header")).toBeVisible(); + await expect( + page.getByTestId("github_starred_icon_get_started"), + ).toBeVisible(); + await expect( + page.getByTestId("create_flow_icon_get_started"), + ).toBeVisible(); + await expect( + page.getByTestId("discord_joined_icon_get_started"), + ).not.toBeVisible(); + await expect( + page.getByTestId("get_started_progress_percentage").first(), + ).toHaveText("66%"); + + await page.getByTestId("discord_joined_btn_get_started").click(); + const pagePromiseDiscord = context.waitForEvent("page"); + + const newPageDiscord = await pagePromiseDiscord; + await newPageDiscord.waitForTimeout(3000); + const newUrlDiscord = newPageDiscord.url(); + + await expect(newUrlDiscord).toContain(DISCORD_URL); + + await newPageDiscord.close(); + + await expect(page.getByTestId("app-header")).toBeVisible(); + await expect( + page.getByTestId("discord_joined_icon_get_started"), + ).toBeVisible(); + await expect( + page.getByTestId("create_flow_icon_get_started"), + ).toBeVisible(); + await expect( + page.getByTestId("github_starred_icon_get_started"), + ).toBeVisible(); + await expect( + page.getByTestId("get_started_progress_percentage").first(), + ).toHaveText("100%"); + + await page.getByTestId("close_get_started_dialog").click(); + + await expect( + page.getByTestId("discord_joined_icon_get_started"), + ).not.toBeVisible(); + await expect( + page.getByTestId("create_flow_icon_get_started"), + ).not.toBeVisible(); + await expect( + page.getByTestId("github_starred_icon_get_started"), + ).not.toBeVisible(); + }, +); diff --git a/src/frontend/tests/utils/add-new-user-and-loggin.ts b/src/frontend/tests/utils/add-new-user-and-loggin.ts new file mode 100644 index 000000000..21fdcbaed --- /dev/null +++ b/src/frontend/tests/utils/add-new-user-and-loggin.ts @@ -0,0 +1,103 @@ +import { expect, Page } from "@playwright/test"; + +export const addNewUserAndLogin = async (page: Page) => { + await page.route("**/api/v1/auto_login", (route) => { + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ + detail: { auto_login: false }, + }), + }); + }); + + await page.addInitScript(() => { + window.process = window.process || {}; + + const newEnv = { ...window.process.env, LANGFLOW_AUTO_LOGIN: "false" }; + + Object.defineProperty(window.process, "env", { + value: newEnv, + writable: true, + configurable: true, + }); + + sessionStorage.setItem("testMockAutoLogin", "true"); + }); + + const randomName = Math.random().toString(36).substring(5); + const randomPassword = Math.random().toString(36).substring(5); + + await page.goto("/"); + + await page.waitForSelector("text=sign in to langflow", { timeout: 30000 }); + + await page.getByPlaceholder("Username").fill("langflow"); + await page.getByPlaceholder("Password").fill("langflow"); + + await page.evaluate(() => { + sessionStorage.removeItem("testMockAutoLogin"); + }); + + await page.getByRole("button", { name: "Sign In" }).click(); + + await page.waitForSelector('[data-testid="mainpage_title"]', { + timeout: 30000, + }); + + await page.waitForSelector('[id="new-project-btn"]', { + timeout: 30000, + }); + + await page.getByTestId("user-profile-settings").click(); + + await page.getByText("Admin Page", { exact: true }).click(); + + //CRUD an user + await page.getByText("New User", { exact: true }).click(); + + await page.getByPlaceholder("Username").last().fill(randomName); + await page.locator('input[name="password"]').fill(randomPassword); + await page.locator('input[name="confirmpassword"]').fill(randomPassword); + + await page.waitForSelector("#is_active", { + timeout: 1500, + }); + + await page.locator("#is_active").click(); + + await page.getByText("Save", { exact: true }).click(); + + await page.waitForSelector("text=new user added", { timeout: 30000 }); + + await expect(page.getByText(randomName, { exact: true })).toBeVisible({ + timeout: 2000, + }); + + await page.waitForSelector("[data-testid='user-profile-settings']", { + timeout: 1500, + }); + + await page.getByTestId("user-profile-settings").click(); + + await page.evaluate(() => { + sessionStorage.setItem("testMockAutoLogin", "true"); + }); + + await page.getByText("Logout", { exact: true }).click(); + + await page.waitForSelector("text=sign in to langflow", { timeout: 30000 }); + + await page.getByPlaceholder("Username").fill(randomName); + await page.getByPlaceholder("Password").fill(randomPassword); + + await page.waitForSelector("text=Sign in", { + timeout: 1500, + }); + + await page.getByRole("button", { name: "Sign In" }).click(); + + await page.evaluate(() => { + sessionStorage.removeItem("testMockAutoLogin"); + }); +}; diff --git a/tasks/20240610_165500_background_gradient_border_styling.md b/tasks/20240610_165500_background_gradient_border_styling.md new file mode 100644 index 000000000..bbdc5cb28 --- /dev/null +++ b/tasks/20240610_165500_background_gradient_border_styling.md @@ -0,0 +1,56 @@ +# Task: Gradient Border Styling Refinement + +**Status**: Completed + +## Analysis + +- [x] Requirements + - [x] Match the gradient border style of the GitHub card shown in the reference image + - [x] Ensure a consistent purple hue that fades from top to bottom + - [x] Maintain proper dark background for content +- [x] Challenges + - [x] Achieving precise gradient fade matching the reference image + - [x] Balancing border visibility while maintaining subtle effect +- [x] Dependencies + - [x] Existing BackgroundGradient component + +## Plan + +- [x] Step 1: Review the current implementation + - [x] Analyze existing gradient colors and opacity values + - [x] Compare with reference image for differences +- [x] Step 2: Adjust color and gradient parameters + - [x] Modify gradient colors to match the purple hue + - [x] Fine-tune opacity values for natural fade + - [x] Ensure proper background color for inner content +- [x] Step 3: Optimize hover effects + - [x] Refine blur and opacity transitions on hover + - [x] Test with different content types + +## Execution + +- [x] Modify gradient parameters + - [x] Update gradient direction and opacity stops + - [x] Simplify to single-color fade for consistency +- [x] Adjust container styles + - [x] Set proper padding for border thickness + - [x] Ensure rounded corners match reference +- [x] Test and refine + - [x] Compare implementation with reference image + - [x] Make final adjustments to achieve exact match + +## Summary + +- [x] Files modified: `src/frontend/src/components/ui/background-gradient.tsx` +- [x] Dependencies added/changed: None +- [x] Edge cases considered: Different screen sizes, container dimensions +- [x] Known limitations: Exact color reproduction may vary slightly based on monitor calibration +- [x] Future impact points: Component can be reused across the application for consistent styling + +### Implementation Details + +1. Changed the gradient to a simpler purple fade from top to bottom +2. Used opacity values of 0.7 at top and 0.3 at bottom for subtle fade +3. Reduced border thickness to 2px with `p-[2px]` +4. Made the hover effect more subtle with `opacity-60` instead of `opacity-100` +5. Changed blur on hover from `blur-lg` to `blur-md` for a less intense glow