feat: session sharing frontend (#76)
* feat: add session sharing types, API client, and utilities Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SessionTimeline and ActionMenu reusable components SessionTimeline extracts timeline/checklist rendering from SessionDetailPage into a reusable component for both authenticated and public session views. ActionMenu provides a dropdown action menu with keyboard/click-outside dismiss. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add ShareSessionModal and integrate into SessionDetailPage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Share Progress popover to TreeNavigationPage Replace the single "Copy for Ticket" button with a "Share Progress" popover that offers three actions: Copy Progress Summary (existing PSA export flow), Copy Share Link (auto-creates account-only share if needed), and Manage Share Links (opens ShareSessionModal). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add public SharedSessionPage with tree preview Add the public-facing shared session page at /share/:shareToken that renders shared sessions without authentication. Includes error handling for 401 (redirect to login), 403 (access denied), 404 (not found), and 410 (expired). The page features a minimal header, session metadata, SessionTimeline component, and a new SharedSessionTreePreview component that renders the decision tree structure with the path taken highlighted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add My Shares management page with nav link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review issues in session sharing - Add useCallback for loadShares in ShareSessionModal (React hook deps) - Use TreeStructure type instead of Record<string, unknown> for type safety - Fix login redirect format to match LoginPage's expected state shape Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add focused tests for session sharing utilities and API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve tree_structure type compatibility for shared session views - Use TreeStructure & Record<string, unknown> intersection for JSONB flexibility - Add explicit cast in SharedSessionTreePreview for recursive node rendering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add session sharing learnings to CLAUDE.md Add gotchas #12 (TreeStructure vs Tree types) and #13 (login redirect state format), note about npm run build strictness, and public route pattern to Common Tasks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: procedural editor UX improvements Add URL intake field type, fix variable name editing collapsing fields (index-based keys/updates), auto-generate variable names by field type, add section header as first-class step type, and simplify step editor with "More Options" collapsible for advanced fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: allow section_header step type in validation, improve tag input - Add 'section_header' to VALID_STEP_TYPES in backend validation so procedural flows with section headers can be published - Replace procedural editor's inline tag input with TagInput component (supports autocomplete, Tab, comma, semicolon, and paste splitting) - Add semicolon delimiter support to TagInput component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add type-aware routing for procedural flows Centralizes tree navigation routing via getTreeNavigatePath helper. Fixes all pages to route procedural sessions to /flows/:id/navigate instead of /trees/:id/navigate. Adds safety redirect in troubleshooting navigator and resume support in procedural navigator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unused index prop from IntakeFieldEditor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #76.
This commit is contained in:
31
frontend/src/lib/routing.ts
Normal file
31
frontend/src/lib/routing.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shared routing helpers for tree/session navigation.
|
||||
* Centralizes the logic for determining the correct navigation path
|
||||
* based on tree type (troubleshooting vs procedural).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the navigation path for starting or resuming a tree/session.
|
||||
*/
|
||||
export function getTreeNavigatePath(
|
||||
treeId: string,
|
||||
treeType?: string
|
||||
): string {
|
||||
if (treeType === 'procedural') {
|
||||
return `/flows/${treeId}/navigate`
|
||||
}
|
||||
return `/trees/${treeId}/navigate`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the editor path for a tree.
|
||||
*/
|
||||
export function getTreeEditorPath(
|
||||
treeId: string,
|
||||
treeType?: string
|
||||
): string {
|
||||
if (treeType === 'procedural') {
|
||||
return `/flows/${treeId}/edit`
|
||||
}
|
||||
return `/trees/${treeId}/edit`
|
||||
}
|
||||
85
frontend/src/lib/sessionShare.test.ts
Normal file
85
frontend/src/lib/sessionShare.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import type { SessionShare } from '@/types'
|
||||
import { buildSessionShareUrl, filterSharesForSession, getLatestActiveShareForSession } from './sessionShare'
|
||||
|
||||
function makeMockShare(overrides: Partial<SessionShare> = {}): SessionShare {
|
||||
return {
|
||||
id: 'share-1',
|
||||
session_id: 'session-1',
|
||||
account_id: 'account-1',
|
||||
share_token: 'abc123',
|
||||
share_name: null,
|
||||
visibility: 'public',
|
||||
created_by: 'user-1',
|
||||
created_at: '2026-02-14T10:00:00Z',
|
||||
updated_at: '2026-02-14T10:00:00Z',
|
||||
expires_at: null,
|
||||
view_count: 0,
|
||||
last_viewed_at: null,
|
||||
is_active: true,
|
||||
share_url: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('buildSessionShareUrl', () => {
|
||||
it('returns share_url when present', () => {
|
||||
const share = makeMockShare({ share_url: 'https://resolutionflow.com/share/abc123' })
|
||||
expect(buildSessionShareUrl(share)).toBe('https://resolutionflow.com/share/abc123')
|
||||
})
|
||||
|
||||
it('constructs URL from token when share_url is null', () => {
|
||||
vi.stubGlobal('location', { origin: 'http://localhost:5173' })
|
||||
const share = makeMockShare({ share_token: 'tok-xyz', share_url: null })
|
||||
expect(buildSessionShareUrl(share)).toBe('http://localhost:5173/share/tok-xyz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterSharesForSession', () => {
|
||||
it('filters to matching session_id and active shares', () => {
|
||||
const shares = [
|
||||
makeMockShare({ id: 's1', session_id: 'sess-A', is_active: true }),
|
||||
makeMockShare({ id: 's2', session_id: 'sess-B', is_active: true }),
|
||||
makeMockShare({ id: 's3', session_id: 'sess-A', is_active: true }),
|
||||
]
|
||||
const result = filterSharesForSession(shares, 'sess-A')
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map(s => s.id)).toEqual(['s1', 's3'])
|
||||
})
|
||||
|
||||
it('excludes inactive shares', () => {
|
||||
const shares = [
|
||||
makeMockShare({ id: 's1', session_id: 'sess-A', is_active: true }),
|
||||
makeMockShare({ id: 's2', session_id: 'sess-A', is_active: false }),
|
||||
]
|
||||
const result = filterSharesForSession(shares, 'sess-A')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLatestActiveShareForSession', () => {
|
||||
it('returns the most recently created share', () => {
|
||||
const shares = [
|
||||
makeMockShare({ id: 'old', session_id: 'sess-A', created_at: '2026-02-10T10:00:00Z', is_active: true }),
|
||||
makeMockShare({ id: 'newest', session_id: 'sess-A', created_at: '2026-02-14T12:00:00Z', is_active: true }),
|
||||
makeMockShare({ id: 'mid', session_id: 'sess-A', created_at: '2026-02-12T10:00:00Z', is_active: true }),
|
||||
]
|
||||
const result = getLatestActiveShareForSession(shares, 'sess-A')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.id).toBe('newest')
|
||||
})
|
||||
|
||||
it('returns null when no shares match', () => {
|
||||
const shares = [
|
||||
makeMockShare({ session_id: 'sess-B', is_active: true }),
|
||||
makeMockShare({ session_id: 'sess-A', is_active: false }),
|
||||
]
|
||||
const result = getLatestActiveShareForSession(shares, 'sess-A')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
27
frontend/src/lib/sessionShare.ts
Normal file
27
frontend/src/lib/sessionShare.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { SessionShare } from '@/types'
|
||||
|
||||
/**
|
||||
* Build the full share URL from a SessionShare object.
|
||||
* Uses share.share_url if present, otherwise constructs from token.
|
||||
*/
|
||||
export function buildSessionShareUrl(share: SessionShare): string {
|
||||
if (share.share_url) return share.share_url
|
||||
return `${window.location.origin}/share/${share.share_token}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter shares to only those belonging to a specific session.
|
||||
*/
|
||||
export function filterSharesForSession(shares: SessionShare[], sessionId: string): SessionShare[] {
|
||||
return shares.filter(s => s.session_id === sessionId && s.is_active)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent active share for a given session.
|
||||
* Returns null if no active shares exist.
|
||||
*/
|
||||
export function getLatestActiveShareForSession(shares: SessionShare[], sessionId: string): SessionShare | null {
|
||||
const sessionShares = filterSharesForSession(shares, sessionId)
|
||||
if (sessionShares.length === 0) return null
|
||||
return sessionShares.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
|
||||
}
|
||||
Reference in New Issue
Block a user