diff --git a/services/platform/app/components/ui/data-display/zoom-pan-viewer.stories.tsx b/services/platform/app/components/ui/data-display/zoom-pan-viewer.stories.tsx new file mode 100644 index 000000000..a8ce4006b --- /dev/null +++ b/services/platform/app/components/ui/data-display/zoom-pan-viewer.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Text } from '../typography/text'; +import { ZoomPanViewer } from './zoom-pan-viewer'; + +const SAMPLE_IMAGE = + 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop'; + +const meta: Meta = { + title: 'Data Display/ZoomPanViewer', + component: ZoomPanViewer, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +An interactive image viewer with zoom, pan, and scroll-wheel support. + +## Usage +\`\`\`tsx +import { ZoomPanViewer } from '@/app/components/ui/data-display/zoom-pan-viewer'; + + +\`\`\` + +## Features +- Zoom in/out with buttons or scroll wheel (0.5x–3x) +- Pan by dragging when zoomed in +- Reset zoom button +- Overlay or inline toolbar positioning +- Optional header content alongside toolbar + `, + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + toolbarPosition: { + control: 'radio', + options: ['overlay', 'bottom', 'inline'], + }, + }, +}; + +// Storybook requires default export for meta +// oxlint-disable-next-line no-default-export -- Storybook convention +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + src: SAMPLE_IMAGE, + alt: 'Mountain landscape', + toolbarPosition: 'overlay', + }, + parameters: { + docs: { + description: { + story: + 'Default overlay toolbar positioned at the top-right. Ideal for dialog/modal use cases.', + }, + }, + }, +}; + +export const InlineToolbar: Story = { + args: { + src: SAMPLE_IMAGE, + alt: 'Mountain landscape', + toolbarPosition: 'inline', + }, + parameters: { + docs: { + description: { + story: + 'Inline toolbar rendered above the image in normal document flow. Ideal for embedded previews.', + }, + }, + }, +}; + +export const WithHeaderContent: Story = { + args: { + src: SAMPLE_IMAGE, + alt: 'Mountain landscape', + toolbarPosition: 'overlay', + headerContent: ( + + landscape-photo.jpg + + ), + }, + parameters: { + docs: { + description: { + story: + 'Overlay toolbar with header content (e.g. filename) rendered to the left of controls.', + }, + }, + }, +}; + +export const BottomToolbar: Story = { + args: { + src: SAMPLE_IMAGE, + alt: 'Mountain landscape', + toolbarPosition: 'bottom', + imageClassName: 'rounded-xl border', + }, + parameters: { + docs: { + description: { + story: + 'Floating pill toolbar at the bottom center, matching the PDF preview toolbar design.', + }, + }, + }, +}; + +export const WithImageStyling: Story = { + args: { + src: SAMPLE_IMAGE, + alt: 'Styled image', + toolbarPosition: 'inline', + imageClassName: 'rounded-xl border', + }, + parameters: { + docs: { + description: { + story: 'Custom image styling with rounded corners and border.', + }, + }, + }, +}; diff --git a/services/platform/app/components/ui/data-display/zoom-pan-viewer.test.tsx b/services/platform/app/components/ui/data-display/zoom-pan-viewer.test.tsx new file mode 100644 index 000000000..9fe3b926f --- /dev/null +++ b/services/platform/app/components/ui/data-display/zoom-pan-viewer.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen, fireEvent } from '@testing-library/react'; +import { afterEach, describe, it, expect, vi } from 'vitest'; + +import { ZoomPanViewer } from './zoom-pan-viewer'; + +vi.mock('@/lib/i18n/client', () => ({ + useT: () => ({ + t: (key: string) => { + const translations: Record = { + 'imagePreview.zoomIn': 'Zoom in', + 'imagePreview.zoomOut': 'Zoom out', + 'imagePreview.resetZoom': 'Reset zoom', + }; + return translations[key] ?? key; + }, + }), +})); + +afterEach(cleanup); + +describe('ZoomPanViewer', () => { + const defaultProps = { + src: 'https://example.com/image.jpg', + alt: 'Test image', + }; + + describe('rendering', () => { + it('renders an image with correct src and alt', () => { + render(); + + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', defaultProps.src); + expect(img).toHaveAttribute('alt', defaultProps.alt); + }); + + it('renders zoom in, zoom out, and reset buttons', () => { + render(); + + expect( + screen.getByRole('button', { name: 'Zoom in' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Zoom out' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Reset zoom' }), + ).toBeInTheDocument(); + }); + + it('shows 100% zoom by default', () => { + render(); + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + + it('applies imageClassName to the img element', () => { + render( + , + ); + + const img = screen.getByRole('img'); + expect(img.className).toContain('rounded-xl'); + expect(img.className).toContain('border'); + }); + + it('applies className to the container', () => { + const { container } = render( + , + ); + + expect(container.firstElementChild?.className).toContain('custom-class'); + }); + + it('sets draggable to false on the image', () => { + render(); + + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('draggable', 'false'); + }); + }); + + describe('toolbar positions', () => { + it('renders overlay toolbar by default', () => { + const { container } = render(); + + const toolbarWrapper = container.querySelector('.absolute.top-4'); + expect(toolbarWrapper).toBeInTheDocument(); + }); + + it('renders inline toolbar when toolbarPosition is inline', () => { + const { container } = render( + , + ); + + const inlineWrapper = container.querySelector('.justify-end.mb-2'); + expect(inlineWrapper).toBeInTheDocument(); + }); + + it('renders headerContent in overlay mode', () => { + render( + file.jpg} + />, + ); + + expect(screen.getByTestId('header')).toBeInTheDocument(); + }); + }); + + describe('zoom controls', () => { + it('increments zoom on zoom in click', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + expect(screen.getByText('125%')).toBeInTheDocument(); + }); + + it('decrements zoom on zoom out click', () => { + render(); + + // First zoom in, then zoom out + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })); + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + + it('resets zoom on reset click', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + expect(screen.getByText('150%')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reset zoom' })); + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + + it('disables reset button when not zoomed', () => { + render(); + + expect(screen.getByRole('button', { name: 'Reset zoom' })).toBeDisabled(); + }); + + it('enables reset button when zoomed', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + expect( + screen.getByRole('button', { name: 'Reset zoom' }), + ).not.toBeDisabled(); + }); + }); + + describe('callbacks', () => { + it('calls onLoad when image loads', () => { + const onLoad = vi.fn(); + render(); + + fireEvent.load(screen.getByRole('img')); + expect(onLoad).toHaveBeenCalledOnce(); + }); + + it('calls onError when image fails', () => { + const onError = vi.fn(); + render(); + + fireEvent.error(screen.getByRole('img')); + expect(onError).toHaveBeenCalledOnce(); + }); + }); + + describe('image transform', () => { + it('applies scale transform to image', () => { + render(); + + const img = screen.getByRole('img'); + expect(img.style.transform).toContain('scale(1)'); + }); + + it('updates transform when zoomed', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + + const img = screen.getByRole('img'); + expect(img.style.transform).toContain('scale(1.25)'); + }); + }); +}); diff --git a/services/platform/app/components/ui/data-display/zoom-pan-viewer.tsx b/services/platform/app/components/ui/data-display/zoom-pan-viewer.tsx new file mode 100644 index 000000000..f3ab6eca3 --- /dev/null +++ b/services/platform/app/components/ui/data-display/zoom-pan-viewer.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; +import { memo } from 'react'; + +import { useZoomPan } from '@/app/hooks/use-zoom-pan'; +import { useT } from '@/lib/i18n/client'; +import { cn } from '@/lib/utils/cn'; + +import { Text } from '../typography/text'; + +interface ZoomPanViewerProps { + /** Image source URL */ + src: string; + /** Alt text for the image */ + alt: string; + /** Additional class for the outermost container */ + className?: string; + /** + * Toolbar layout: + * - `'overlay'` positions absolutely on top-right (dialog use case) + * - `'bottom'` sticky bottom-center floating pill (document preview use case) + * - `'inline'` renders in normal flow above the image (embedded use case) + */ + toolbarPosition?: 'overlay' | 'bottom' | 'inline'; + /** Additional class for the toolbar wrapper */ + toolbarClassName?: string; + /** Content rendered to the left of the toolbar in overlay mode (e.g. alt text label) */ + headerContent?: React.ReactNode; + /** When this value changes, zoom/pan resets. Pass dialog open state or image src. */ + resetTrigger?: unknown; + /** Additional class for the `` element */ + imageClassName?: string; + /** Called when the image finishes loading */ + onLoad?: () => void; + /** Called when the image fails to load */ + onError?: () => void; +} + +export const ZoomPanViewer = memo(function ZoomPanViewer({ + src, + alt, + className, + toolbarPosition = 'overlay', + toolbarClassName, + headerContent, + resetTrigger, + imageClassName, + onLoad, + onError, +}: ZoomPanViewerProps) { + const { t } = useT('common'); + const { + zoom, + isDragging, + containerRef, + zoomIn, + zoomOut, + reset, + pointerHandlers, + canZoomIn, + canZoomOut, + isZoomed, + transformStyle, + } = useZoomPan({ resetTrigger }); + + const isBottom = toolbarPosition === 'bottom'; + + const buttonClass = isBottom + ? 'grid size-8 place-items-center rounded-full transition hover:bg-white/10 disabled:opacity-35' + : 'text-foreground size-8 disabled:opacity-50'; + + const toolbar = ( +
+ + + {Math.round(zoom * 100)}% + + + +
+ ); + + const renderToolbar = () => { + switch (toolbarPosition) { + case 'overlay': + return ( +
+ {headerContent ?? } + {toolbar} +
+ ); + case 'bottom': + return null; + case 'inline': + return
{toolbar}
; + } + }; + + return ( +
+ {renderToolbar()} + +
+ {alt} +
+ + {isBottom && ( +
+ {toolbar} +
+ )} +
+ ); +}); diff --git a/services/platform/app/features/documents/components/__tests__/document-preview-image.test.tsx b/services/platform/app/features/documents/components/__tests__/document-preview-image.test.tsx new file mode 100644 index 000000000..3214cdc21 --- /dev/null +++ b/services/platform/app/features/documents/components/__tests__/document-preview-image.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen, fireEvent } from '@testing-library/react'; +import { afterEach, describe, it, expect, vi } from 'vitest'; + +import { DocumentPreviewImage } from '../document-preview-image'; + +vi.mock('@/lib/i18n/client', () => ({ + useT: (ns: string) => ({ + t: (key: string) => { + const translations: Record> = { + documents: { + 'preview.failedToLoad': 'Failed to load document', + 'preview.document': 'Document', + }, + common: { + 'imagePreview.zoomIn': 'Zoom in', + 'imagePreview.zoomOut': 'Zoom out', + 'imagePreview.resetZoom': 'Reset zoom', + }, + }; + return translations[ns]?.[key] ?? key; + }, + }), +})); + +afterEach(cleanup); + +describe('DocumentPreviewImage', () => { + it('renders an img element with the provided url', () => { + render( + , + ); + + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg'); + expect(img).toHaveAttribute('alt', 'photo.jpg'); + }); + + it('uses fallback alt text when no fileName is provided', () => { + render(); + + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('alt', 'Document'); + }); + + it('shows loading skeleton initially', () => { + const { container } = render( + , + ); + + expect(container.querySelector('[class*="animate-pulse"]')).toBeTruthy(); + }); + + it('hides loading skeleton after image loads', () => { + const { container } = render( + , + ); + + const img = screen.getByRole('img'); + fireEvent.load(img); + + expect(container.querySelector('[class*="animate-pulse"]')).toBeFalsy(); + }); + + it('shows error message when image fails to load', () => { + render(); + + const img = screen.getByRole('img'); + fireEvent.error(img); + + expect(screen.getByText('Failed to load document')).toBeInTheDocument(); + }); + + it('applies object-contain styling for proper scaling', () => { + render(); + + const img = screen.getByRole('img'); + expect(img.className).toContain('object-contain'); + }); + + it('renders zoom controls', () => { + render(); + + expect(screen.getByRole('button', { name: 'Zoom in' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Zoom out' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Reset zoom' }), + ).toBeInTheDocument(); + }); + + it('shows zoom percentage', () => { + render(); + + expect(screen.getByText('100%')).toBeInTheDocument(); + }); +}); diff --git a/services/platform/app/features/documents/components/__tests__/document-preview-routing.test.tsx b/services/platform/app/features/documents/components/__tests__/document-preview-routing.test.tsx new file mode 100644 index 000000000..6e5b47156 --- /dev/null +++ b/services/platform/app/features/documents/components/__tests__/document-preview-routing.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeAll, describe, it, expect, vi } from 'vitest'; + +vi.mock('@/lib/i18n/client', () => ({ + useT: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/app/hooks/use-toast', () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock('../document-preview-image', () => ({ + DocumentPreviewImage: ({ + url, + fileName, + }: { + url: string; + fileName?: string; + }) => ( +
+ ), +})); + +vi.mock('../document-preview-pdf', () => ({ + DocumentPreviewPDF: ({ url }: { url: string }) => ( +
+ ), +})); + +vi.mock('../document-preview-docx', () => ({ + DocumentPreviewDocx: ({ url }: { url: string }) => ( +
+ ), +})); + +vi.mock('../document-preview-xlsx', () => ({ + DocumentPreviewXlsx: ({ url }: { url: string }) => ( +
+ ), +})); + +vi.mock('../document-preview-text', () => ({ + DocumentPreviewText: ({ + url, + fileName, + }: { + url: string; + fileName?: string; + }) => ( +
+ ), +})); + +// Track all promises from lazyComponent factories so we can resolve +// them before running any tests. vi.hoisted runs before vi.mock hoisting. +const { lazyPromises } = vi.hoisted(() => ({ + lazyPromises: [] as Promise[], +})); + +vi.mock('@/lib/utils/lazy-component', () => ({ + lazyComponent: (factory: () => Promise<{ default: unknown }>) => { + let Resolved: React.ComponentType> | null = null; + const p = factory().then((m) => { + Resolved = m.default as React.ComponentType>; + }); + lazyPromises.push(p); + return function Lazy(props: Record) { + if (!Resolved) return null; + return ; + }; + }, +})); + +import { DocumentPreview } from '../document-preview'; + +// Resolve all lazy component factories before tests run. +// Since vi.mock makes dynamic imports synchronous, these promises +// resolve immediately in the microtask queue. +beforeAll(async () => { + await Promise.all(lazyPromises); +}); + +afterEach(cleanup); + +describe('DocumentPreview routing', () => { + it.each(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'])( + 'routes .%s files to image preview', + (ext) => { + render( + , + ); + + expect(screen.getByTestId('image-preview')).toBeInTheDocument(); + }, + ); + + it('routes .pdf files to PDF preview', () => { + render( + , + ); + + expect(screen.getByTestId('pdf-preview')).toBeInTheDocument(); + }); + + it('routes .docx files to DOCX preview', () => { + render( + , + ); + + expect(screen.getByTestId('docx-preview')).toBeInTheDocument(); + }); + + it('routes uppercase image extensions correctly', () => { + render( + , + ); + + expect(screen.getByTestId('image-preview')).toBeInTheDocument(); + }); +}); diff --git a/services/platform/app/features/documents/components/__tests__/document-team-tags-dialog.test.tsx b/services/platform/app/features/documents/components/__tests__/document-team-tags-dialog.test.tsx new file mode 100644 index 000000000..ce273a668 --- /dev/null +++ b/services/platform/app/features/documents/components/__tests__/document-team-tags-dialog.test.tsx @@ -0,0 +1,331 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockMutateAsync = vi.fn(); +const mockToast = vi.fn(); +const mockNavigate = vi.fn(); + +vi.mock('@/lib/i18n/client', () => ({ + useT: (ns: string) => ({ + t: (key: string, params?: Record) => { + if (params) { + return Object.entries(params).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, v), + `${ns}.${key}`, + ); + } + return `${ns}.${key}`; + }, + }), +})); + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => mockNavigate, +})); + +vi.mock('@/app/hooks/use-organization-id', () => ({ + useOrganizationId: () => 'org-1', +})); + +vi.mock('@/app/hooks/use-toast', () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})); + +vi.mock('../../hooks/mutations', () => ({ + useUpdateDocument: () => ({ mutateAsync: mockMutateAsync }), +})); + +const mockTeams = [ + { id: 'team-1', name: 'Sales' }, + { id: 'team-2', name: 'Support' }, + { id: 'team-3', name: 'Operations' }, +]; + +let mockTeamsData: { teams: typeof mockTeams | undefined; isLoading: boolean } = + { teams: mockTeams, isLoading: false }; + +vi.mock('@/app/features/settings/teams/hooks/queries', () => ({ + useTeams: () => mockTeamsData, +})); + +vi.mock('@/convex/lib/type_cast_helpers', () => ({ + toId: (id: string) => id, +})); + +import { DocumentTeamTagsDialog } from '../document-team-tags-dialog'; + +describe('DocumentTeamTagsDialog', () => { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + documentId: 'doc-1', + documentName: 'Return policy v2.docx', + currentTeamIds: [] as string[], + }; + + beforeEach(() => { + mockTeamsData = { teams: mockTeams, isLoading: false }; + mockMutateAsync.mockResolvedValue(undefined); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('renders nothing when not open', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders the dialog title', () => { + render(); + expect( + screen.getByRole('heading', { name: 'documents.teamTags.title' }), + ).toBeInTheDocument(); + }); + + it('shows the document name as description', () => { + render(); + expect(screen.getByText('Return policy v2.docx')).toBeInTheDocument(); + }); + + it('extracts filename from path for description', () => { + render( + , + ); + expect(screen.getByText('Report.pdf')).toBeInTheDocument(); + }); + + it('renders organization-wide option and all team checkboxes', () => { + render(); + expect(screen.getByText('documents.teamTags.orgWide')).toBeInTheDocument(); + expect(screen.getByText('Sales')).toBeInTheDocument(); + expect(screen.getByText('Support')).toBeInTheDocument(); + expect(screen.getByText('Operations')).toBeInTheDocument(); + }); + + it('shows org-wide checkbox as checked by default (no teams selected)', () => { + render(); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + expect(checkboxes[3]).not.toBeChecked(); + }); + + it('shows loading state', () => { + mockTeamsData = { teams: undefined, isLoading: true }; + render(); + expect(screen.getByText('common.actions.loading')).toBeInTheDocument(); + }); + + it('shows empty state with title, description and settings link', () => { + mockTeamsData = { teams: [], isLoading: false }; + render(); + expect( + screen.getByText('documents.teamTags.noTeamsTitle'), + ).toBeInTheDocument(); + expect( + screen.getByText('documents.teamTags.noTeamsDescription'), + ).toBeInTheDocument(); + expect( + screen.getByText('documents.teamTags.goToSettings'), + ).toBeInTheDocument(); + }); + + it('shows footer with disabled save when no teams', () => { + mockTeamsData = { teams: [], isLoading: false }; + render(); + expect(screen.getByText('common.actions.cancel')).toBeInTheDocument(); + expect(screen.getByText('common.actions.save')).toBeDisabled(); + }); + + it('navigates to settings on go to settings click', async () => { + mockTeamsData = { teams: [], isLoading: false }; + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByText('documents.teamTags.goToSettings')); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/dashboard/$id/settings/teams', + params: { id: 'org-1' }, + }); + }); + + it('pre-selects current teams and unchecks org-wide', () => { + render( + , + ); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).toBeChecked(); + expect(checkboxes[3]).not.toBeChecked(); + }); + + it('toggles team selection on click', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Sales')); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + }); + + it('deselects team when clicking an already-selected team', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByText('Sales')); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + }); + + it('allows selecting multiple teams', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Sales')); + await user.click(screen.getByText('Support')); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).toBeChecked(); + expect(checkboxes[3]).not.toBeChecked(); + }); + + it('disables save when no changes', () => { + render(); + const saveButton = screen.getByText('common.actions.save'); + expect(saveButton).toBeDisabled(); + }); + + it('enables save when team changes', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Sales')); + const saveButton = screen.getByText('common.actions.save'); + expect(saveButton).toBeEnabled(); + }); + + it('submits with the selected team ids', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Sales')); + await user.click(screen.getByText('Support')); + await user.click(screen.getByText('common.actions.save')); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + documentId: 'doc-1', + teamIds: expect.arrayContaining(['team-1', 'team-2']), + }); + }); + + it('clears all teams when clicking organization-wide', async () => { + const user = userEvent.setup(); + render( + , + ); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).toBeChecked(); + + await user.click(screen.getByText('documents.teamTags.orgWide')); + + const updatedCheckboxes = screen.getAllByRole('checkbox'); + expect(updatedCheckboxes[0]).toBeChecked(); + expect(updatedCheckboxes[1]).not.toBeChecked(); + expect(updatedCheckboxes[2]).not.toBeChecked(); + expect(updatedCheckboxes[3]).not.toBeChecked(); + }); + + it('submits empty array when org-wide is selected', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByText('documents.teamTags.orgWide')); + await user.click(screen.getByText('common.actions.save')); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + documentId: 'doc-1', + teamIds: [], + }); + }); + + it('shows success toast after save', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Sales')); + await user.click(screen.getByText('common.actions.save')); + + expect(mockToast).toHaveBeenCalledWith({ + title: 'documents.teamTags.updated', + variant: 'success', + }); + }); + + it('shows error toast on save failure', async () => { + mockMutateAsync.mockRejectedValue(new Error('fail')); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Sales')); + await user.click(screen.getByText('common.actions.save')); + + expect(mockToast).toHaveBeenCalledWith({ + title: 'documents.teamTags.updateFailed', + variant: 'destructive', + }); + }); + + it('closes dialog on cancel', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByText('common.actions.cancel')); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('closes dialog after successful save', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByText('Sales')); + await user.click(screen.getByText('common.actions.save')); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/services/platform/app/features/documents/components/document-preview-dialog.tsx b/services/platform/app/features/documents/components/document-preview-dialog.tsx index 8399a607d..33face5ec 100644 --- a/services/platform/app/features/documents/components/document-preview-dialog.tsx +++ b/services/platform/app/features/documents/components/document-preview-dialog.tsx @@ -12,11 +12,18 @@ import { Button } from '@/app/components/ui/primitives/button'; import { IconButton } from '@/app/components/ui/primitives/icon-button'; import { Heading } from '@/app/components/ui/typography/heading'; import { Text } from '@/app/components/ui/typography/text'; +import { useTeams } from '@/app/features/settings/teams/hooks/queries'; +import { useFormatDate } from '@/app/hooks/use-format-date'; +import { useLocale } from '@/app/hooks/use-locale'; import { useToast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; +import { formatBytes } from '@/lib/utils/format/number'; + +import type { Document } from '../hooks/queries'; import { useDocuments } from '../hooks/queries'; import { DocumentPreview } from './document-preview'; +import { RagStatusBadge } from './rag-status-badge'; interface DocumentPreviewDialogProps { open: boolean; @@ -26,6 +33,112 @@ interface DocumentPreviewDialogProps { fileName?: string; } +function SidebarRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +function DetailsSidebar({ doc }: { doc: Document }) { + const { t } = useT('documents'); + const { formatDate } = useFormatDate(); + const { locale } = useLocale(); + const { teams } = useTeams(); + + const teamNames = useMemo(() => { + const ids = doc.teamIds ?? []; + if (ids.length === 0 || !teams) return []; + return ids + .map( + (id) => + teams.find((entry: { id: string; name: string }) => entry.id === id) + ?.name, + ) + .filter(Boolean); + }, [doc.teamIds, teams]); + + const sourceLabel = useMemo(() => { + const labels: Record = { + upload: t('preview.sidebar.sourceUpload'), + onedrive: t('preview.sidebar.sourceOnedrive'), + sharepoint: t('preview.sidebar.sourceSharepoint'), + }; + return labels[doc.sourceProvider ?? 'upload'] ?? doc.sourceProvider; + }, [doc.sourceProvider, t]); + + const modifiedDate = useMemo(() => { + if (!doc.lastModified) return undefined; + return formatDate(new Date(doc.lastModified), 'short'); + }, [doc.lastModified, formatDate]); + + return ( + + ); +} + export function DocumentPreviewDialog({ open, onOpenChange, @@ -89,45 +202,33 @@ export function DocumentPreviewDialog({ - -
- -
-
- - {displayName} - -
-
+
+ + {t('preview.title')} + {doc?.url && ( )} - )} {!isLoading && doc?.url && ( - +
+
+ +
+ +
)}
); diff --git a/services/platform/app/features/documents/components/document-preview-docx.tsx b/services/platform/app/features/documents/components/document-preview-docx.tsx index 90285acc6..3419b5a88 100644 --- a/services/platform/app/features/documents/components/document-preview-docx.tsx +++ b/services/platform/app/features/documents/components/document-preview-docx.tsx @@ -5,6 +5,7 @@ import { useCallback } from 'react'; import { useT } from '@/lib/i18n/client'; import { useDocxPreview } from '../hooks/use-document-preview'; +import { PreviewPane } from './preview-pane'; interface DocumentPreviewDocxProps { url: string; @@ -22,7 +23,7 @@ export function DocumentPreviewDocx({ url }: DocumentPreviewDocxProps) { ); return ( -
+ {isLoading && (
{t('preview.loading')} @@ -39,6 +40,6 @@ export function DocumentPreviewDocx({ url }: DocumentPreviewDocxProps) { className="prose mx-auto aspect-[1/1.4] w-full max-w-2xl" /> )} -
+
); } diff --git a/services/platform/app/features/documents/components/document-preview-image.tsx b/services/platform/app/features/documents/components/document-preview-image.tsx new file mode 100644 index 000000000..54dde6b9d --- /dev/null +++ b/services/platform/app/features/documents/components/document-preview-image.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +import { ZoomPanViewer } from '@/app/components/ui/data-display/zoom-pan-viewer'; +import { Skeleton } from '@/app/components/ui/feedback/skeleton'; +import { Center } from '@/app/components/ui/layout/layout'; +import { Text } from '@/app/components/ui/typography/text'; +import { useT } from '@/lib/i18n/client'; + +import { PreviewPane } from './preview-pane'; + +interface DocumentPreviewImageProps { + url: string; + fileName?: string; +} + +export function DocumentPreviewImage({ + url, + fileName, +}: DocumentPreviewImageProps) { + const { t } = useT('documents'); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const handleLoad = useCallback(() => { + setIsLoading(false); + }, []); + + const handleError = useCallback(() => { + setIsLoading(false); + setHasError(true); + }, []); + + useEffect(() => { + setIsLoading(true); + setHasError(false); + }, [url]); + + if (hasError) { + return ( + + + {t('preview.failedToLoad')} + + + ); + } + + return ( + + {isLoading && ( +
+ +
+ )} + +
+ ); +} diff --git a/services/platform/app/features/documents/components/document-preview-pdf.tsx b/services/platform/app/features/documents/components/document-preview-pdf.tsx index 665ad2714..7a8b8980f 100644 --- a/services/platform/app/features/documents/components/document-preview-pdf.tsx +++ b/services/platform/app/features/documents/components/document-preview-pdf.tsx @@ -6,6 +6,8 @@ import React, { useReducer, useEffect, useRef, useCallback } from 'react'; import { HStack } from '@/app/components/ui/layout/layout'; import { useT } from '@/lib/i18n/client'; +import { PreviewPane } from './preview-pane'; + interface PageViewport { width: number; height: number; @@ -285,7 +287,7 @@ export const DocumentPreviewPDF = ({ url }: { url: string }) => { return ( <> -
+ {/* Canvas */} { {t('preview.loading')}
)} -
+ ); }; diff --git a/services/platform/app/features/documents/components/document-preview-text.tsx b/services/platform/app/features/documents/components/document-preview-text.tsx index 0311f8d28..31c6eb89d 100644 --- a/services/platform/app/features/documents/components/document-preview-text.tsx +++ b/services/platform/app/features/documents/components/document-preview-text.tsx @@ -12,6 +12,7 @@ import { } from '@/lib/utils/text-file-types'; import { useTextPreview } from '../hooks/use-document-preview'; +import { PreviewPane } from './preview-pane'; interface DocumentPreviewTextProps { url: string; @@ -58,7 +59,7 @@ export function DocumentPreviewText({ ); return ( -
+ {isLoading && ( {t('preview.loading')} @@ -87,6 +88,6 @@ export function DocumentPreviewText({
))} -
+ ); } diff --git a/services/platform/app/features/documents/components/document-preview-xlsx.tsx b/services/platform/app/features/documents/components/document-preview-xlsx.tsx index 25c50b77c..b803c9b93 100644 --- a/services/platform/app/features/documents/components/document-preview-xlsx.tsx +++ b/services/platform/app/features/documents/components/document-preview-xlsx.tsx @@ -5,6 +5,7 @@ import { useCallback } from 'react'; import { useT } from '@/lib/i18n/client'; import { useXlsxPreview } from '../hooks/use-document-preview'; +import { PreviewPane } from './preview-pane'; interface DocumentPreviewXlsxProps { url: string; @@ -22,7 +23,7 @@ export function DocumentPreviewXlsx({ url }: DocumentPreviewXlsxProps) { ); return ( -
+ {isLoading && (
{t('preview.loading')} @@ -39,6 +40,6 @@ export function DocumentPreviewXlsx({ url }: DocumentPreviewXlsxProps) { className="[&_td]:border-border [&_table]:bg-background text-foreground max-w-none [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:px-3 [&_td]:py-2 [&_td]:align-top [&_th]:text-left [&_tr]:border-b" /> )} -
+
); } diff --git a/services/platform/app/features/documents/components/document-preview.tsx b/services/platform/app/features/documents/components/document-preview.tsx index 0c41eec35..8d0a019bf 100644 --- a/services/platform/app/features/documents/components/document-preview.tsx +++ b/services/platform/app/features/documents/components/document-preview.tsx @@ -57,6 +57,27 @@ const DocumentPreviewText = lazyComponent( loading: () => , }, ); +const DocumentPreviewImage = lazyComponent( + () => + import('./document-preview-image').then((m) => ({ + default: m.DocumentPreviewImage, + })), + { + loading: () => , + }, +); + +const IMAGE_EXTENSIONS: ReadonlySet = new Set([ + 'JPG', + 'JPEG', + 'PNG', + 'GIF', + 'WEBP', + 'SVG', + 'BMP', + 'ICO', + 'AVIF', +]); export interface DocumentPreviewProps { url: string; @@ -124,6 +145,10 @@ export function DocumentPreview({ url, fileName }: DocumentPreviewProps) { return ; } + if (IMAGE_EXTENSIONS.has(extension)) { + return ; + } + if (isTextBasedFile(fileName || url)) { return ; } diff --git a/services/platform/app/features/documents/components/document-row-actions.tsx b/services/platform/app/features/documents/components/document-row-actions.tsx index 3577c2b8a..4c962e875 100644 --- a/services/platform/app/features/documents/components/document-row-actions.tsx +++ b/services/platform/app/features/documents/components/document-row-actions.tsx @@ -26,7 +26,7 @@ interface DocumentRowActionsProps { syncConfigId?: string; isDirectlySelected?: boolean; sourceMode?: StorageSourceMode; - teamId?: string | null; + teamIds?: string[]; onFolderDeleted?: () => void; } @@ -37,7 +37,7 @@ export function DocumentRowActions({ syncConfigId, isDirectlySelected, sourceMode, - teamId, + teamIds, onFolderDeleted, }: DocumentRowActionsProps) { const { t: tDocuments } = useT('documents'); @@ -199,7 +199,7 @@ export function DocumentRowActions({ onOpenChange={dialogs.setOpen.teamTags} documentId={documentId} documentName={name} - currentTeamId={teamId} + currentTeamIds={teamIds} /> ); diff --git a/services/platform/app/features/documents/components/document-team-tags-dialog.tsx b/services/platform/app/features/documents/components/document-team-tags-dialog.tsx index b4bc5313b..a1b29b06e 100644 --- a/services/platform/app/features/documents/components/document-team-tags-dialog.tsx +++ b/services/platform/app/features/documents/components/document-team-tags-dialog.tsx @@ -1,18 +1,20 @@ 'use client'; -import { Users } from 'lucide-react'; -import { useState, useMemo } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { Settings, Users } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; import { Dialog } from '@/app/components/ui/dialog/dialog'; import { EmptyState } from '@/app/components/ui/feedback/empty-state'; -import { Select } from '@/app/components/ui/forms/select'; -import { Stack } from '@/app/components/ui/layout/layout'; +import { Checkbox } from '@/app/components/ui/forms/checkbox'; import { Button } from '@/app/components/ui/primitives/button'; import { Text } from '@/app/components/ui/typography/text'; import { useTeams } from '@/app/features/settings/teams/hooks/queries'; +import { useOrganizationId } from '@/app/hooks/use-organization-id'; import { toast } from '@/app/hooks/use-toast'; import { toId } from '@/convex/lib/type_cast_helpers'; import { useT } from '@/lib/i18n/client'; +import { cn } from '@/lib/utils/cn'; import { useUpdateDocument } from '../hooks/mutations'; @@ -21,11 +23,9 @@ interface DocumentTeamDialogProps { onOpenChange: (open: boolean) => void; documentId: string; documentName?: string | null; - currentTeamId?: string | null; + currentTeamIds?: string[]; } -const ORG_WIDE_VALUE = '__org_wide__'; - /** * Internal content component containing all hooks. * IMPORTANT: This component must only be rendered when the dialog is open. @@ -37,46 +37,54 @@ function DocumentTeamDialogContent({ onOpenChange, documentId, documentName, - currentTeamId, + currentTeamIds, }: DocumentTeamDialogProps) { const { t: tDocuments } = useT('documents'); const { t: tCommon } = useT('common'); + const navigate = useNavigate(); + const organizationId = useOrganizationId(); - const [selectedValue, setSelectedValue] = useState( - () => currentTeamId ?? ORG_WIDE_VALUE, + const [selectedTeamIds, setSelectedTeamIds] = useState>( + () => new Set(currentTeamIds ?? []), ); const [isSubmitting, setIsSubmitting] = useState(false); const updateDocument = useUpdateDocument(); const { teams, isLoading } = useTeams(); - const teamOptions = useMemo(() => { - const items = [ - { value: ORG_WIDE_VALUE, label: tDocuments('teamTags.orgWide') }, - ]; - if (teams) { - for (const team of teams) { - items.push({ value: team.id, label: team.name }); - } - } - return items; - }, [teams, tDocuments]); + const hasTeams = teams && teams.length > 0; - const handleClose = () => { + const handleClose = useCallback(() => { if (!isSubmitting) { onOpenChange(false); } - }; + }, [isSubmitting, onOpenChange]); + + const isOrgWide = selectedTeamIds.size === 0; + + const handleSelectOrgWide = useCallback(() => { + setSelectedTeamIds(new Set()); + }, []); - const handleSubmit = async () => { + const handleToggleTeam = useCallback((teamId: string) => { + setSelectedTeamIds((prev) => { + const next = new Set(prev); + if (next.has(teamId)) { + next.delete(teamId); + } else { + next.add(teamId); + } + return next; + }); + }, []); + + const handleSubmit = useCallback(async () => { setIsSubmitting(true); try { - const newTeamId = selectedValue === ORG_WIDE_VALUE ? null : selectedValue; - await updateDocument.mutateAsync({ documentId: toId<'documents'>(documentId), - teamId: newTeamId, + teamIds: [...selectedTeamIds], }); toast({ @@ -94,12 +102,16 @@ function DocumentTeamDialogContent({ } finally { setIsSubmitting(false); } - }; + }, [documentId, selectedTeamIds, updateDocument, onOpenChange, tDocuments]); const hasChanges = useMemo(() => { - const currentValue = currentTeamId ?? ORG_WIDE_VALUE; - return currentValue !== selectedValue; - }, [currentTeamId, selectedValue]); + const currentSet = new Set(currentTeamIds ?? []); + if (currentSet.size !== selectedTeamIds.size) return true; + for (const id of currentSet) { + if (!selectedTeamIds.has(id)) return true; + } + return false; + }, [currentTeamIds, selectedTeamIds]); const displayName = useMemo(() => { if (!documentName) return ''; @@ -107,11 +119,24 @@ function DocumentTeamDialogContent({ return parts[parts.length - 1] || documentName; }, [documentName]); + const handleGoToSettings = useCallback(() => { + if (!organizationId) return; + onOpenChange(false); + void navigate({ + to: '/dashboard/$id/settings/teams', + params: { id: organizationId }, + }); + }, [organizationId, onOpenChange, navigate]); + return (