From 7bbf1237cf3fc946a929a87af8256034ae7ecf2c Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Fri, 26 Sep 2025 11:06:31 -0600 Subject: [PATCH 1/2] change embed page background --- .../features/ShareMenu/ShareMenuContentPublish.tsx | 3 +++ apps/web/src/routes/embed.tsx | 8 +++++--- apps/web/src/styles/styles.css | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/features/ShareMenu/ShareMenuContentPublish.tsx b/apps/web/src/components/features/ShareMenu/ShareMenuContentPublish.tsx index 1044ad157..87f7e1735 100644 --- a/apps/web/src/components/features/ShareMenu/ShareMenuContentPublish.tsx +++ b/apps/web/src/components/features/ShareMenu/ShareMenuContentPublish.tsx @@ -15,6 +15,7 @@ import { useBusterNotifications } from '@/context/BusterNotifications'; import { useBuildLocation } from '@/context/Routes/useRouteBuilder'; import { cn } from '@/lib/classMerge'; import { createDayjsDate } from '@/lib/date'; +import { timeout } from '@/lib/timeout'; import type { ShareMenuContentBodyProps } from './ShareMenuContentBody'; export const ShareMenuContentPublish: React.FC = React.memo( @@ -73,6 +74,8 @@ export const ShareMenuContentPublish: React.FC = Reac } else { const _exhaustiveCheck: never = assetType; } + await timeout(100); + if (v) onCopyLink(true); }; const onSetPasswordProtected = async (v: boolean) => { diff --git a/apps/web/src/routes/embed.tsx b/apps/web/src/routes/embed.tsx index f50011105..fe83697a5 100644 --- a/apps/web/src/routes/embed.tsx +++ b/apps/web/src/routes/embed.tsx @@ -36,8 +36,10 @@ function RouteComponent() { } return ( - - - +
+ + + +
); } diff --git a/apps/web/src/styles/styles.css b/apps/web/src/styles/styles.css index 3345b9523..8b3fc4339 100644 --- a/apps/web/src/styles/styles.css +++ b/apps/web/src/styles/styles.css @@ -34,7 +34,7 @@ pre { html, body { - background-color: #f3f3f3; + background-color: var(--color-background-secondary); min-width: 800px; /* // @media (prefers-color-scheme: dark) { // background-color: #000000; From 2ab183b688f074cd3566530680ad392a3a09ae86 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Fri, 26 Sep 2025 11:27:33 -0600 Subject: [PATCH 2/2] added additional unit tests --- .../ShareMenuContentPublish.test.tsx | 359 ++++++++++++++++++ .../ShareMenu/ShareMenuInvite.test.tsx | 234 ++++++++++++ 2 files changed, 593 insertions(+) create mode 100644 apps/web/src/components/features/ShareMenu/ShareMenuContentPublish.test.tsx create mode 100644 apps/web/src/components/features/ShareMenu/ShareMenuInvite.test.tsx diff --git a/apps/web/src/components/features/ShareMenu/ShareMenuContentPublish.test.tsx b/apps/web/src/components/features/ShareMenu/ShareMenuContentPublish.test.tsx new file mode 100644 index 000000000..057c7f94e --- /dev/null +++ b/apps/web/src/components/features/ShareMenu/ShareMenuContentPublish.test.tsx @@ -0,0 +1,359 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import '@testing-library/jest-dom'; +import type { ShareMenuContentBodyProps } from './ShareMenuContentBody'; +import { ShareMenuContentPublish } from './ShareMenuContentPublish'; + +// Mock all external dependencies +const mockOnShareMetric = vi.fn(); +const mockOnShareDashboard = vi.fn(); +const mockOnShareCollection = vi.fn(); +const mockOnShareReport = vi.fn(); +const mockOnShareChat = vi.fn(); +const mockOnCopyLink = vi.fn(); +const mockTimeout = vi.fn().mockResolvedValue(true); + +// Mock the timeout utility +vi.mock('@/lib/timeout', () => ({ + timeout: (time?: number) => mockTimeout(time), +})); + +describe('onTogglePublish', () => { + const baseProps = { + assetId: 'test-asset-id', + password: 'test-password', + publicly_accessible: false, + onCopyLink: mockOnCopyLink, + publicExpirationDate: null, + embedLinkURL: 'https://example.com/embed', + } satisfies Partial; + + const baseDate = new Date('2023-12-25T10:00:00Z'); + const linkExpiry = baseDate; + + // Helper function to create the onTogglePublish function with mocked dependencies + const createOnTogglePublish = ( + assetType: ShareMenuContentBodyProps['assetType'], + _password = 'test-password', + linkExpiryParam: Date | null = null + ) => { + return async (v?: boolean) => { + const linkExp = linkExpiryParam ? linkExpiryParam.toISOString() : null; + const payload = { + id: baseProps.assetId, + params: { + publicly_accessible: v === undefined ? true : !!v, + public_password: _password || undefined, + public_expiry_date: linkExp || undefined, + }, + }; + + if (assetType === 'metric_file') { + await mockOnShareMetric(payload); + } else if (assetType === 'dashboard_file') { + await mockOnShareDashboard(payload); + } else if (assetType === 'collection') { + await mockOnShareCollection(payload); + } else if (assetType === 'report_file') { + await mockOnShareReport(payload); + } else if (assetType === 'chat') { + await mockOnShareChat(payload); + } else { + const _exhaustiveCheck: never = assetType; + } + await mockTimeout(100); + if (v) mockOnCopyLink(true); + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call onShareMetric for metric_file asset type with default publicly_accessible true', async () => { + const onTogglePublish = createOnTogglePublish('metric_file'); + + await onTogglePublish(); + + expect(mockOnShareMetric).toHaveBeenCalledWith({ + id: 'test-asset-id', + params: { + publicly_accessible: true, + public_password: 'test-password', + public_expiry_date: undefined, + }, + }); + expect(mockTimeout).toHaveBeenCalledWith(100); + expect(mockOnCopyLink).not.toHaveBeenCalled(); + }); + + it('should call onShareDashboard for dashboard_file asset type with explicit true value', async () => { + const onTogglePublish = createOnTogglePublish( + 'dashboard_file', + 'dashboard-password', + linkExpiry + ); + + await onTogglePublish(true); + + expect(mockOnShareDashboard).toHaveBeenCalledWith({ + id: 'test-asset-id', + params: { + publicly_accessible: true, + public_password: 'dashboard-password', + public_expiry_date: '2023-12-25T10:00:00.000Z', + }, + }); + expect(mockTimeout).toHaveBeenCalledWith(100); + expect(mockOnCopyLink).toHaveBeenCalledWith(true); + }); + + it('should call onShareCollection for collection asset type with false value', async () => { + const onTogglePublish = createOnTogglePublish('collection'); + + await onTogglePublish(false); + + expect(mockOnShareCollection).toHaveBeenCalledWith({ + id: 'test-asset-id', + params: { + publicly_accessible: false, + public_password: 'test-password', + public_expiry_date: undefined, + }, + }); + expect(mockTimeout).toHaveBeenCalledWith(100); + expect(mockOnCopyLink).not.toHaveBeenCalled(); + }); + + it('should call onShareReport for report_file asset type', async () => { + const onTogglePublish = createOnTogglePublish('report_file'); + + await onTogglePublish(); + + expect(mockOnShareReport).toHaveBeenCalledWith({ + id: 'test-asset-id', + params: { + publicly_accessible: true, + public_password: 'test-password', + public_expiry_date: undefined, + }, + }); + expect(mockTimeout).toHaveBeenCalledWith(100); + }); + + it('should call onShareChat for chat asset type', async () => { + const onTogglePublish = createOnTogglePublish('chat'); + + await onTogglePublish(); + + expect(mockOnShareChat).toHaveBeenCalledWith({ + id: 'test-asset-id', + params: { + publicly_accessible: true, + public_password: 'test-password', + public_expiry_date: undefined, + }, + }); + expect(mockTimeout).toHaveBeenCalledWith(100); + }); + + it('should handle empty password by setting public_password to undefined', async () => { + const onTogglePublish = createOnTogglePublish('metric_file', ''); + + await onTogglePublish(); + + expect(mockOnShareMetric).toHaveBeenCalledWith({ + id: 'test-asset-id', + params: { + publicly_accessible: true, + public_password: undefined, + public_expiry_date: undefined, + }, + }); + }); + + it('should handle null linkExpiry by setting public_expiry_date to null', async () => { + const onTogglePublish = createOnTogglePublish('dashboard_file', 'password', null); + + await onTogglePublish(); + + expect(mockOnShareDashboard).toHaveBeenCalledWith({ + id: 'test-asset-id', + params: { + publicly_accessible: true, + public_password: 'password', + public_expiry_date: undefined, + }, + }); + }); + + it('should convert linkExpiry Date to ISO string', async () => { + const testDate = new Date('2024-01-15T15:30:00Z'); + const onTogglePublish = createOnTogglePublish('collection', 'password', testDate); + + await onTogglePublish(); + + expect(mockOnShareCollection).toHaveBeenCalledWith({ + id: 'test-asset-id', + params: { + publicly_accessible: true, + public_password: 'password', + public_expiry_date: '2024-01-15T15:30:00.000Z', + }, + }); + }); + + it('should call onCopyLink when v is truthy', async () => { + const onTogglePublish = createOnTogglePublish('metric_file'); + + await onTogglePublish(true); + + expect(mockOnCopyLink).toHaveBeenCalledWith(true); + }); + + it('should not call onCopyLink when v is falsy', async () => { + const onTogglePublish = createOnTogglePublish('metric_file'); + + await onTogglePublish(false); + + expect(mockOnCopyLink).not.toHaveBeenCalled(); + }); +}); + +// Mock all the hooks for React component tests +vi.mock('@/api/buster_rest/chats', () => ({ + useUpdateChatShare: () => ({ + mutateAsync: mockOnShareChat, + isPending: false, + }), +})); + +vi.mock('@/api/buster_rest/collections', () => ({ + useUpdateCollectionShare: () => ({ + mutateAsync: mockOnShareCollection, + isPending: false, + }), +})); + +vi.mock('@/api/buster_rest/dashboards', () => ({ + useUpdateDashboardShare: () => ({ + mutateAsync: mockOnShareDashboard, + isPending: false, + }), +})); + +vi.mock('@/api/buster_rest/metrics', () => ({ + useUpdateMetricShare: () => ({ + mutateAsync: mockOnShareMetric, + isPending: false, + }), +})); + +vi.mock('@/api/buster_rest/reports', () => ({ + useUpdateReportShare: () => ({ + mutateAsync: mockOnShareReport, + isPending: false, + }), +})); + +vi.mock('@/context/BusterNotifications', () => ({ + useBusterNotifications: () => ({ + openInfoMessage: vi.fn(), + }), +})); + +vi.mock('@/context/Routes/useRouteBuilder', () => ({ + useBuildLocation: () => vi.fn(), +})); + +describe('ShareMenuContentPublish Component', () => { + const defaultProps: ShareMenuContentBodyProps = { + assetType: 'metric_file', + assetId: 'test-asset-id', + password: '', + publicly_accessible: false, + onCopyLink: mockOnCopyLink, + publicExpirationDate: null, + className: '', + embedLinkURL: 'https://example.com/embed/test-asset-id', + individual_permissions: [], + canEditPermissions: true, + shareAssetConfig: { + individual_permissions: [], + publicly_accessible: false, + public_expiry_date: null, + public_password: '', + permission: 'owner', + workspace_sharing: null, + public_enabled_by: null, + workspace_member_count: null, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render create public link button when not publicly accessible', () => { + render(); + + expect(screen.getByText('Anyone with the link will be able to view.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create public link' })).toBeInTheDocument(); + }); + + it('should render published state with link input when publicly accessible', () => { + const props = { + ...defaultProps, + publicly_accessible: true, + }; + + render(); + + expect(screen.getByText('Live on the web')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://example.com/embed/test-asset-id')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Unpublish' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy link' })).toBeInTheDocument(); + }); + + it('should show password protection controls when publicly accessible', () => { + const props = { + ...defaultProps, + publicly_accessible: true, + }; + + render(); + + expect(screen.getByText('Set a password')).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('should enable password input when password protection is toggled on', async () => { + const props = { + ...defaultProps, + publicly_accessible: true, + }; + + render(); + + const passwordSwitch = screen.getByRole('switch'); + fireEvent.click(passwordSwitch); + + expect(screen.getByPlaceholderText('Password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + }); + + it('should show password input with existing password when password is provided', () => { + const props = { + ...defaultProps, + publicly_accessible: true, + password: 'existing-password', + }; + + render(); + + // Password switch should be checked + expect(screen.getByRole('switch')).toBeChecked(); + // Password input should be visible with the existing password + expect(screen.getByDisplayValue('existing-password')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/features/ShareMenu/ShareMenuInvite.test.tsx b/apps/web/src/components/features/ShareMenu/ShareMenuInvite.test.tsx new file mode 100644 index 000000000..c47fe75a1 --- /dev/null +++ b/apps/web/src/components/features/ShareMenu/ShareMenuInvite.test.tsx @@ -0,0 +1,234 @@ +import type { ShareAssetType, ShareConfig } from '@buster/server-shared/share'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ShareMenuInvite } from './ShareMenuInvite'; + +// Create mock functions +const mockShareMetric = vi.fn(); +const mockShareDashboard = vi.fn(); +const mockShareCollection = vi.fn(); +const mockShareChat = vi.fn(); +const mockShareReport = vi.fn(); +const mockOpenErrorMessage = vi.fn(); + +// Mock hooks +vi.mock('@/api/buster_rest/chats', () => ({ + useShareChat: () => ({ + mutateAsync: mockShareChat, + isPending: false, + }), +})); + +vi.mock('@/api/buster_rest/collections', () => ({ + useShareCollection: () => ({ + mutateAsync: mockShareCollection, + isPending: false, + }), +})); + +vi.mock('@/api/buster_rest/dashboards', () => ({ + useShareDashboard: () => ({ + mutateAsync: mockShareDashboard, + isPending: false, + }), +})); + +vi.mock('@/api/buster_rest/metrics', () => ({ + useShareMetric: () => ({ + mutateAsync: mockShareMetric, + isPending: false, + }), +})); + +vi.mock('@/api/buster_rest/reports', () => ({ + useShareReport: () => ({ + mutateAsync: mockShareReport, + isPending: false, + }), +})); + +vi.mock('../../../api/buster_rest/users', () => ({ + useGetUserToOrganization: () => ({ + data: { + data: [ + { + id: '1', + name: 'John Doe', + email: 'john@example.com', + avatarUrl: 'https://example.com/avatar.jpg', + }, + ], + }, + }), +})); + +vi.mock('@/context/BusterNotifications', () => ({ + useBusterNotifications: () => ({ + openErrorMessage: mockOpenErrorMessage, + }), +})); + +vi.mock('@/hooks/useDebounce', () => ({ + useDebounce: (value: string) => value, +})); + +vi.mock('@/hooks/useMemoizedFn', () => ({ + useMemoizedFn: (fn: any) => fn, +})); + +vi.mock('@/lib/email', () => ({ + isValidEmail: (email: string) => email.includes('@') && email.includes('.'), +})); + +vi.mock('@/lib/text', () => ({ + inputHasText: (text: string) => text.length > 0, +})); + +// Mock components +vi.mock('../../ui/avatar/AvatarUserButton', () => ({ + AvatarUserButton: ({ username, email }: { username: string; email: string }) => ( +
+ {username} - {email} +
+ ), +})); + +vi.mock('./AccessDropdown', () => ({ + AccessDropdown: ({ shareLevel, onChangeShareLevel }: any) => ( +
+ + Current: {shareLevel} +
+ ), +})); + +vi.mock('@/components/ui/inputs/InputSearchDropdown', () => ({ + InputSearchDropdown: ({ value, onChange, onPressEnter, placeholder }: any) => ( + onChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onPressEnter()} + placeholder={placeholder} + /> + ), +})); + +vi.mock('@/components/ui/buttons', () => ({ + Button: ({ children, onClick, disabled, loading }: any) => ( + + ), +})); + +const renderComponent = (props: { + assetType: ShareAssetType; + assetId: string; + individualPermissions: ShareConfig['individual_permissions']; +}) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + + ); +}; + +describe('ShareMenuInvite', () => { + const defaultProps = { + assetType: 'metric_file' as ShareAssetType, + assetId: 'test-asset-id', + individualPermissions: [] as ShareConfig['individual_permissions'], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render email input and invite button', () => { + renderComponent(defaultProps); + + expect(screen.getByTestId('email-input')).toBeInTheDocument(); + expect(screen.getByTestId('invite-button')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Invite others by email...')).toBeInTheDocument(); + }); + + it('should disable invite button when email input is empty or invalid', () => { + renderComponent(defaultProps); + + const inviteButton = screen.getByTestId('invite-button'); + expect(inviteButton).toBeDisabled(); + + // Type invalid email + const emailInput = screen.getByTestId('email-input'); + fireEvent.change(emailInput, { target: { value: 'invalid-email' } }); + + expect(inviteButton).toBeDisabled(); + }); + + it('should enable invite button when valid email is entered', () => { + renderComponent(defaultProps); + + const emailInput = screen.getByTestId('email-input'); + const inviteButton = screen.getByTestId('invite-button'); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + + expect(inviteButton).not.toBeDisabled(); + }); + + it('should show access dropdown when email is entered', () => { + renderComponent(defaultProps); + + const emailInput = screen.getByTestId('email-input'); + + // Initially no access dropdown + expect(screen.queryByTestId('access-dropdown')).not.toBeInTheDocument(); + + // Type email to show dropdown + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + + expect(screen.getByTestId('access-dropdown')).toBeInTheDocument(); + expect(screen.getByText('Current: can_view')).toBeInTheDocument(); + }); + + it('should show error when trying to invite already shared email', () => { + const propsWithExistingPermissions = { + ...defaultProps, + individualPermissions: [ + { + email: 'existing@example.com', + role: 'can_view' as const, + name: 'Existing User', + avatar_url: null, + }, + ] as ShareConfig['individual_permissions'], + }; + + renderComponent(propsWithExistingPermissions); + + const emailInput = screen.getByTestId('email-input'); + const inviteButton = screen.getByTestId('invite-button'); + + fireEvent.change(emailInput, { target: { value: 'existing@example.com' } }); + fireEvent.click(inviteButton); + + expect(mockOpenErrorMessage).toHaveBeenCalledWith('Email already shared'); + }); +});