diff --git a/apps/electron/src/renderer/components/CanvasView.tsx b/apps/electron/src/renderer/components/CanvasView.tsx index a2e1b70f5..ea2a0fe0a 100644 --- a/apps/electron/src/renderer/components/CanvasView.tsx +++ b/apps/electron/src/renderer/components/CanvasView.tsx @@ -6,8 +6,7 @@ import type { CanvasHandle, CanvasNode, Rect } from '@xnetjs/canvas' import { Canvas, createNode } from '@xnetjs/canvas' import { CanvasSchema } from '@xnetjs/data' import { useNode, useIdentity } from '@xnetjs/react' -import { IconButton } from '@xnetjs/ui' -import { Compass, Database, FileText, Maximize2, StickyNote } from 'lucide-react' +import { Database, FileText, StickyNote } from 'lucide-react' import React, { forwardRef, useCallback, @@ -19,6 +18,8 @@ import React, { } from 'react' import { createCanvasShellNoteProperties, + getCanvasShellNotePlacement, + getLinkedDocumentPlacement, isCanvasShellNote, shouldRenderCanvasShellCard, type LinkedDocType, @@ -154,12 +155,7 @@ export const CanvasView = forwardRef(function const nodesMap = doc.getMap('nodes') const noteNode = createNode( 'card', - { - x: viewport.x - 160, - y: viewport.y - 100, - width: 320, - height: 180 - }, + getCanvasShellNotePlacement(viewport), createCanvasShellNoteProperties() ) @@ -172,19 +168,10 @@ export const CanvasView = forwardRef(function const viewport = canvasRef.current.getViewportSnapshot() const nodesMap = doc.getMap('nodes') - const linkedNode = createNode( - 'embed', - { - x: viewport.x - (document.type === 'database' ? 220 : 180), - y: viewport.y - 120, - width: document.type === 'database' ? 440 : 360, - height: document.type === 'database' ? 260 : 220 - }, - { - title: document.title, - linkedType: document.type - } - ) + const linkedNode = createNode('embed', getLinkedDocumentPlacement(viewport, document.type), { + title: document.title, + linkedType: document.type + }) linkedNode.linkedNodeId = document.id nodesMap.set(linkedNode.id, linkedNode) }, @@ -257,25 +244,6 @@ export const CanvasView = forwardRef(function ) : null} -
-
- } - label="Fit canvas to content" - onClick={() => canvasRef.current?.fitToContent(80)} - className="h-10 w-10 rounded-2xl" - /> -
-
- } - label="Reset canvas view" - onClick={() => canvasRef.current?.resetView()} - className="h-10 w-10 rounded-2xl" - /> -
-
-
(function minZoom: 0.1, maxZoom: 4 }} + showNavigationTools + navigationToolsPosition="bottom-right" + navigationToolsShowZoomLabel={false} + navigationToolsStyle={{ + bottom: 24, + right: 24, + borderRadius: 24, + background: 'rgba(255, 255, 255, 0.8)', + backdropFilter: 'blur(16px)', + boxShadow: '0 18px 38px rgba(15, 23, 42, 0.12)', + border: '1px solid rgba(148, 163, 184, 0.28)' + }} renderNode={(node) => { const linkedDocument = node.linkedNodeId ? documentMap.get(node.linkedNodeId) diff --git a/apps/electron/src/renderer/components/DatabaseView.test.tsx b/apps/electron/src/renderer/components/DatabaseView.test.tsx new file mode 100644 index 000000000..415237f98 --- /dev/null +++ b/apps/electron/src/renderer/components/DatabaseView.test.tsx @@ -0,0 +1,233 @@ +/** + * @vitest-environment jsdom + */ + +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { describe, beforeEach, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { DatabaseView } from './DatabaseView' + +const mockUpdate = vi.fn() +const mockCreate = vi.fn() + +const mockUseNode = vi.fn() +const mockUseIdentity = vi.fn() +const mockUseMutate = vi.fn() +const mockUseQuery = vi.fn() +const mockUseDatabaseComments = vi.fn() + +vi.mock('@xnetjs/data', () => ({ + DatabaseSchema: { + schema: { + '@id': 'xnet://schema/database' + } + }, + decodeAnchor: vi.fn(() => ({})), + buildDatabaseSchema: vi.fn((docId: string) => ({ + '@id': `xnet://schema/${docId}` + })), + createInitialSchemaMetadata: vi.fn((name: string) => ({ + version: 1, + name, + createdAt: 0, + updatedAt: 0, + history: [] + })), + bumpSchemaVersion: vi.fn((metadata: { version: number }) => ({ + ...metadata, + version: metadata.version + 1 + })), + getVersionBumpType: vi.fn(() => 'minor'), + cloneSchema: vi.fn((columns: unknown[]) => columns), + createVersionEntry: vi.fn(() => ({ + version: 1, + timestamp: 0, + changes: [] + })), + pruneVersionHistory: vi.fn((history: unknown[]) => history) +})) + +vi.mock('@xnetjs/react', () => ({ + useNode: (...args: unknown[]) => mockUseNode(...args), + useIdentity: (...args: unknown[]) => mockUseIdentity(...args), + useMutate: (...args: unknown[]) => mockUseMutate(...args), + useQuery: (...args: unknown[]) => mockUseQuery(...args) +})) + +vi.mock('@xnetjs/views', () => ({ + TableView: () =>
table view
, + BoardView: () =>
board view
, + CardDetailModal: () => null, + AddColumnModal: () => null, + SchemaInfoModal: () => null, + CloneSchemaModal: () => null, + useDatabaseComments: (...args: unknown[]) => mockUseDatabaseComments(...args) +})) + +vi.mock('@xnetjs/ui', async () => { + const ReactModule = await import('react') + + function Menu({ + trigger, + children + }: { + trigger: React.ReactNode + children: React.ReactNode + }): React.ReactElement { + const [open, setOpen] = ReactModule.useState(false) + + if (!ReactModule.isValidElement(trigger)) { + return
{children}
+ } + + return ( +
+ {ReactModule.cloneElement(trigger, { + onClick: () => setOpen((prev) => !prev) + })} + {open ?
{children}
: null} +
+ ) + } + + return { + CommentPopover: () => null, + CommentsSidebar: ({ open }: { open: boolean }) => ( +
{open ? 'open' : 'closed'}
+ ), + Menu, + MenuItem: ({ children, onSelect }: { children: React.ReactNode; onSelect?: () => void }) => ( + + ), + MenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + MenuSeparator: () =>
+ } +}) + +vi.mock('./ShareButton', () => ({ + ShareButton: ({ docId }: { docId: string }) => ( + + ) +})) + +vi.mock('./PresenceAvatars', () => ({ + PresenceAvatars: () =>
presence
+})) + +function createAwarenessMock() { + return { + clientID: 1, + getStates: () => new Map>(), + setLocalStateField: vi.fn(), + on: vi.fn(), + off: vi.fn() + } +} + +function createDatabaseDoc(): Y.Doc { + const doc = new Y.Doc() + const dataMap = doc.getMap('data') + + dataMap.set('columns', [ + { + id: 'title', + name: 'Title', + type: 'text' + }, + { + id: 'status', + name: 'Status', + type: 'select', + options: ['Todo', 'Done'] + } + ]) + dataMap.set('rows', [ + { + id: 'row-1', + values: { + title: 'First row', + status: 'Todo' + } + } + ]) + dataMap.set('schema', { + version: 1, + name: 'Focus DB', + createdAt: 0, + updatedAt: 0, + history: [] + }) + + return doc +} + +describe('DatabaseView minimal chrome', () => { + beforeEach(() => { + mockUpdate.mockReset() + mockCreate.mockReset() + + mockUseIdentity.mockReturnValue({ did: 'did:xnet:test' }) + mockUseMutate.mockReturnValue({ create: mockCreate }) + mockUseQuery.mockReturnValue({ data: [] }) + mockUseDatabaseComments.mockReturnValue({ + threads: [], + cellCommentCounts: new Map(), + unresolvedCount: 2, + commentOnCell: vi.fn(), + getThreadsForCell: vi.fn(() => []), + replyTo: vi.fn(), + resolveThread: vi.fn(), + reopenThread: vi.fn(), + deleteComment: vi.fn(), + editComment: vi.fn() + }) + mockUseNode.mockReturnValue({ + data: { title: 'Focus DB' }, + doc: createDatabaseDoc(), + loading: false, + update: mockUpdate, + presence: [{ did: 'did:xnet:peer', color: '#22c55e' }], + awareness: createAwarenessMock() + }) + }) + + it('renders the compact focus controls and moves sharing into the overflow menu', async () => { + render() + + expect(await screen.findByDisplayValue('Focus DB')).toBeTruthy() + expect(screen.getByTestId('presence-avatars')).toBeTruthy() + expect(screen.getByRole('button', { name: /add row/i })).toBeTruthy() + expect(screen.getByRole('button', { name: /comments/i })).toBeTruthy() + expect(screen.getByRole('button', { name: /open database actions/i })).toBeTruthy() + expect(screen.queryByTestId('share-button')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /open database actions/i })) + + expect(screen.getByText('Schema info')).toBeTruthy() + expect(screen.getByText('Clone schema')).toBeTruthy() + expect(screen.getByTestId('share-button')).toBeTruthy() + }) + + it('keeps comments accessible from the compact toolbar', async () => { + render() + + expect((await screen.findByTestId('comments-sidebar')).textContent).toContain('closed') + + fireEvent.click(screen.getByRole('button', { name: /comments/i })) + + expect(screen.getByTestId('comments-sidebar').textContent).toContain('open') + }) + + it('preserves the richer header outside focused mode', async () => { + render() + + expect(await screen.findByDisplayValue('Focus DB')).toBeTruthy() + expect(screen.getByTestId('share-button')).toBeTruthy() + expect(screen.queryByRole('button', { name: /open database actions/i })).toBeNull() + }) +}) diff --git a/apps/electron/src/renderer/components/DatabaseView.tsx b/apps/electron/src/renderer/components/DatabaseView.tsx index 50ff187bc..efc8973de 100644 --- a/apps/electron/src/renderer/components/DatabaseView.tsx +++ b/apps/electron/src/renderer/components/DatabaseView.tsx @@ -28,7 +28,15 @@ import { type SchemaVersionEntry } from '@xnetjs/data' import { useNode, useIdentity, useMutate, useQuery } from '@xnetjs/react' -import { CommentPopover, CommentsSidebar, type CommentThreadData } from '@xnetjs/ui' +import { + CommentPopover, + CommentsSidebar, + Menu, + MenuItem, + MenuLabel, + MenuSeparator, + type CommentThreadData +} from '@xnetjs/ui' import { TableView, BoardView, @@ -40,10 +48,9 @@ import { type ViewConfig, type TableRow, type CellPresence, - type ColumnUpdate, type NewColumnDefinition } from '@xnetjs/views' -import { Table, LayoutGrid, Plus, Info, Copy } from 'lucide-react' +import { Table, LayoutGrid, Plus, Info, Copy, Ellipsis, MessageSquare } from 'lucide-react' import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import * as Y from 'yjs' import { PresenceAvatars } from './PresenceAvatars' @@ -117,6 +124,118 @@ function buildDefaultBoardView(columns: StoredColumn[]): ViewConfig { } } +function DatabaseViewModeToggle({ + viewMode, + onChange, + compact = false +}: { + viewMode: ViewMode + onChange: (mode: ViewMode) => void + compact?: boolean +}): React.ReactElement { + return ( +
+