mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(frontend): context menu cta (#13338)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ContextMenuContainer } from "#/components/features/context-menu/context-menu-container";
|
||||
|
||||
describe("ContextMenuContainer", () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
it("should render children", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock}>
|
||||
<div data-testid="child-1">Child 1</div>
|
||||
<div data-testid="child-2">Child 2</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply consistent base styling", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
expect(container).toHaveClass("bg-[#050505]");
|
||||
expect(container).toHaveClass("border");
|
||||
expect(container).toHaveClass("border-[#242424]");
|
||||
expect(container).toHaveClass("rounded-[12px]");
|
||||
expect(container).toHaveClass("p-[25px]");
|
||||
expect(container).toHaveClass("context-menu-box-shadow");
|
||||
});
|
||||
|
||||
it("should call onClose when clicking outside", async () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
await user.click(document.body);
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should render children in a flex row layout", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div data-testid="child-1">Child 1</div>
|
||||
<div data-testid="child-2">Child 2</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
const innerDiv = container.firstChild as HTMLElement;
|
||||
expect(innerDiv).toHaveClass("flex");
|
||||
expect(innerDiv).toHaveClass("flex-row");
|
||||
expect(innerDiv).toHaveClass("gap-4");
|
||||
});
|
||||
|
||||
it("should apply additional className when provided", () => {
|
||||
render(
|
||||
<ContextMenuContainer
|
||||
onClose={onCloseMock}
|
||||
testId="test-container"
|
||||
className="custom-class"
|
||||
>
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
expect(container).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ContextMenuCTA } from "#/components/features/context-menu/context-menu-cta";
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ContextMenuCTA", () => {
|
||||
it("should render the CTA component", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$ENTERPRISE_DESCRIPTION")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call trackSaasSelfhostedInquiry with location 'context_menu' when Learn More is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "context_menu",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render Learn More as a link with correct href and target", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise/",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should render the stacked icon", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const contentContainer = screen.getByTestId("context-menu-cta-content");
|
||||
const icon = contentContainer.querySelector("svg");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveAttribute("width", "40");
|
||||
expect(icon).toHaveAttribute("height", "40");
|
||||
});
|
||||
});
|
||||
@@ -11,23 +11,23 @@ vi.mock("posthog-js/react", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
const { ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
describe("EnterpriseBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Feature Flag", () => {
|
||||
it("should not render when proj_user_journey feature flag is disabled", () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
|
||||
const { container } = renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("EnterpriseBanner", () => {
|
||||
});
|
||||
|
||||
it("should render when proj_user_journey feature flag is enabled", () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
|
||||
@@ -18,6 +18,27 @@ import { OrganizationMember } from "#/types/org";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
|
||||
// Mock useBreakpoint hook
|
||||
vi.mock("#/hooks/use-breakpoint", () => ({
|
||||
useBreakpoint: vi.fn(() => false), // Default to desktop (not mobile)
|
||||
}));
|
||||
|
||||
// Mock feature flags
|
||||
const mockEnableProjUserJourney = vi.fn(() => true);
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
|
||||
}));
|
||||
|
||||
// Mock useTracking hook for CTA
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import the mocked modules
|
||||
import * as breakpoint from "#/hooks/use-breakpoint";
|
||||
|
||||
type UserContextMenuProps = GetComponentPropTypes<typeof UserContextMenu>;
|
||||
|
||||
function UserContextMenuWithRootOutlet({
|
||||
@@ -123,6 +144,9 @@ describe("UserContextMenu", () => {
|
||||
// Ensure clean state at the start of each test
|
||||
vi.restoreAllMocks();
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
// Reset feature flag and breakpoint mocks to defaults
|
||||
mockEnableProjUserJourney.mockReturnValue(true);
|
||||
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(false); // Desktop by default
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -630,4 +654,77 @@ describe("UserContextMenu", () => {
|
||||
// Verify that the dropdown shows the selected organization
|
||||
expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name);
|
||||
});
|
||||
|
||||
describe("Context Menu CTA", () => {
|
||||
it("should render the CTA component in SaaS mode on desktop with feature flag enabled", async () => {
|
||||
// Set SaaS mode
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("context-menu-cta")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the CTA component in OSS mode even with feature flag enabled", async () => {
|
||||
// Set OSS mode
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "oss" }),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the CTA component on mobile even in SaaS mode with feature flag enabled", async () => {
|
||||
// Set SaaS mode
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
// Set mobile mode
|
||||
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(true);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the CTA component when feature flag is disabled in SaaS mode", async () => {
|
||||
// Set SaaS mode
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
// Disable the feature flag
|
||||
mockEnableProjUserJourney.mockReturnValue(false);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import DeviceVerify from "#/routes/device-verify";
|
||||
|
||||
const { useIsAuthedMock, PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
const { useIsAuthedMock, ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
useIsAuthedMock: vi.fn(() => ({
|
||||
data: false as boolean | undefined,
|
||||
isLoading: false,
|
||||
})),
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
@@ -24,7 +24,7 @@ vi.mock("posthog-js/react", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -67,7 +67,7 @@ describe("DeviceVerify", () => {
|
||||
),
|
||||
);
|
||||
// Enable feature flag by default
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -254,7 +254,7 @@ describe("DeviceVerify", () => {
|
||||
});
|
||||
|
||||
it("should not include the EnterpriseBanner and be center-aligned when feature flag is disabled", async () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
|
||||
interface ContextMenuContainerProps {
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuContainer({
|
||||
children,
|
||||
onClose,
|
||||
testId,
|
||||
className,
|
||||
}: ContextMenuContainerProps) {
|
||||
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
// Base styling - same for ALL modes (SaaS, OSS, mobile, desktop)
|
||||
"absolute rounded-[12px] p-[25px]",
|
||||
"bg-[#050505] border border-[#242424]",
|
||||
"text-white overflow-hidden z-[9999]",
|
||||
"context-menu-box-shadow",
|
||||
// Positioning
|
||||
"right-0 md:right-auto md:left-full md:bottom-0",
|
||||
"w-fit",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-4 items-stretch">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Card } from "#/ui/card";
|
||||
import { CardTitle } from "#/ui/card-title";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import StackedIcon from "#/icons/stacked.svg?react";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
export function ContextMenuCTA() {
|
||||
const { t } = useTranslation();
|
||||
const { trackSaasSelfhostedInquiry } = useTracking();
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
trackSaasSelfhostedInquiry({ location: "context_menu" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
testId="context-menu-cta"
|
||||
theme="dark"
|
||||
className={cn(
|
||||
"w-[286px] min-h-[200px] rounded-[6px]",
|
||||
"flex-col justify-end",
|
||||
"cta-card-gradient",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="context-menu-cta-content"
|
||||
className={cn("flex flex-col gap-[11px] p-[25px]")}
|
||||
>
|
||||
<StackedIcon width={40} height={40} />
|
||||
|
||||
<CardTitle>{t(I18nKey.CTA$ENTERPRISE_TITLE)}</CardTitle>
|
||||
|
||||
<Typography.Text
|
||||
className={cn(
|
||||
"text-[#8C8C8C] font-inter font-normal",
|
||||
"text-[14px] leading-[20px]",
|
||||
)}
|
||||
>
|
||||
{t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="flex mt-auto">
|
||||
<a
|
||||
href="https://openhands.dev/enterprise/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleLearnMoreClick}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
"h-[40px] px-4 rounded-[4px]",
|
||||
"bg-[#050505] border border-[#242424]",
|
||||
"text-white hover:bg-[#0a0a0a]",
|
||||
"font-semibold text-sm",
|
||||
)}
|
||||
>
|
||||
{t(I18nKey.CTA$LEARN_MORE)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { usePostHog } from "posthog-js/react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { H2, Text } from "#/ui/typography";
|
||||
import CheckCircleFillIcon from "#/icons/check-circle-fill.svg?react";
|
||||
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
const ENTERPRISE_FEATURE_KEYS: I18nKey[] = [
|
||||
I18nKey.ENTERPRISE$FEATURE_DATA_PRIVACY,
|
||||
@@ -16,7 +16,7 @@ export function EnterpriseBanner() {
|
||||
const { t } = useTranslation();
|
||||
const posthog = usePostHog();
|
||||
|
||||
if (!PROJ_USER_JOURNEY()) {
|
||||
if (!ENABLE_PROJ_USER_JOURNEY()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export function NewConversation() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="flex-col p-5 gap-2.5 min-h-[286px] md:min-h-auto w-full">
|
||||
<CardTitle icon={<PlusIcon width={17} height={14} />}>
|
||||
{t(I18nKey.COMMON$START_FROM_SCRATCH)}
|
||||
</CardTitle>
|
||||
|
||||
@@ -56,6 +56,7 @@ export function OrgSelector() {
|
||||
label: getOrgDisplayName(org),
|
||||
})) || []
|
||||
}
|
||||
className="bg-[#1F1F1F66] border-[#242424]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { FiUsers } from "react-icons/fi";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { OrganizationUserRole } from "#/types/org";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { OrgSelector } from "../org/org-selector";
|
||||
@@ -18,11 +17,16 @@ import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
|
||||
import DocumentIcon from "#/icons/document.svg?react";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { ContextMenuContainer } from "../context-menu/context-menu-container";
|
||||
import { ContextMenuCTA } from "../context-menu/context-menu-cta";
|
||||
import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector";
|
||||
import { useBreakpoint } from "#/hooks/use-breakpoint";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
// Shared className for context menu list items in the user context menu
|
||||
const contextMenuListItemClassName = cn(
|
||||
"flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded",
|
||||
"flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded text-xs",
|
||||
);
|
||||
|
||||
interface UserContextMenuProps {
|
||||
@@ -40,9 +44,10 @@ export function UserContextMenu({
|
||||
const navigate = useNavigate();
|
||||
const { mutate: logout } = useLogout();
|
||||
const { isPersonalOrg } = useOrgTypeAndAccess();
|
||||
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
|
||||
const settingsNavItems = useSettingsNavItems();
|
||||
const shouldHideSelector = useShouldHideOrgSelector();
|
||||
const isMobile = useBreakpoint(768);
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// Filter out org routes since they're handled separately via buttons in this menu
|
||||
const navItems = settingsNavItems.filter(
|
||||
@@ -51,7 +56,10 @@ export function UserContextMenu({
|
||||
);
|
||||
|
||||
const isMember = type === "member";
|
||||
const isSaasMode = config?.app_mode === "saas";
|
||||
|
||||
// CTA only renders in SaaS desktop with feature flag enabled
|
||||
const showCta = isSaasMode && !isMobile && ENABLE_PROJ_USER_JOURNEY();
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
onClose();
|
||||
@@ -73,96 +81,93 @@ export function UserContextMenu({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="user-context-menu"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-72 flex flex-col gap-3 bg-tertiary border border-tertiary rounded-xl p-4 context-menu-box-shadow",
|
||||
"text-sm absolute left-full bottom-0 z-101",
|
||||
)}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{t(I18nKey.ORG$ACCOUNT)}
|
||||
</h3>
|
||||
<ContextMenuContainer testId="user-context-menu" onClose={onClose}>
|
||||
<div className="flex flex-col gap-3 w-[248px]">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{t(I18nKey.ORG$ACCOUNT)}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{!shouldHideSelector && (
|
||||
<div className="w-full relative">
|
||||
<OrgSelector />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{!shouldHideSelector && (
|
||||
<div className="w-full relative">
|
||||
<OrgSelector />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMember && !isPersonalOrg && (
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<ContextMenuListItem
|
||||
onClick={handleInviteMemberClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoPersonAddOutline className="text-white" size={16} />
|
||||
{t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageAccountClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoCardOutline className="text-white" size={16} />
|
||||
{t(I18nKey.COMMON$ORGANIZATION)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageOrganizationMembersClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<FiUsers className="text-white shrink-0" size={16} />
|
||||
{t(I18nKey.ORG$ORGANIZATION_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
<Divider className="my-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMember && !isPersonalOrg && (
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<ContextMenuListItem
|
||||
onClick={handleInviteMemberClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoPersonAddOutline className="text-white" size={14} />
|
||||
{t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageAccountClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoCardOutline className="text-white" size={14} />
|
||||
{t(I18nKey.COMMON$ORGANIZATION)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageOrganizationMembersClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<FiUsers className="text-white shrink-0" size={14} />
|
||||
{t(I18nKey.ORG$ORGANIZATION_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
<Divider className="my-1.5" />
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full text-xs"
|
||||
>
|
||||
{React.cloneElement(item.icon, {
|
||||
className: "text-white",
|
||||
width: 16,
|
||||
height: 16,
|
||||
} as React.SVGProps<SVGSVGElement>)}
|
||||
{t(item.text)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<a
|
||||
href="https://docs.openhands.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full text-xs"
|
||||
>
|
||||
{React.cloneElement(item.icon, {
|
||||
className: "text-white",
|
||||
width: 14,
|
||||
height: 14,
|
||||
} as React.SVGProps<SVGSVGElement>)}
|
||||
{t(item.text)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<DocumentIcon className="text-white" width={16} height={16} />
|
||||
{t(I18nKey.SIDEBAR$DOCS)}
|
||||
</a>
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<a
|
||||
href="https://docs.openhands.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
|
||||
>
|
||||
<DocumentIcon className="text-white" width={14} height={14} />
|
||||
{t(I18nKey.SIDEBAR$DOCS)}
|
||||
</a>
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleLogout}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoLogOutOutline className="text-white" size={14} />
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem
|
||||
onClick={handleLogout}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoLogOutOutline className="text-white" size={16} />
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCta && <ContextMenuCTA />}
|
||||
</ContextMenuContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,13 @@ export const useTracking = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const trackSaasSelfhostedInquiry = ({ location }: { location: string }) => {
|
||||
posthog.capture("saas_selfhosted_inquiry", {
|
||||
location,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
trackLoginButtonClick,
|
||||
trackConversationCreated,
|
||||
@@ -134,5 +141,6 @@ export const useTracking = () => {
|
||||
trackCreditLimitReached,
|
||||
trackAddTeamMembersButtonClick,
|
||||
trackOnboardingCompleted,
|
||||
trackSaasSelfhostedInquiry,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1171,4 +1171,7 @@ export enum I18nKey {
|
||||
DEVICE$CONTINUE = "DEVICE$CONTINUE",
|
||||
DEVICE$AUTH_REQUIRED = "DEVICE$AUTH_REQUIRED",
|
||||
DEVICE$SIGN_IN_PROMPT = "DEVICE$SIGN_IN_PROMPT",
|
||||
CTA$ENTERPRISE_TITLE = "CTA$ENTERPRISE_TITLE",
|
||||
CTA$ENTERPRISE_DESCRIPTION = "CTA$ENTERPRISE_DESCRIPTION",
|
||||
CTA$LEARN_MORE = "CTA$LEARN_MORE",
|
||||
}
|
||||
|
||||
@@ -18734,5 +18734,53 @@
|
||||
"es": "Por favor, inicia sesión para autorizar tu dispositivo.",
|
||||
"tr": "Cihazınızı yetkilendirmek için lütfen giriş yapın.",
|
||||
"uk": "Будь ласка, увійдіть, щоб авторизувати свій пристрій."
|
||||
},
|
||||
"CTA$ENTERPRISE_TITLE": {
|
||||
"en": "Get OpenHands For Enterprise",
|
||||
"ja": "エンタープライズ向けOpenHandsを入手",
|
||||
"zh-CN": "获取企业版 OpenHands",
|
||||
"zh-TW": "獲取企業版 OpenHands",
|
||||
"ko-KR": "기업용 OpenHands 받기",
|
||||
"no": "Få OpenHands for bedrift",
|
||||
"it": "Ottieni OpenHands per Enterprise",
|
||||
"pt": "Obtenha o OpenHands para Enterprise",
|
||||
"es": "Obtén OpenHands para Empresas",
|
||||
"ar": "احصل على OpenHands للمؤسسات",
|
||||
"fr": "Obtenez OpenHands pour Entreprise",
|
||||
"tr": "Kurumsal OpenHands'i Edinin",
|
||||
"de": "OpenHands für Unternehmen",
|
||||
"uk": "Отримайте OpenHands для підприємств"
|
||||
},
|
||||
"CTA$ENTERPRISE_DESCRIPTION": {
|
||||
"en": "Cloud allows you to access OpenHands anywhere and coordinate with your team like never before.",
|
||||
"ja": "クラウドを使用すると、どこからでもOpenHandsにアクセスし、チームとこれまでにない方法で連携できます。",
|
||||
"zh-CN": "云端让您可以随时随地访问 OpenHands,并以前所未有的方式与团队协作。",
|
||||
"zh-TW": "雲端讓您可以隨時隨地存取 OpenHands,並以前所未有的方式與團隊協作。",
|
||||
"ko-KR": "클라우드를 통해 어디서나 OpenHands에 접속하고 팀과 이전과는 다른 방식으로 협업할 수 있습니다.",
|
||||
"no": "Cloud lar deg få tilgang til OpenHands hvor som helst og koordinere med teamet ditt som aldri før.",
|
||||
"it": "Cloud ti permette di accedere a OpenHands ovunque e coordinare con il tuo team come mai prima d'ora.",
|
||||
"pt": "O Cloud permite que você acesse o OpenHands de qualquer lugar e coordene com sua equipe como nunca antes.",
|
||||
"es": "Cloud le permite acceder a OpenHands desde cualquier lugar y coordinar con su equipo como nunca antes.",
|
||||
"ar": "يتيح لك Cloud الوصول إلى OpenHands من أي مكان والتنسيق مع فريقك بشكل لم يسبق له مثيل.",
|
||||
"fr": "Cloud vous permet d'accéder à OpenHands n'importe où et de coordonner avec votre équipe comme jamais auparavant.",
|
||||
"tr": "Cloud, OpenHands'e her yerden erişmenizi ve ekibinizle daha önce hiç olmadığı gibi koordinasyon sağlamanızı mümkün kılar.",
|
||||
"de": "Cloud ermöglicht Ihnen den Zugriff auf OpenHands von überall und die Koordination mit Ihrem Team wie nie zuvor.",
|
||||
"uk": "Cloud дозволяє отримати доступ до OpenHands будь-де та координувати роботу з вашою командою як ніколи раніше."
|
||||
},
|
||||
"CTA$LEARN_MORE": {
|
||||
"en": "Learn more",
|
||||
"ja": "詳細を見る",
|
||||
"zh-CN": "了解更多",
|
||||
"zh-TW": "了解更多",
|
||||
"ko-KR": "자세히 알아보기",
|
||||
"no": "Lær mer",
|
||||
"it": "Scopri di più",
|
||||
"pt": "Saiba mais",
|
||||
"es": "Más información",
|
||||
"ar": "اعرف المزيد",
|
||||
"fr": "En savoir plus",
|
||||
"tr": "Daha fazla bilgi",
|
||||
"de": "Mehr erfahren",
|
||||
"uk": "Дізнатися більше"
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/src/icons/stacked.svg
Normal file
6
frontend/src/icons/stacked.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M33.334 3.33398H6.66732C4.82637 3.33398 3.33398 4.82637 3.33398 6.66732V13.334C3.33398 15.1749 4.82637 16.6673 6.66732 16.6673H33.334C35.1749 16.6673 36.6673 15.1749 36.6673 13.334V6.66732C36.6673 4.82637 35.1749 3.33398 33.334 3.33398Z" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M33.334 23.334H6.66732C4.82637 23.334 3.33398 24.8264 3.33398 26.6673V33.334C3.33398 35.1749 4.82637 36.6673 6.66732 36.6673H33.334C35.1749 36.6673 36.6673 35.1749 36.6673 33.334V26.6673C36.6673 24.8264 35.1749 23.334 33.334 23.334Z" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 10H10.0167" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 30H10.0167" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 999 B |
@@ -5,7 +5,7 @@ import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { H1 } from "#/ui/typography";
|
||||
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
export default function DeviceVerify() {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,7 +16,7 @@ export default function DeviceVerify() {
|
||||
messageKey: I18nKey;
|
||||
} | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const showEnterpriseBanner = PROJ_USER_JOURNEY();
|
||||
const showEnterpriseBanner = ENABLE_PROJ_USER_JOURNEY();
|
||||
|
||||
// Get user_code from URL parameters
|
||||
const userCode = searchParams.get("user_code");
|
||||
|
||||
@@ -376,3 +376,9 @@
|
||||
animation: shine 2s linear infinite;
|
||||
background: radial-gradient(circle at center, rgb(24 24 27 / 85%), transparent) -200% 50% / 200% 100% no-repeat, #f4f4f5;
|
||||
}
|
||||
|
||||
/* CTA card gradient and shadow */
|
||||
.cta-card-gradient {
|
||||
background: radial-gradient(85.36% 123.38% at 50% 0%, rgba(255, 255, 255, 0.14) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
box-shadow: 0px 4px 6px -4px rgba(0, 0, 0, 0.1), 0px 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -2,43 +2,30 @@ import { ReactNode } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
const cardVariants = cva(
|
||||
"w-full flex flex-col rounded-[12px] p-[20px] border border-[#727987] bg-[#26282D] relative",
|
||||
{
|
||||
variants: {
|
||||
gap: {
|
||||
default: "gap-[10px]",
|
||||
large: "gap-6",
|
||||
},
|
||||
minHeight: {
|
||||
default: "min-h-[286px] md:min-h-auto",
|
||||
small: "min-h-[263.5px]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
gap: "default",
|
||||
minHeight: "default",
|
||||
const cardVariants = cva("flex", {
|
||||
variants: {
|
||||
theme: {
|
||||
default: "relative bg-[#26282D] border border-[#727987] rounded-xl",
|
||||
outlined: "relative bg-transparent border border-[#727987] rounded-xl",
|
||||
dark: "relative bg-black border border-[#242424] rounded-2xl",
|
||||
},
|
||||
},
|
||||
);
|
||||
defaultVariants: {
|
||||
theme: "default",
|
||||
},
|
||||
});
|
||||
|
||||
interface CardProps extends VariantProps<typeof cardVariants> {
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = "",
|
||||
testId,
|
||||
gap,
|
||||
minHeight,
|
||||
}: CardProps) {
|
||||
export function Card({ children, className, testId, theme }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className={cn(cardVariants({ gap, minHeight }), className)}
|
||||
className={cn(cardVariants({ theme }), className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -2,42 +2,53 @@ import React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
const contextMenuVariants = cva(
|
||||
"absolute bg-tertiary rounded-[6px] text-white overflow-hidden z-50 context-menu-box-shadow",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
compact: "py-1 px-1",
|
||||
default: "py-[6px] px-1",
|
||||
},
|
||||
layout: {
|
||||
vertical: "flex flex-col gap-2",
|
||||
},
|
||||
position: {
|
||||
top: "bottom-full",
|
||||
bottom: "top-full",
|
||||
},
|
||||
spacing: {
|
||||
default: "mt-2",
|
||||
},
|
||||
alignment: {
|
||||
left: "left-0",
|
||||
right: "right-0",
|
||||
},
|
||||
const contextMenuVariants = cva("text-white overflow-hidden z-50", {
|
||||
variants: {
|
||||
theme: {
|
||||
default:
|
||||
"absolute bg-tertiary rounded-[6px] context-menu-box-shadow py-[6px] px-1",
|
||||
naked: "relative",
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
layout: "vertical",
|
||||
spacing: "default",
|
||||
size: {
|
||||
compact: "py-1 px-1",
|
||||
default: "",
|
||||
},
|
||||
layout: {
|
||||
vertical: "flex flex-col gap-2",
|
||||
},
|
||||
position: {
|
||||
top: "bottom-full",
|
||||
bottom: "top-full",
|
||||
},
|
||||
spacing: {
|
||||
default: "mt-2",
|
||||
none: "",
|
||||
},
|
||||
alignment: {
|
||||
left: "left-0",
|
||||
right: "right-0",
|
||||
},
|
||||
},
|
||||
);
|
||||
compoundVariants: [
|
||||
{
|
||||
theme: "naked",
|
||||
className: "shadow-none",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
theme: "default",
|
||||
size: "default",
|
||||
layout: "vertical",
|
||||
spacing: "default",
|
||||
},
|
||||
});
|
||||
|
||||
interface ContextMenuProps {
|
||||
ref?: React.RefObject<HTMLUListElement | null>;
|
||||
testId?: string;
|
||||
children: React.ReactNode;
|
||||
className?: React.HTMLAttributes<HTMLUListElement>["className"];
|
||||
theme?: VariantProps<typeof contextMenuVariants>["theme"];
|
||||
size?: VariantProps<typeof contextMenuVariants>["size"];
|
||||
layout?: VariantProps<typeof contextMenuVariants>["layout"];
|
||||
position?: VariantProps<typeof contextMenuVariants>["position"];
|
||||
@@ -50,6 +61,7 @@ export function ContextMenu({
|
||||
children,
|
||||
className,
|
||||
ref,
|
||||
theme,
|
||||
size,
|
||||
layout,
|
||||
position,
|
||||
@@ -61,7 +73,14 @@ export function ContextMenu({
|
||||
data-testid={testId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
contextMenuVariants({ size, layout, position, spacing, alignment }),
|
||||
contextMenuVariants({
|
||||
theme,
|
||||
size,
|
||||
layout,
|
||||
position,
|
||||
spacing,
|
||||
alignment,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function DropdownMenu({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-10 w-full mt-1",
|
||||
"bg-[#454545] border border-[#727987] rounded-lg",
|
||||
"bg-[#1F1F1F] border border-[#242424] rounded-lg",
|
||||
"max-h-60 overflow-auto",
|
||||
!isOpen && "hidden",
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface DropdownProps {
|
||||
defaultValue?: DropdownOption;
|
||||
onChange?: (item: DropdownOption | null) => void;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
@@ -30,6 +31,7 @@ export function Dropdown({
|
||||
defaultValue,
|
||||
onChange,
|
||||
testId,
|
||||
className,
|
||||
}: DropdownProps) {
|
||||
const [inputValue, setInputValue] = useState(defaultValue?.label ?? "");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -98,6 +100,7 @@ export function Dropdown({
|
||||
"bg-tertiary border border-[#717888] rounded w-full p-2",
|
||||
"flex items-center gap-2",
|
||||
isDisabled && "cursor-not-allowed opacity-60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DropdownInput
|
||||
|
||||
@@ -20,4 +20,5 @@ export const ENABLE_TRAJECTORY_REPLAY = () =>
|
||||
export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING");
|
||||
export const ENABLE_SANDBOX_GROUPING = () =>
|
||||
loadFeatureFlag("SANDBOX_GROUPING");
|
||||
export const PROJ_USER_JOURNEY = () => loadFeatureFlag("PROJ_USER_JOURNEY");
|
||||
export const ENABLE_PROJ_USER_JOURNEY = () =>
|
||||
loadFeatureFlag("PROJ_USER_JOURNEY");
|
||||
|
||||
Reference in New Issue
Block a user