feat: session sharing frontend #76
@@ -139,7 +139,7 @@ pytest --override-ini="addopts="
|
||||
# First time only: create test database
|
||||
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
|
||||
|
||||
# Frontend build
|
||||
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
|
||||
cd frontend && npm run build
|
||||
|
||||
# Database migrations
|
||||
@@ -222,6 +222,10 @@ markSaved() // Clear isDirty BEFORE navigate()
|
||||
navigate(`/trees/${newTree.id}/edit`)
|
||||
```
|
||||
|
||||
**12. TreeStructure vs Tree types:** `TreeStructure` is for node structure only — it does NOT have `tree_type`, `name`, etc. Those are on `Tree`. JSONB tree snapshots need `TreeStructure & Record<string, unknown>` for extra fields.
|
||||
|
||||
**13. Login redirect state format:** `navigate('/login', { state: { from: { pathname: '/path' } } })` — LoginPage expects `state.from.pathname` (object), NOT a plain string.
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Permissions
|
||||
@@ -260,6 +264,7 @@ navigate(`/trees/${newTree.id}/edit`)
|
||||
|
||||
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
|
||||
- **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx`
|
||||
- **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children.
|
||||
- **Schema change:** Update model → `alembic revision --autogenerate -m "desc"` → review → `alembic upgrade head`
|
||||
- **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ def _validate_children(children: list[dict[str, Any]], path: str, errors: list[d
|
||||
|
||||
# --- Procedural Tree Validation ---
|
||||
|
||||
VALID_STEP_TYPES = {"procedure_step", "procedure_end"}
|
||||
VALID_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}
|
||||
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ TreeType = Literal['troubleshooting', 'procedural']
|
||||
# --- Intake Form Schemas ---
|
||||
|
||||
FIELD_TYPES = Literal[
|
||||
'text', 'textarea', 'number', 'ip_address', 'email',
|
||||
'text', 'textarea', 'number', 'ip_address', 'email', 'url',
|
||||
'select', 'multi_select', 'checkbox', 'password'
|
||||
]
|
||||
|
||||
|
||||
72
frontend/src/api/sessions.test.ts
Normal file
72
frontend/src/api/sessions.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { sessionsApi } from './sessions'
|
||||
import apiClient from './client'
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockClient = apiClient as unknown as {
|
||||
get: ReturnType<typeof vi.fn>
|
||||
post: ReturnType<typeof vi.fn>
|
||||
delete: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('sessionsApi sharing methods', () => {
|
||||
it('createShare hits POST /sessions/{id}/shares with correct payload', async () => {
|
||||
const mockShare = { id: 'share-1', share_token: 'tok-123', visibility: 'public' }
|
||||
mockClient.post.mockResolvedValue({ data: mockShare })
|
||||
|
||||
const payload = { visibility: 'public' as const, share_name: 'My Share' }
|
||||
const result = await sessionsApi.createShare('session-42', payload)
|
||||
|
||||
expect(mockClient.post).toHaveBeenCalledWith('/sessions/session-42/shares', payload)
|
||||
expect(result).toEqual(mockShare)
|
||||
})
|
||||
|
||||
it('listMyShares hits GET /shares/my-shares', async () => {
|
||||
const mockShares = [
|
||||
{ id: 'share-1', share_token: 'tok-1' },
|
||||
{ id: 'share-2', share_token: 'tok-2' },
|
||||
]
|
||||
mockClient.get.mockResolvedValue({ data: mockShares })
|
||||
|
||||
const result = await sessionsApi.listMyShares()
|
||||
|
||||
expect(mockClient.get).toHaveBeenCalledWith('/shares/my-shares')
|
||||
expect(result).toEqual(mockShares)
|
||||
})
|
||||
|
||||
it('revokeShare hits DELETE /shares/{id}', async () => {
|
||||
mockClient.delete.mockResolvedValue({})
|
||||
|
||||
await sessionsApi.revokeShare('share-99')
|
||||
|
||||
expect(mockClient.delete).toHaveBeenCalledWith('/shares/share-99')
|
||||
})
|
||||
|
||||
it('getSharedSession hits GET /share/{token}', async () => {
|
||||
const mockView = {
|
||||
session_id: 'sess-1',
|
||||
tree_name: 'DNS Troubleshooting',
|
||||
path_taken: ['root', 'node-1'],
|
||||
decisions: [],
|
||||
}
|
||||
mockClient.get.mockResolvedValue({ data: mockView })
|
||||
|
||||
const result = await sessionsApi.getSharedSession('tok-abc')
|
||||
|
||||
expect(mockClient.get).toHaveBeenCalledWith('/share/tok-abc')
|
||||
expect(result).toEqual(mockView)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary } from '@/types'
|
||||
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary, SessionShareCreate, SessionShare, SharedSessionView } from '@/types'
|
||||
|
||||
export interface SessionListParams {
|
||||
page?: number
|
||||
@@ -85,6 +85,26 @@ export const sessionsApi = {
|
||||
const response = await apiClient.post<SaveAsTreeResponse>(`/sessions/${id}/save-as-tree`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Session Sharing
|
||||
async createShare(sessionId: string, data: SessionShareCreate): Promise<SessionShare> {
|
||||
const response = await apiClient.post<SessionShare>(`/sessions/${sessionId}/shares`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listMyShares(): Promise<SessionShare[]> {
|
||||
const response = await apiClient.get<SessionShare[]>('/shares/my-shares')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async revokeShare(shareId: string): Promise<void> {
|
||||
await apiClient.delete(`/shares/${shareId}`)
|
||||
},
|
||||
|
||||
async getSharedSession(shareToken: string): Promise<SharedSessionView> {
|
||||
const response = await apiClient.get<SharedSessionView>(`/share/${shareToken}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default sessionsApi
|
||||
|
||||
100
frontend/src/components/common/ActionMenu.tsx
Normal file
100
frontend/src/components/common/ActionMenu.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { MoreVertical } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MenuAction {
|
||||
label: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
interface ActionMenuProps {
|
||||
actions: MenuAction[]
|
||||
align?: 'left' | 'right' // default 'right'
|
||||
}
|
||||
|
||||
export function ActionMenu({ actions, align = 'right' }: ActionMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleItemClick = (action: MenuAction) => {
|
||||
if (action.disabled) return
|
||||
action.onClick()
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-2 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
aria-label="Actions"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-50 mt-1 min-w-[180px] glass-card rounded-lg p-1',
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
)}
|
||||
>
|
||||
{actions.map((action, index) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleItemClick(action)}
|
||||
disabled={action.disabled}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
|
||||
action.disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: action.variant === 'destructive'
|
||||
? 'text-red-400 hover:bg-white/10 hover:text-red-300'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
{action.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { MenuAction, ActionMenuProps }
|
||||
|
||||
export default ActionMenu
|
||||
@@ -106,10 +106,14 @@ export function TagInput({
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowSuggestions(false)
|
||||
setSelectedIndex(-1)
|
||||
} else if (e.key === ',' || e.key === 'Tab') {
|
||||
} else if (e.key === ',' || e.key === ';' || e.key === 'Tab') {
|
||||
if (inputValue.trim()) {
|
||||
e.preventDefault()
|
||||
addTag(inputValue)
|
||||
// Support multiple tags separated by commas or semicolons
|
||||
const parts = inputValue.split(/[,;]/).map(s => s.trim()).filter(Boolean)
|
||||
for (const part of parts) {
|
||||
addTag(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +161,20 @@ export function TagInput({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
// If pasted text contains delimiters, split into tags immediately
|
||||
if (val.includes(',') || val.includes(';')) {
|
||||
const parts = val.split(/[,;]/).map(s => s.trim()).filter(Boolean)
|
||||
const last = parts.pop()
|
||||
for (const part of parts) {
|
||||
addTag(part)
|
||||
}
|
||||
setInputValue(last || '')
|
||||
} else {
|
||||
setInputValue(val)
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
if (inputValue.length >= 1 && suggestions.length > 0) {
|
||||
@@ -221,7 +238,7 @@ export function TagInput({
|
||||
|
||||
{/* Helper text */}
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
{tags.length}/{maxTags} tags. Press Enter or comma to add.
|
||||
{tags.length}/{maxTags} tags. Press Enter, Tab, comma, or semicolon to add.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -84,6 +84,7 @@ export function AppLayout() {
|
||||
},
|
||||
{ path: '/my-trees', label: 'My Flows' },
|
||||
{ path: '/sessions', label: 'Sessions' },
|
||||
{ path: '/shares', label: 'My Shares' },
|
||||
{ path: '/account', label: 'Account' },
|
||||
...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []),
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ const FIELD_TYPE_OPTIONS: { value: IntakeFieldType; label: string }[] = [
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'ip_address', label: 'IP Address' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'url', label: 'URL' },
|
||||
{ value: 'select', label: 'Select (Dropdown)' },
|
||||
{ value: 'multi_select', label: 'Multi-Select' },
|
||||
{ value: 'checkbox', label: 'Checkbox' },
|
||||
|
||||
@@ -36,10 +36,10 @@ export function IntakeFormBuilder() {
|
||||
<div className="space-y-2">
|
||||
{intakeForm.map((field, index) => (
|
||||
<IntakeFieldEditor
|
||||
key={field.variable_name + '-' + index}
|
||||
key={`field-${index}-${field.display_order}`}
|
||||
field={field}
|
||||
onUpdate={(updates) => updateField(field.variable_name, updates)}
|
||||
onRemove={() => removeField(field.variable_name)}
|
||||
onUpdate={(updates) => updateField(index, updates)}
|
||||
onRemove={() => removeField(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronUp, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Type } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronUp, ChevronDown, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Settings2 } from 'lucide-react'
|
||||
import type { ProceduralStep, StepContentType, IntakeFormField } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -18,6 +19,35 @@ interface StepEditorProps {
|
||||
}
|
||||
|
||||
export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVariables }: StepEditorProps) {
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
|
||||
// Section header steps get a minimal editor
|
||||
if (step.type === 'section_header') {
|
||||
return (
|
||||
<div className="glass-card rounded-xl border border-white/10 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white/50">Edit Section Header</span>
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
placeholder="Section title"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-xl border border-white/10 p-4">
|
||||
{/* Header */}
|
||||
@@ -48,55 +78,18 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content type + Section header row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
|
||||
<div className="flex gap-1">
|
||||
{CONTENT_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onUpdate({ content_type: opt.value })}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||
step.content_type === opt.value
|
||||
? 'bg-white/15 ' + opt.color
|
||||
: 'text-white/40 hover:bg-white/10 hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<Clock className="h-3 w-3" />
|
||||
Est. Minutes
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={step.estimated_minutes || ''}
|
||||
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
placeholder="—"
|
||||
min={1}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Header */}
|
||||
<div>
|
||||
{/* Est. Minutes */}
|
||||
<div className="w-40">
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<Type className="h-3 w-3" />
|
||||
Section Header (optional)
|
||||
<Clock className="h-3 w-3" />
|
||||
Est. Minutes
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.section_header || ''}
|
||||
onChange={(e) => onUpdate({ section_header: e.target.value || undefined })}
|
||||
placeholder="e.g. Phase 2: AD Configuration"
|
||||
type="number"
|
||||
value={step.estimated_minutes || ''}
|
||||
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
placeholder="—"
|
||||
min={1}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
@@ -127,23 +120,6 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning text */}
|
||||
{(step.content_type === 'warning' || step.warning_text) && (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-yellow-400/70">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Warning Text
|
||||
</label>
|
||||
<textarea
|
||||
value={step.warning_text || ''}
|
||||
onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
|
||||
placeholder="Caution: This will restart the service..."
|
||||
rows={2}
|
||||
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commands */}
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
@@ -159,74 +135,127 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expected Outcome */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.expected_outcome || ''}
|
||||
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
|
||||
placeholder="Server should respond with..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
{/* More Options toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMore(!showMore)}
|
||||
className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60"
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
More Options
|
||||
{showMore ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
{/* Verification */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
Verification Prompt (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.verification_prompt || ''}
|
||||
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
|
||||
placeholder="Confirm the role was installed"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label>
|
||||
<select
|
||||
value={step.verification_type || ''}
|
||||
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="checkbox">Checkbox (confirm done)</option>
|
||||
<option value="text_input">Text input (enter value)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{showMore && (
|
||||
<div className="space-y-4 border-t border-white/[0.06] pt-4">
|
||||
{/* Content Type */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
|
||||
<div className="flex gap-1">
|
||||
{CONTENT_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onUpdate({ content_type: opt.value })}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||
step.content_type === opt.value
|
||||
? 'bg-white/15 ' + opt.color
|
||||
: 'text-white/40 hover:bg-white/10 hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference URL + Notes toggle */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Reference URL (optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={step.reference_url || ''}
|
||||
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
|
||||
placeholder="https://learn.microsoft.com/..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-sm text-white/60">
|
||||
{/* Warning text */}
|
||||
{(step.content_type === 'warning' || step.warning_text) && (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-yellow-400/70">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Warning Text
|
||||
</label>
|
||||
<textarea
|
||||
value={step.warning_text || ''}
|
||||
onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
|
||||
placeholder="Caution: This will restart the service..."
|
||||
rows={2}
|
||||
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expected Outcome */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={step.notes_enabled !== false}
|
||||
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
|
||||
className="rounded border-white/20"
|
||||
type="text"
|
||||
value={step.expected_outcome || ''}
|
||||
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
|
||||
placeholder="Server should respond with..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
Allow tech notes
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Verification */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
Verification Prompt (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.verification_prompt || ''}
|
||||
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
|
||||
placeholder="Confirm the role was installed"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label>
|
||||
<select
|
||||
value={step.verification_type || ''}
|
||||
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="checkbox">Checkbox (confirm done)</option>
|
||||
<option value="text_input">Text input (enter value)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference URL + Notes toggle */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Reference URL (optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={step.reference_url || ''}
|
||||
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
|
||||
placeholder="https://learn.microsoft.com/..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-sm text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={step.notes_enabled !== false}
|
||||
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
|
||||
className="rounded border-white/20"
|
||||
/>
|
||||
Allow tech notes
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield } from 'lucide-react'
|
||||
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react'
|
||||
import type { StepContentType } from '@/types'
|
||||
import { StepEditor } from './StepEditor'
|
||||
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
||||
@@ -18,11 +18,13 @@ export function StepList() {
|
||||
expandedStepId,
|
||||
setExpandedStepId,
|
||||
addStep,
|
||||
addSectionHeader,
|
||||
removeStep,
|
||||
updateStep,
|
||||
} = useProceduralEditorStore()
|
||||
|
||||
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
|
||||
let stepCounter = 0
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6">
|
||||
@@ -34,19 +36,27 @@ export function StepList() {
|
||||
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addStep()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Step
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => addSectionHeader()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<SeparatorHorizontal className="h-3.5 w-3.5" />
|
||||
Add Section
|
||||
</button>
|
||||
<button
|
||||
onClick={() => addStep()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => {
|
||||
{steps.map((step) => {
|
||||
if (step.type === 'procedure_end') {
|
||||
// Render end step as a simple footer
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
@@ -65,20 +75,57 @@ export function StepList() {
|
||||
)
|
||||
}
|
||||
|
||||
// Section header rendering
|
||||
if (step.type === 'section_header') {
|
||||
const isExpanded = expandedStepId === step.id
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={step.id}>
|
||||
<StepEditor
|
||||
step={step}
|
||||
stepNumber={0}
|
||||
onUpdate={(updates) => updateStep(step.id, updates)}
|
||||
onCollapse={() => setExpandedStepId(null)}
|
||||
availableVariables={intakeForm}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="group flex items-center gap-2 border-b border-white/[0.06] pb-1 pt-3"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-white/20 group-hover:text-white/40" />
|
||||
<span
|
||||
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-white/40 hover:text-white/60"
|
||||
onClick={() => setExpandedStepId(step.id)}
|
||||
>
|
||||
{step.title || 'Untitled Section'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="shrink-0 rounded p-1 text-white/30 opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regular procedure step
|
||||
stepCounter++
|
||||
const stepNumber = stepCounter
|
||||
const isExpanded = expandedStepId === step.id
|
||||
const contentType = step.content_type || 'action'
|
||||
const config = contentTypeConfig[contentType]
|
||||
const Icon = config.icon
|
||||
const stepNumber = index + 1
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{step.section_header && (
|
||||
<div className="mb-2 mt-4 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
|
||||
{step.section_header}
|
||||
</div>
|
||||
)}
|
||||
<StepEditor
|
||||
step={step}
|
||||
stepNumber={stepNumber}
|
||||
@@ -92,11 +139,6 @@ export function StepList() {
|
||||
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{step.section_header && (
|
||||
<div className="mb-2 mt-4 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
|
||||
{step.section_header}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded-xl border border-white/[0.06] px-3 py-2.5 transition-colors',
|
||||
|
||||
@@ -171,6 +171,18 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
||||
)
|
||||
break
|
||||
|
||||
case 'url':
|
||||
input = (
|
||||
<input
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={(e) => setValue(field.variable_name, e.target.value)}
|
||||
placeholder={field.placeholder || 'https://...'}
|
||||
className={baseInputClass}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
default: // text, ip_address, email
|
||||
input = (
|
||||
<input
|
||||
|
||||
207
frontend/src/components/session/SessionTimeline.tsx
Normal file
207
frontend/src/components/session/SessionTimeline.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import type { DecisionRecord } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SessionTimelineProps {
|
||||
decisions: DecisionRecord[]
|
||||
treeType?: string // 'procedural' or 'troubleshooting' (default)
|
||||
startedAt: string
|
||||
completedAt: string | null
|
||||
showCopyButtons?: boolean // default true
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
function formatDuration(durationSeconds: number | null | undefined) {
|
||||
if (durationSeconds == null || durationSeconds < 0) return null
|
||||
if (durationSeconds < 60) return `${durationSeconds}s`
|
||||
const hours = Math.floor(durationSeconds / 3600)
|
||||
const minutes = Math.floor((durationSeconds % 3600) / 60)
|
||||
const seconds = durationSeconds % 60
|
||||
if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
|
||||
}
|
||||
|
||||
export function SessionTimeline({
|
||||
decisions,
|
||||
treeType,
|
||||
startedAt,
|
||||
completedAt,
|
||||
showCopyButtons = true,
|
||||
}: SessionTimelineProps) {
|
||||
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
|
||||
|
||||
const handleCopyStep = async (decision: DecisionRecord, index: number) => {
|
||||
const lines: string[] = []
|
||||
if (decision.question) lines.push(`Question: ${decision.question}`)
|
||||
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
|
||||
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
|
||||
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
|
||||
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
|
||||
try {
|
||||
await navigator.clipboard.writeText(lines.join('\n'))
|
||||
setCopiedStepIndex(index)
|
||||
setTimeout(() => setCopiedStepIndex(null), 2000)
|
||||
} catch {
|
||||
// Clipboard access denied
|
||||
}
|
||||
}
|
||||
|
||||
if (treeType === 'procedural') {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Procedure Steps</h2>
|
||||
<div className="space-y-2">
|
||||
{decisions.map((decision, index) => {
|
||||
const isCompleted = decision.answer === 'completed'
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'glass-card rounded-xl p-4',
|
||||
isCompleted && 'border-l-2 border-emerald-400/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn(
|
||||
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
|
||||
isCompleted ? 'bg-emerald-400/10 text-emerald-400' : 'bg-white/10 text-white/50'
|
||||
)}>
|
||||
{isCompleted ? '\u2713' : index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-white">{decision.question || 'Step'}</p>
|
||||
{decision.notes && (
|
||||
<p className="mt-1.5 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
Verification: {decision.command_output}
|
||||
</p>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-1 text-xs text-white/30">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{showCopyButtons && (
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{completedAt && (
|
||||
<div className="flex items-center gap-3 pl-2 pt-2 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500" />
|
||||
<span className="text-emerald-400">
|
||||
Procedure completed: {formatDate(completedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default: troubleshooting decision timeline
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Decision Timeline</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-white" />
|
||||
<span className="text-white/40">
|
||||
Session started: {formatDate(startedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{decisions.map((decision, index) => (
|
||||
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
{decision.question && (
|
||||
<p className="font-medium text-white">{decision.question}</p>
|
||||
)}
|
||||
{decision.answer && (
|
||||
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
||||
)}
|
||||
{decision.action_performed && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
Action: {decision.action_performed}
|
||||
</p>
|
||||
)}
|
||||
{decision.notes && (
|
||||
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<div className="mt-2">
|
||||
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
|
||||
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
|
||||
{decision.command_output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-2 text-xs text-white/50">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
{formatDate(decision.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
{showCopyButtons && (
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{completedAt && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<span className="text-emerald-400">
|
||||
Session completed: {formatDate(completedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionTimeline
|
||||
431
frontend/src/components/session/ShareSessionModal.tsx
Normal file
431
frontend/src/components/session/ShareSessionModal.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { X, Copy, Check, Globe, Users, Clock, Trash2, Link2 } from 'lucide-react'
|
||||
import type { SessionShare, SessionShareVisibility } from '@/types'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { buildSessionShareUrl, filterSharesForSession } from '@/lib/sessionShare'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface ShareSessionModalProps {
|
||||
sessionId: string
|
||||
sessionLabel: string // e.g. ticket number or "Session Details"
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ExpirationPreset = 'never' | '1day' | '7days' | '30days' | 'custom'
|
||||
|
||||
function getRelativeTime(dateString: string): string {
|
||||
const now = Date.now()
|
||||
const date = new Date(dateString).getTime()
|
||||
const diffMs = now - date
|
||||
const diffSeconds = Math.floor(diffMs / 1000)
|
||||
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffSeconds < 60) return 'just now'
|
||||
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
|
||||
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`
|
||||
}
|
||||
|
||||
function getExpirationLabel(expiresAt: string | null): { text: string; isExpired: boolean } {
|
||||
if (!expiresAt) return { text: 'No expiration', isExpired: false }
|
||||
const now = Date.now()
|
||||
const expiry = new Date(expiresAt).getTime()
|
||||
if (expiry <= now) return { text: 'Expired', isExpired: true }
|
||||
|
||||
const diffMs = expiry - now
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 0) return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false }
|
||||
if (diffHours > 0) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false }
|
||||
return { text: 'Expires soon', isExpired: false }
|
||||
}
|
||||
|
||||
function computeExpiresAt(preset: ExpirationPreset, customDatetime: string): string | undefined {
|
||||
if (preset === 'never') return undefined
|
||||
if (preset === 'custom') {
|
||||
if (!customDatetime) return undefined
|
||||
return new Date(customDatetime).toISOString()
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
switch (preset) {
|
||||
case '1day':
|
||||
now.setDate(now.getDate() + 1)
|
||||
break
|
||||
case '7days':
|
||||
now.setDate(now.getDate() + 7)
|
||||
break
|
||||
case '30days':
|
||||
now.setDate(now.getDate() + 30)
|
||||
break
|
||||
}
|
||||
return now.toISOString()
|
||||
}
|
||||
|
||||
export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }: ShareSessionModalProps) {
|
||||
const [shares, setShares] = useState<SessionShare[]>([])
|
||||
const [isLoadingShares, setIsLoadingShares] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [copiedShareId, setCopiedShareId] = useState<string | null>(null)
|
||||
|
||||
// Form state
|
||||
const [visibility, setVisibility] = useState<SessionShareVisibility>('account')
|
||||
const [shareName, setShareName] = useState('')
|
||||
const [expirationPreset, setExpirationPreset] = useState<ExpirationPreset>('never')
|
||||
const [customDatetime, setCustomDatetime] = useState('')
|
||||
const [visibilityError, setVisibilityError] = useState<string | null>(null)
|
||||
|
||||
const loadShares = useCallback(async () => {
|
||||
setIsLoadingShares(true)
|
||||
try {
|
||||
const allShares = await sessionsApi.listMyShares()
|
||||
const sessionShares = filterSharesForSession(allShares, sessionId)
|
||||
// Sort newest first
|
||||
sessionShares.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
setShares(sessionShares)
|
||||
} catch (err) {
|
||||
console.error('Failed to load shares:', err)
|
||||
} finally {
|
||||
setIsLoadingShares(false)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadShares()
|
||||
// Reset form state
|
||||
setVisibility('account')
|
||||
setShareName('')
|
||||
setExpirationPreset('never')
|
||||
setCustomDatetime('')
|
||||
setVisibilityError(null)
|
||||
setCopiedShareId(null)
|
||||
}
|
||||
}, [isOpen, sessionId, loadShares])
|
||||
|
||||
const handleGenerateLink = async () => {
|
||||
setIsGenerating(true)
|
||||
setVisibilityError(null)
|
||||
try {
|
||||
const expires_at = computeExpiresAt(expirationPreset, customDatetime)
|
||||
const newShare = await sessionsApi.createShare(sessionId, {
|
||||
visibility,
|
||||
share_name: shareName.trim() || undefined,
|
||||
expires_at,
|
||||
})
|
||||
setShares([newShare, ...shares])
|
||||
toast.success('Share link generated')
|
||||
// Reset form
|
||||
setShareName('')
|
||||
setExpirationPreset('never')
|
||||
setCustomDatetime('')
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number; data?: { detail?: string } } }
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
error.response?.data?.detail?.toLowerCase().includes('public session sharing')
|
||||
) {
|
||||
setVisibilityError(error.response.data.detail ?? 'Organization does not allow public session sharing')
|
||||
} else {
|
||||
console.error('Failed to generate share link:', err)
|
||||
toast.error('Failed to generate share link')
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyUrl = async (share: SessionShare) => {
|
||||
try {
|
||||
const url = buildSessionShareUrl(share)
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopiedShareId(share.id)
|
||||
toast.success('Link copied to clipboard')
|
||||
setTimeout(() => setCopiedShareId(null), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy link:', err)
|
||||
toast.error('Failed to copy link')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (shareId: string) => {
|
||||
try {
|
||||
await sessionsApi.revokeShare(shareId)
|
||||
setShares(shares.filter((s) => s.id !== shareId))
|
||||
toast.success('Share link revoked')
|
||||
} catch (err) {
|
||||
console.error('Failed to revoke share:', err)
|
||||
toast.error('Failed to revoke share')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const presetButtons: { value: ExpirationPreset; label: string }[] = [
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '1day', label: '1 day' },
|
||||
{ value: '7days', label: '7 days' },
|
||||
{ value: '30days', label: '30 days' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg glass-card rounded-2xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Share Session</h2>
|
||||
<p className="text-sm text-white/40">{sessionLabel}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-white/40 hover:bg-white/[0.06] hover:text-white"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="max-h-[60vh] overflow-y-auto px-6 py-4 space-y-6">
|
||||
{/* Create Share Form */}
|
||||
<div className="space-y-4">
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Visibility
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => { setVisibility('account'); setVisibilityError(null) }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||
visibility === 'account'
|
||||
? 'border-white/20 bg-white/10 text-white'
|
||||
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">Account Only</div>
|
||||
<div className="text-xs text-white/40">Visible to your team</div>
|
||||
</div>
|
||||
{visibility === 'account' && (
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setVisibility('public'); setVisibilityError(null) }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||
visibility === 'public'
|
||||
? 'border-white/20 bg-white/10 text-white'
|
||||
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">Public</div>
|
||||
<div className="text-xs text-white/40">Anyone with the link</div>
|
||||
</div>
|
||||
{visibility === 'public' && (
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{visibilityError && (
|
||||
<p className="mt-2 text-sm text-red-400">{visibilityError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share Name */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Share Name <span className="text-white/40">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={shareName}
|
||||
onChange={(e) => setShareName(e.target.value.slice(0, 100))}
|
||||
placeholder="e.g. Training link, Customer escalation"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder-white/30',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expiration */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Expiration
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presetButtons.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => setExpirationPreset(preset.value)}
|
||||
className={cn(
|
||||
'rounded-md border px-3 py-1.5 text-sm transition-colors',
|
||||
expirationPreset === preset.value
|
||||
? 'border-white/20 bg-white/10 text-white'
|
||||
: 'border-white/10 text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{expirationPreset === 'custom' && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customDatetime}
|
||||
onChange={(e) => setCustomDatetime(e.target.value)}
|
||||
className={cn(
|
||||
'mt-2 w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'[color-scheme:dark]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
onClick={handleGenerateLink}
|
||||
disabled={isGenerating || (expirationPreset === 'custom' && !customDatetime)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{isGenerating ? 'Generating...' : 'Generate Link'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Existing Shares */}
|
||||
{shares.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-white">
|
||||
Active Shares ({shares.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{shares.map((share) => {
|
||||
const expiration = getExpirationLabel(share.expires_at)
|
||||
const isCopied = copiedShareId === share.id
|
||||
return (
|
||||
<div
|
||||
key={share.id}
|
||||
className="glass-card rounded-xl p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs',
|
||||
share.visibility === 'public'
|
||||
? 'bg-white/10 text-white/70'
|
||||
: 'bg-white/10 text-white/70'
|
||||
)}>
|
||||
{share.visibility === 'public' ? (
|
||||
<Globe className="h-3 w-3" />
|
||||
) : (
|
||||
<Users className="h-3 w-3" />
|
||||
)}
|
||||
{share.visibility === 'public' ? 'Public' : 'Account'}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-white">
|
||||
{share.share_name || 'Untitled share'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40">
|
||||
<span>{getRelativeTime(share.created_at)}</span>
|
||||
<span>
|
||||
{share.view_count > 0
|
||||
? `${share.view_count} view${share.view_count === 1 ? '' : 's'}`
|
||||
: 'Not viewed yet'}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'flex items-center gap-1',
|
||||
expiration.isExpired && 'text-red-400'
|
||||
)}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{expiration.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => handleCopyUrl(share)}
|
||||
title="Copy share URL"
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-sm transition-colors',
|
||||
isCopied
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-white/50 hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRevoke(share.id)}
|
||||
title="Revoke share"
|
||||
className="rounded-md border border-white/10 p-1.5 text-white/50 hover:bg-red-500/10 hover:border-red-500/30 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoadingShares && shares.length === 0 && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShareSessionModal
|
||||
89
frontend/src/components/session/SharedSessionTreePreview.tsx
Normal file
89
frontend/src/components/session/SharedSessionTreePreview.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { TreeStructure } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SharedSessionTreePreviewProps {
|
||||
treeStructure: TreeStructure
|
||||
pathTaken: string[]
|
||||
}
|
||||
|
||||
const nodeTypeColors: Record<string, string> = {
|
||||
root: 'bg-white',
|
||||
decision: 'bg-blue-400',
|
||||
action: 'bg-yellow-400',
|
||||
solution: 'bg-emerald-400',
|
||||
information: 'bg-white/50',
|
||||
}
|
||||
|
||||
function getNodeTitle(node: Record<string, unknown>): string {
|
||||
return (
|
||||
(node.question as string) ||
|
||||
(node.title as string) ||
|
||||
(node.node_type as string) ||
|
||||
'Untitled'
|
||||
)
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
pathTaken,
|
||||
}: {
|
||||
node: Record<string, unknown>
|
||||
depth: number
|
||||
pathTaken: string[]
|
||||
}) {
|
||||
const nodeId = (node.id as string) || ''
|
||||
const nodeType = (node.node_type as string) || 'decision'
|
||||
const isInPath = pathTaken.includes(nodeId)
|
||||
const children = (node.children as Record<string, unknown>[]) || []
|
||||
const colorClass = nodeTypeColors[nodeType] || 'bg-white/50'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 text-sm',
|
||||
isInPath
|
||||
? 'rounded-md border-l-2 border-white/40 bg-white/10 font-medium text-white'
|
||||
: 'text-white/30'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
>
|
||||
<span
|
||||
className={cn('h-2 w-2 shrink-0 rounded-full', colorClass)}
|
||||
/>
|
||||
<span className="truncate">{getNodeTitle(node)}</span>
|
||||
</div>
|
||||
{children.map((child, index) => (
|
||||
<TreeNode
|
||||
key={(child.id as string) || index}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
pathTaken={pathTaken}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SharedSessionTreePreview({
|
||||
treeStructure,
|
||||
pathTaken,
|
||||
}: SharedSessionTreePreviewProps) {
|
||||
if (!treeStructure) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-2xl">
|
||||
<div className="sticky top-0 z-10 rounded-t-2xl border-b border-white/[0.06] bg-black/80 px-6 py-4 backdrop-blur">
|
||||
<h3 className="text-sm font-semibold text-white">Tree Structure</h3>
|
||||
</div>
|
||||
<div className="max-h-[600px] overflow-y-auto py-2">
|
||||
<TreeNode node={treeStructure as unknown as Record<string, unknown>} depth={0} pathTaken={pathTaken} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SharedSessionTreePreview
|
||||
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]
|
||||
}
|
||||
240
frontend/src/pages/MySharesPage.tsx
Normal file
240
frontend/src/pages/MySharesPage.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { buildSessionShareUrl } from '@/lib/sessionShare'
|
||||
import type { SessionShare } from '@/types'
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMinutes < 1) return 'just now'
|
||||
if (diffHours < 1) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`
|
||||
if (diffDays < 1) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
|
||||
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`
|
||||
}
|
||||
|
||||
function formatExpiration(expiresAt: string | null): { text: string; isExpired: boolean } {
|
||||
if (!expiresAt) return { text: 'No expiration', isExpired: false }
|
||||
|
||||
const expiry = new Date(expiresAt)
|
||||
const now = new Date()
|
||||
const diffMs = expiry.getTime() - now.getTime()
|
||||
|
||||
if (diffMs <= 0) return { text: 'Expired', isExpired: true }
|
||||
|
||||
const diffMinutes = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffHours < 1) return { text: `Expires in ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}`, isExpired: false }
|
||||
if (diffDays < 1) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false }
|
||||
return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false }
|
||||
}
|
||||
|
||||
export default function MySharesPage() {
|
||||
const navigate = useNavigate()
|
||||
const [shares, setShares] = useState<SessionShare[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
const fetchShares = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await sessionsApi.listMyShares()
|
||||
setShares(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load shares')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchShares()
|
||||
}, [fetchShares])
|
||||
|
||||
const handleCopyLink = async (share: SessionShare) => {
|
||||
try {
|
||||
const url = buildSessionShareUrl(share)
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopiedId(share.id)
|
||||
toast.success('Link copied')
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
} catch {
|
||||
toast.error('Failed to copy link')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (share: SessionShare) => {
|
||||
const confirmed = window.confirm(
|
||||
'Revoke this share link? Anyone with the link will no longer be able to access the session.'
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await sessionsApi.revokeShare(share.id)
|
||||
setShares((prev) => prev.filter((s) => s.id !== share.id))
|
||||
toast.success('Share link revoked')
|
||||
} catch {
|
||||
toast.error('Failed to revoke share link')
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-white/20 border-t-white" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="glass-card rounded-xl p-6 border border-red-400/20">
|
||||
<div className="text-center">
|
||||
<p className="text-red-400 text-sm mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchShares}
|
||||
className="bg-white text-black hover:bg-white/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/sessions"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-white/40 hover:text-white/70 transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to sessions
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">My Shared Sessions</h1>
|
||||
<p className="text-white/40 mt-1">Manage your session share links</p>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{shares.length === 0 ? (
|
||||
<div className="glass-card rounded-xl p-12 text-center">
|
||||
<Link2 className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold text-white mb-2">No shared sessions</h2>
|
||||
<p className="text-white/40 text-sm mb-6">
|
||||
Share a session from the session detail page to create a link
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="bg-white text-black hover:bg-white/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Go to Sessions
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{shares.map((share) => {
|
||||
const expiration = formatExpiration(share.expires_at)
|
||||
const isCopied = copiedId === share.id
|
||||
|
||||
return (
|
||||
<div key={share.id} className="glass-card rounded-xl p-5">
|
||||
{/* Top row: badge + name */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs rounded-full px-2 py-0.5 bg-white/10 text-white/60">
|
||||
{share.visibility === 'public' ? (
|
||||
<Globe className="h-3 w-3" />
|
||||
) : (
|
||||
<Users className="h-3 w-3" />
|
||||
)}
|
||||
{share.visibility === 'public' ? 'Public' : 'Account Only'}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
{share.share_name || 'Untitled share'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Session info */}
|
||||
<p className="text-sm text-white/50 mb-2">
|
||||
Session ID: {share.session_id.slice(0, 8)}...
|
||||
</p>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-white/40 mb-4">
|
||||
<span>Created {formatRelativeTime(share.created_at)}</span>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
<span>
|
||||
{share.view_count > 0
|
||||
? `${share.view_count} view${share.view_count === 1 ? '' : 's'}`
|
||||
: 'Not viewed yet'}
|
||||
</span>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
<span className={cn(expiration.isExpired && 'text-red-400')}>
|
||||
{expiration.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleCopyLink(share)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
isCopied
|
||||
? 'bg-emerald-400/10 text-emerald-400'
|
||||
: 'bg-white text-black hover:bg-white/90'
|
||||
)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isCopied ? 'Copied' : 'Copy Link'}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to={`/sessions/${share.session_id}`}
|
||||
className="inline-flex items-center gap-1.5 border border-white/10 text-white/60 hover:bg-white/10 rounded-md px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
View Session
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => handleRevoke(share)}
|
||||
className="inline-flex items-center gap-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-md px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Save, ArrowLeft, ListOrdered } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
||||
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
|
||||
import { StepList } from '@/components/procedural-editor/StepList'
|
||||
import { TagInput } from '@/components/common/TagInput'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function ProceduralEditorPage() {
|
||||
@@ -33,8 +34,6 @@ export function ProceduralEditorPage() {
|
||||
getTreeForSave,
|
||||
} = useProceduralEditorStore()
|
||||
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
// Load tree or init new
|
||||
useEffect(() => {
|
||||
if (isEditMode && id) {
|
||||
@@ -95,18 +94,6 @@ export function ProceduralEditorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTag = () => {
|
||||
const tag = tagInput.trim()
|
||||
if (tag && !tags.includes(tag)) {
|
||||
setTags([...tags, tag])
|
||||
setTagInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setTags(tags.filter((t) => t !== tag))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
@@ -187,34 +174,7 @@ export function ProceduralEditorPage() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-white/60">Tags</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } }}
|
||||
placeholder="Add tag..."
|
||||
className="flex-1 rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<TagInput tags={tags} onChange={setTags} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-end pb-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
@@ -21,6 +21,8 @@ interface StepState {
|
||||
export function ProceduralNavigationPage() {
|
||||
const { id: treeId } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const locationState = location.state as { sessionId?: string } | undefined
|
||||
|
||||
const [tree, setTree] = useState<Tree | null>(null)
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
@@ -98,6 +100,12 @@ export function ProceduralNavigationPage() {
|
||||
}
|
||||
setTree(treeData)
|
||||
|
||||
// If resuming an existing session
|
||||
if (locationState?.sessionId) {
|
||||
await resumeSession(treeData, locationState.sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if intake form exists
|
||||
if (treeData.intake_form && treeData.intake_form.length > 0) {
|
||||
setShowIntakeForm(true)
|
||||
@@ -134,6 +142,42 @@ export function ProceduralNavigationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const resumeSession = async (treeData: Tree, sessionId: string) => {
|
||||
try {
|
||||
const sessionData = await sessionsApi.get(sessionId)
|
||||
setSession(sessionData)
|
||||
setSessionVariables(sessionData.session_variables || {})
|
||||
setShowIntakeForm(false)
|
||||
|
||||
// Initialize step states from session decisions
|
||||
const allSteps = getStepsFromTree(treeData)
|
||||
const initialStates = new Map<string, StepState>()
|
||||
for (const step of allSteps) {
|
||||
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
||||
}
|
||||
|
||||
// Hydrate completed steps from decisions
|
||||
for (const decision of sessionData.decisions || []) {
|
||||
if (decision.answer === 'completed' && initialStates.has(decision.node_id)) {
|
||||
initialStates.set(decision.node_id, {
|
||||
notes: decision.notes || '',
|
||||
verificationValue: decision.command_output || '',
|
||||
completedAt: decision.exited_at || decision.timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
setStepStates(initialStates)
|
||||
|
||||
// Set current step to first incomplete step
|
||||
const pSteps = allSteps.filter((s) => s.type === 'procedure_step')
|
||||
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
|
||||
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
||||
} catch {
|
||||
toast.error('Failed to resume session')
|
||||
navigate('/my-trees')
|
||||
}
|
||||
}
|
||||
|
||||
const getStepsFromTree = (t: Tree): ProceduralStep[] => {
|
||||
const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] }
|
||||
return structure.steps || []
|
||||
|
||||
@@ -5,6 +5,7 @@ import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import type { Session } from '@/types/session'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
@@ -27,7 +28,7 @@ export function QuickStartPage() {
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
const [activeSessions, setActiveSessions] = useState<Session[]>([])
|
||||
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string }[]>([])
|
||||
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string; tree_type?: string }[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -44,7 +45,7 @@ export function QuickStartPage() {
|
||||
|
||||
// Deduplicate recent sessions by tree_id, max 5
|
||||
const seen = new Set<string>()
|
||||
const deduped: { tree_id: string; name: string; lastUsed: string }[] = []
|
||||
const deduped: { tree_id: string; name: string; lastUsed: string; tree_type?: string }[] = []
|
||||
for (const s of recent) {
|
||||
if (!seen.has(s.tree_id) && deduped.length < 5) {
|
||||
seen.add(s.tree_id)
|
||||
@@ -52,6 +53,7 @@ export function QuickStartPage() {
|
||||
tree_id: s.tree_id,
|
||||
name: s.tree_snapshot?.name || 'Unnamed Tree',
|
||||
lastUsed: s.started_at,
|
||||
tree_type: s.tree_snapshot?.tree_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -164,7 +166,7 @@ export function QuickStartPage() {
|
||||
{searchResults.map((tree) => (
|
||||
<li key={tree.id}>
|
||||
<button
|
||||
onClick={() => navigate(`/trees/${tree.id}/navigate`)}
|
||||
onClick={() => navigate(getTreeNavigatePath(tree.id, tree.tree_type))}
|
||||
className="w-full px-5 py-3.5 text-left transition-all hover:bg-white/[0.06]"
|
||||
>
|
||||
<div className="text-sm font-medium text-white">
|
||||
@@ -210,7 +212,7 @@ export function QuickStartPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate(`/trees/${activeSessions[0].tree_id}/navigate`, {
|
||||
navigate(getTreeNavigatePath(activeSessions[0].tree_id, activeSessions[0].tree_snapshot?.tree_type), {
|
||||
state: { sessionId: activeSessions[0].id },
|
||||
})
|
||||
}
|
||||
@@ -234,7 +236,7 @@ export function QuickStartPage() {
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() =>
|
||||
navigate(`/trees/${session.tree_id}/navigate`, {
|
||||
navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), {
|
||||
state: { sessionId: session.id },
|
||||
})
|
||||
}
|
||||
@@ -282,7 +284,7 @@ export function QuickStartPage() {
|
||||
{recentTrees.map((tree) => (
|
||||
<button
|
||||
key={tree.tree_id}
|
||||
onClick={() => navigate(`/trees/${tree.tree_id}/navigate`)}
|
||||
onClick={() => navigate(getTreeNavigatePath(tree.tree_id, tree.tree_type))}
|
||||
className="glass-card hover:glass-card-hover rounded-2xl p-5 text-left transition-all hover:scale-[1.02] cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Copy, Check, Eye, Save } from 'lucide-react'
|
||||
import { Copy, Check, Eye, Save, Share2 } from 'lucide-react'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { stepsApi } from '@/api/steps'
|
||||
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
|
||||
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
|
||||
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
||||
import { SessionTimeline } from '@/components/session/SessionTimeline'
|
||||
import { StepRatingModal } from '@/components/session/StepRatingModal'
|
||||
import { ActionMenu } from '@/components/common/ActionMenu'
|
||||
import type { MenuAction } from '@/components/common/ActionMenu'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
|
||||
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
|
||||
@@ -30,7 +34,7 @@ export function SessionDetailPage() {
|
||||
const [showRatingModal, setShowRatingModal] = useState(false)
|
||||
const [isSavingRatings, setIsSavingRatings] = useState(false)
|
||||
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
|
||||
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
|
||||
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
|
||||
const [includeSummary, setIncludeSummary] = useState(false)
|
||||
@@ -257,26 +261,6 @@ export function SessionDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyStep = async (decision: Session['decisions'][number], index: number) => {
|
||||
const lines: string[] = []
|
||||
if (decision.question) lines.push(`Question: ${decision.question}`)
|
||||
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
|
||||
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
|
||||
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
|
||||
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
|
||||
try {
|
||||
await navigator.clipboard.writeText(lines.join('\n'))
|
||||
setCopiedStepIndex(index)
|
||||
setTimeout(() => setCopiedStepIndex(null), 2000)
|
||||
} catch {
|
||||
// Clipboard access denied
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
const formatDuration = (durationSeconds: number | null | undefined) => {
|
||||
if (durationSeconds == null || durationSeconds < 0) return null
|
||||
if (durationSeconds < 60) return `${durationSeconds}s`
|
||||
@@ -381,20 +365,20 @@ export function SessionDetailPage() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{/* Save as Tree - Only for completed sessions */}
|
||||
{session.completed_at && (
|
||||
<button
|
||||
onClick={() => setShowSaveAsTreeModal(true)}
|
||||
disabled={isSavingTree}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-white/10 bg-transparent px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save as Tree
|
||||
</button>
|
||||
)}
|
||||
<ActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: 'Share',
|
||||
icon: Share2,
|
||||
onClick: () => setShowShareModal(true),
|
||||
},
|
||||
...(session.completed_at ? [{
|
||||
label: 'Save as Tree',
|
||||
icon: Save,
|
||||
onClick: () => setShowSaveAsTreeModal(true),
|
||||
}] as MenuAction[] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Copy for Ticket */}
|
||||
<button
|
||||
@@ -482,151 +466,12 @@ export function SessionDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Timeline / Step Checklist */}
|
||||
<div className="mb-8">
|
||||
{(session.tree_snapshot as unknown as Record<string, unknown>).tree_type === 'procedural' ? (
|
||||
<>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Procedure Steps</h2>
|
||||
<div className="space-y-2">
|
||||
{session.decisions.map((decision, index) => {
|
||||
const isCompleted = decision.answer === 'completed'
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'glass-card rounded-xl p-4',
|
||||
isCompleted && 'border-l-2 border-emerald-400/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn(
|
||||
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
|
||||
isCompleted ? 'bg-emerald-400/10 text-emerald-400' : 'bg-white/10 text-white/50'
|
||||
)}>
|
||||
{isCompleted ? '\u2713' : index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-white">{decision.question || 'Step'}</p>
|
||||
{decision.notes && (
|
||||
<p className="mt-1.5 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
Verification: {decision.command_output}
|
||||
</p>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-1 text-xs text-white/30">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{session.completed_at && (
|
||||
<div className="flex items-center gap-3 pl-2 pt-2 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500" />
|
||||
<span className="text-emerald-400">
|
||||
Procedure completed: {formatDate(session.completed_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Decision Timeline</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-white" />
|
||||
<span className="text-white/40">
|
||||
Session started: {formatDate(session.started_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{session.decisions.map((decision, index) => (
|
||||
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
{decision.question && (
|
||||
<p className="font-medium text-white">{decision.question}</p>
|
||||
)}
|
||||
{decision.answer && (
|
||||
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
||||
)}
|
||||
{decision.action_performed && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
Action: {decision.action_performed}
|
||||
</p>
|
||||
)}
|
||||
{decision.notes && (
|
||||
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<div className="mt-2">
|
||||
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
|
||||
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
|
||||
{decision.command_output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-2 text-xs text-white/50">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
{formatDate(decision.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{session.completed_at && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<span className="text-emerald-400">
|
||||
Session completed: {formatDate(session.completed_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SessionTimeline
|
||||
decisions={session.decisions}
|
||||
treeType={(session.tree_snapshot as unknown as Record<string, unknown>).tree_type as string}
|
||||
startedAt={session.started_at}
|
||||
completedAt={session.completed_at}
|
||||
/>
|
||||
|
||||
{/* Export Preview Modal */}
|
||||
<ExportPreviewModal
|
||||
@@ -660,6 +505,14 @@ export function SessionDetailPage() {
|
||||
librarySteps={librarySteps}
|
||||
isSaving={isSavingRatings}
|
||||
/>
|
||||
|
||||
{/* Share Session Modal */}
|
||||
<ShareSessionModal
|
||||
sessionId={session.id}
|
||||
sessionLabel={session.ticket_number || 'Session Details'}
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SessionFilters } from '@/components/session/SessionFilters'
|
||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
|
||||
export function SessionHistoryPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -284,7 +285,7 @@ export function SessionHistoryPage() {
|
||||
</button>
|
||||
{!session.completed_at && (
|
||||
<button
|
||||
onClick={() => navigate(`/trees/${session.tree_id}/navigate`, { state: { sessionId: session.id } })}
|
||||
onClick={() => navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90'
|
||||
|
||||
263
frontend/src/pages/SharedSessionPage.tsx
Normal file
263
frontend/src/pages/SharedSessionPage.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { Globe, Users, ShieldAlert, FileX, Clock, Loader2 } from 'lucide-react'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { SessionTimeline } from '@/components/session/SessionTimeline'
|
||||
import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview'
|
||||
import type { SharedSessionView } from '@/types'
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: string, completedAt: string): string {
|
||||
const start = new Date(startedAt).getTime()
|
||||
const end = new Date(completedAt).getTime()
|
||||
const totalSeconds = Math.floor((end - start) / 1000)
|
||||
if (totalSeconds < 0) return '0s'
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (hours > 0) {
|
||||
return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
|
||||
}
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
|
||||
}
|
||||
|
||||
type ErrorState = {
|
||||
type: 'access_denied' | 'not_found' | 'expired' | 'generic'
|
||||
message: string
|
||||
}
|
||||
|
||||
function ErrorCard({ error }: { error: ErrorState }) {
|
||||
const iconMap = {
|
||||
access_denied: ShieldAlert,
|
||||
not_found: FileX,
|
||||
expired: Clock,
|
||||
generic: FileX,
|
||||
}
|
||||
const titleMap = {
|
||||
access_denied: 'Access Denied',
|
||||
not_found: 'Not Found',
|
||||
expired: 'Link Expired',
|
||||
generic: 'Error',
|
||||
}
|
||||
|
||||
const Icon = iconMap[error.type]
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||
<div className="glass-card w-full max-w-md rounded-2xl p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white/5">
|
||||
<Icon className="h-8 w-8 text-white/40" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-xl font-semibold text-white">{titleMap[error.type]}</h1>
|
||||
<p className="mb-6 text-sm text-white/50">{error.message}</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-block rounded-lg bg-white px-6 py-2.5 text-sm font-medium text-black hover:bg-white/90"
|
||||
>
|
||||
Go to ResolutionFlow
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SharedSessionPage() {
|
||||
const { shareToken } = useParams<{ shareToken: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<SharedSessionView | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<ErrorState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shareToken) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function fetchSharedSession() {
|
||||
try {
|
||||
const result = await sessionsApi.getSharedSession(shareToken!)
|
||||
if (!cancelled) {
|
||||
setData(result)
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
|
||||
if (isAxiosError(err)) {
|
||||
const status = err.response?.status
|
||||
if (status === 401) {
|
||||
navigate('/login', {
|
||||
state: { from: { pathname: `/share/${shareToken}` } },
|
||||
replace: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (status === 403) {
|
||||
setError({
|
||||
type: 'access_denied',
|
||||
message:
|
||||
'This session is private to the account. You need to be a member of the account to view it.',
|
||||
})
|
||||
} else if (status === 404) {
|
||||
setError({
|
||||
type: 'not_found',
|
||||
message: 'This share link was not found or has been revoked.',
|
||||
})
|
||||
} else if (status === 410) {
|
||||
setError({
|
||||
type: 'expired',
|
||||
message: 'This share link has expired.',
|
||||
})
|
||||
} else {
|
||||
setError({
|
||||
type: 'generic',
|
||||
message: 'Failed to load shared session. Please try again.',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setError({
|
||||
type: 'generic',
|
||||
message: 'Failed to load shared session. Please try again.',
|
||||
})
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSharedSession()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [shareToken, navigate])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-white/40" />
|
||||
<p className="text-sm text-white/40">Loading shared session...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorCard error={error} />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
{/* Minimal header */}
|
||||
<header className="border-b border-white/[0.06] px-6 py-4">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<BrandLogo size="sm" />
|
||||
<span className="text-lg font-semibold text-white">ResolutionFlow</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="container mx-auto max-w-7xl px-4 py-8">
|
||||
{/* Metadata section */}
|
||||
<div className="mb-8">
|
||||
{data.share_name && (
|
||||
<h1 className="mb-2 text-2xl font-bold text-white">{data.share_name}</h1>
|
||||
)}
|
||||
<p className="text-lg text-white/70">
|
||||
<span className="text-white/40">Tree:</span> {data.tree_name}
|
||||
</p>
|
||||
|
||||
{(data.ticket_number || data.client_name) && (
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
{data.ticket_number && (
|
||||
<span>Ticket: #{data.ticket_number}</span>
|
||||
)}
|
||||
{data.ticket_number && data.client_name && (
|
||||
<span className="mx-1.5">·</span>
|
||||
)}
|
||||
{data.client_name && (
|
||||
<span>Client: {data.client_name}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-white/40">
|
||||
<span>Started: {formatDate(data.started_at)}</span>
|
||||
{data.completed_at && (
|
||||
<>
|
||||
<span>Completed: {formatDate(data.completed_at)}</span>
|
||||
<span>Duration: {formatDuration(data.started_at, data.completed_at)}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{data.visibility === 'public' ? (
|
||||
<>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
Public
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Account
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Decision Timeline (2 cols) */}
|
||||
<div className="lg:col-span-2">
|
||||
<SessionTimeline
|
||||
decisions={data.decisions}
|
||||
treeType={data.tree_structure?.tree_type as string | undefined}
|
||||
startedAt={data.started_at}
|
||||
completedAt={data.completed_at}
|
||||
showCopyButtons={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tree Preview (1 col) */}
|
||||
<div className="lg:col-span-1">
|
||||
<SharedSessionTreePreview
|
||||
treeStructure={data.tree_structure}
|
||||
pathTaken={data.path_taken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-8 text-center text-sm text-white/30">
|
||||
Powered by{' '}
|
||||
<Link to="/" className="underline hover:text-white/50">
|
||||
ResolutionFlow
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SharedSessionPage
|
||||
@@ -15,6 +15,7 @@ import { TreeTableView } from '@/components/library/TreeTableView'
|
||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||
import { SortDropdown } from '@/components/library/SortDropdown'
|
||||
import { cn, safeGetItem } from '@/lib/utils'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -450,7 +451,7 @@ export function TreeLibraryPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/trees/${s.tree_id}/navigate`, { state: { sessionId: s.id } })}
|
||||
onClick={() => navigate(getTreeNavigatePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
|
||||
className="flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black hover:bg-white/90"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
@@ -10,9 +10,11 @@ import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
|
||||
import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle } from 'lucide-react'
|
||||
import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle, Link2, ChevronDown, Settings } from 'lucide-react'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
||||
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
||||
|
||||
interface LocationState {
|
||||
sessionId?: string
|
||||
@@ -48,6 +50,11 @@ export function TreeNavigationPage() {
|
||||
const [selectingOption, setSelectingOption] = useState<string | null>(null)
|
||||
const [copiedForTicket, setCopiedForTicket] = useState(false)
|
||||
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
|
||||
const [showSharePopover, setShowSharePopover] = useState(false)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
||||
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
||||
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleCopyCommand = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
@@ -78,6 +85,55 @@ export function TreeNavigationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyShareLink = async () => {
|
||||
if (!session || isCopyingShareLink) return
|
||||
setIsCopyingShareLink(true)
|
||||
try {
|
||||
const allShares = await sessionsApi.listMyShares()
|
||||
const existingShare = getLatestActiveShareForSession(allShares, session.id)
|
||||
let shareUrl: string
|
||||
if (existingShare) {
|
||||
shareUrl = buildSessionShareUrl(existingShare)
|
||||
} else {
|
||||
const newShare = await sessionsApi.createShare(session.id, { visibility: 'account' })
|
||||
shareUrl = buildSessionShareUrl(newShare)
|
||||
}
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
setCopiedShareLink(true)
|
||||
toast.success('Share link copied to clipboard')
|
||||
setTimeout(() => setCopiedShareLink(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Copy share link failed:', err)
|
||||
toast.error('Failed to copy share link')
|
||||
} finally {
|
||||
setIsCopyingShareLink(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Close share popover on outside click
|
||||
useEffect(() => {
|
||||
if (!showSharePopover) return
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (sharePopoverRef.current && !sharePopoverRef.current.contains(e.target as Node)) {
|
||||
setShowSharePopover(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown)
|
||||
}, [showSharePopover])
|
||||
|
||||
// Close share popover on Escape key
|
||||
useEffect(() => {
|
||||
if (!showSharePopover) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowSharePopover(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [showSharePopover])
|
||||
|
||||
// Session metadata (prefill from Repeat Last Session)
|
||||
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
||||
const [clientName, setClientName] = useState<string>(locationState?.prefillClientName || '')
|
||||
@@ -205,6 +261,16 @@ export function TreeNavigationPage() {
|
||||
setError(null)
|
||||
try {
|
||||
const treeData = await treesApi.get(treeId!)
|
||||
|
||||
// Safety redirect: procedural trees should use the procedural navigator
|
||||
if (treeData.tree_type === 'procedural') {
|
||||
navigate(`/flows/${treeId}/navigate`, {
|
||||
replace: true,
|
||||
state: locationState,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setTree(treeData)
|
||||
|
||||
// If resuming a session
|
||||
@@ -576,18 +642,70 @@ export function TreeNavigationPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopyForTicket}
|
||||
disabled={isCopyingForTicket}
|
||||
title="Copy progress notes for ticket"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white transition-colors disabled:opacity-50'
|
||||
{/* Share Progress Popover */}
|
||||
<div className="relative" ref={sharePopoverRef}>
|
||||
<button
|
||||
onClick={() => setShowSharePopover(!showSharePopover)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white transition-colors'
|
||||
)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Share Progress
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{showSharePopover && (
|
||||
<div className="absolute right-0 mt-1 z-50 glass-card rounded-lg p-1 min-w-[220px]">
|
||||
{/* Copy Progress Summary */}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCopyForTicket()
|
||||
setShowSharePopover(false)
|
||||
}}
|
||||
disabled={isCopyingForTicket}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 w-full text-left cursor-pointer transition-colors',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{copiedForTicket ? <Check className="h-4 w-4 text-emerald-400" /> : <Clipboard className="h-4 w-4" />}
|
||||
{copiedForTicket ? 'Copied!' : 'Copy Progress Summary'}
|
||||
</button>
|
||||
{/* Copy Share Link */}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCopyShareLink()
|
||||
setShowSharePopover(false)
|
||||
}}
|
||||
disabled={isCopyingShareLink}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 w-full text-left cursor-pointer transition-colors',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{copiedShareLink ? <Check className="h-4 w-4 text-emerald-400" /> : <Link2 className="h-4 w-4" />}
|
||||
{isCopyingShareLink ? 'Loading...' : copiedShareLink ? 'Copied!' : 'Copy Share Link'}
|
||||
</button>
|
||||
{/* Divider */}
|
||||
<div className="border-t border-white/[0.06] my-1" />
|
||||
{/* Manage Share Links */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSharePopover(false)
|
||||
setShowShareModal(true)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 w-full text-left cursor-pointer transition-colors',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Manage Share Links...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{copiedForTicket ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copiedForTicket ? 'Copied!' : 'Copy for Ticket'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-white/50 hover:bg-white/[0.06] hover:text-white"
|
||||
@@ -1092,6 +1210,16 @@ export function TreeNavigationPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Share Session Modal */}
|
||||
{session && (
|
||||
<ShareSessionModal
|
||||
sessionId={session.id}
|
||||
sessionLabel={ticketNumber || tree?.name || 'Session'}
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
RegisterPage,
|
||||
} from '@/pages'
|
||||
|
||||
// Public pages
|
||||
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
|
||||
|
||||
// Standalone auth pages
|
||||
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
|
||||
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
|
||||
@@ -23,6 +26,7 @@ const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage'))
|
||||
const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage'))
|
||||
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
|
||||
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
|
||||
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
|
||||
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
||||
// Admin pages
|
||||
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
||||
@@ -69,6 +73,15 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/share/:shareToken',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<SharedSessionPage />
|
||||
</Suspense>
|
||||
),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/change-password',
|
||||
element: (
|
||||
@@ -177,6 +190,14 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'shares',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<MySharesPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
// Admin routes
|
||||
{
|
||||
path: 'admin',
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { create } from 'zustand'
|
||||
import { temporal } from 'zundo'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import type { Tree, IntakeFormField, ProceduralStep, ProceduralTreeStructure, TreeType } from '@/types'
|
||||
import type { Tree, IntakeFormField, IntakeFieldType, ProceduralStep, ProceduralTreeStructure, TreeType } from '@/types'
|
||||
|
||||
const generateId = () => crypto.randomUUID()
|
||||
|
||||
const FIELD_TYPE_PREFIX: Record<IntakeFieldType, string> = {
|
||||
text: 'text',
|
||||
textarea: 'textarea',
|
||||
number: 'number',
|
||||
ip_address: 'ip',
|
||||
email: 'email',
|
||||
url: 'url',
|
||||
select: 'select',
|
||||
multi_select: 'multiselect',
|
||||
checkbox: 'checkbox',
|
||||
password: 'password',
|
||||
}
|
||||
|
||||
function generateVariableName(fieldType: IntakeFieldType, existingFields: IntakeFormField[]): string {
|
||||
const prefix = FIELD_TYPE_PREFIX[fieldType] || 'field'
|
||||
const count = existingFields.filter((f) => f.field_type === fieldType).length
|
||||
return `${prefix}_${count + 1}`
|
||||
}
|
||||
|
||||
function createDefaultStep(index: number): ProceduralStep {
|
||||
return {
|
||||
id: generateId(),
|
||||
@@ -24,9 +43,17 @@ function createEndStep(): ProceduralStep {
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultField(index: number): IntakeFormField {
|
||||
function createSectionHeader(title: string): ProceduralStep {
|
||||
return {
|
||||
variable_name: `field_${index + 1}`,
|
||||
id: generateId(),
|
||||
type: 'section_header',
|
||||
title,
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultField(index: number, existingFields: IntakeFormField[]): IntakeFormField {
|
||||
return {
|
||||
variable_name: generateVariableName('text', existingFields),
|
||||
label: `Field ${index + 1}`,
|
||||
field_type: 'text',
|
||||
required: true,
|
||||
@@ -70,6 +97,7 @@ interface ProceduralEditorState {
|
||||
|
||||
// Actions - Steps
|
||||
addStep: (afterIndex?: number) => void
|
||||
addSectionHeader: (afterIndex?: number) => void
|
||||
removeStep: (stepId: string) => void
|
||||
updateStep: (stepId: string, updates: Partial<ProceduralStep>) => void
|
||||
moveStep: (fromIndex: number, toIndex: number) => void
|
||||
@@ -78,8 +106,8 @@ interface ProceduralEditorState {
|
||||
|
||||
// Actions - Intake Form
|
||||
addField: () => void
|
||||
removeField: (variableName: string) => void
|
||||
updateField: (variableName: string, updates: Partial<IntakeFormField>) => void
|
||||
removeField: (index: number) => void
|
||||
updateField: (index: number, updates: Partial<IntakeFormField>) => void
|
||||
moveField: (fromIndex: number, toIndex: number) => void
|
||||
|
||||
// Actions - Save
|
||||
@@ -200,15 +228,31 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
|
||||
})
|
||||
},
|
||||
|
||||
addSectionHeader: (afterIndex) => {
|
||||
set((state) => {
|
||||
const endIndex = state.steps.findIndex((s) => s.type === 'procedure_end')
|
||||
const insertAt = afterIndex !== undefined
|
||||
? Math.min(afterIndex + 1, endIndex >= 0 ? endIndex : state.steps.length)
|
||||
: (endIndex >= 0 ? endIndex : state.steps.length)
|
||||
|
||||
const newHeader = createSectionHeader('New Section')
|
||||
state.steps.splice(insertAt, 0, newHeader)
|
||||
state.expandedStepId = newHeader.id
|
||||
state.isDirty = true
|
||||
})
|
||||
},
|
||||
|
||||
removeStep: (stepId) => {
|
||||
set((state) => {
|
||||
const index = state.steps.findIndex((s) => s.id === stepId)
|
||||
if (index === -1) return
|
||||
// Don't remove the end step
|
||||
if (state.steps[index].type === 'procedure_end') return
|
||||
// Don't remove if it's the only procedure_step
|
||||
const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length
|
||||
if (stepCount <= 1) return
|
||||
// Don't remove if it's the only procedure_step (section headers can always be removed)
|
||||
if (state.steps[index].type === 'procedure_step') {
|
||||
const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length
|
||||
if (stepCount <= 1) return
|
||||
}
|
||||
|
||||
state.steps.splice(index, 1)
|
||||
if (state.selectedStepId === stepId) state.selectedStepId = null
|
||||
@@ -249,28 +293,31 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
|
||||
// Intake Form
|
||||
addField: () => {
|
||||
set((state) => {
|
||||
const newField = createDefaultField(state.intakeForm.length)
|
||||
const newField = createDefaultField(state.intakeForm.length, state.intakeForm)
|
||||
state.intakeForm.push(newField)
|
||||
state.isDirty = true
|
||||
})
|
||||
},
|
||||
|
||||
removeField: (variableName) => {
|
||||
removeField: (index) => {
|
||||
set((state) => {
|
||||
const index = state.intakeForm.findIndex((f) => f.variable_name === variableName)
|
||||
if (index !== -1) {
|
||||
state.intakeForm.splice(index, 1)
|
||||
// Reorder display_order
|
||||
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
|
||||
state.isDirty = true
|
||||
}
|
||||
if (index < 0 || index >= state.intakeForm.length) return
|
||||
state.intakeForm.splice(index, 1)
|
||||
// Reorder display_order
|
||||
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
|
||||
state.isDirty = true
|
||||
})
|
||||
},
|
||||
|
||||
updateField: (variableName, updates) => {
|
||||
updateField: (index, updates) => {
|
||||
set((state) => {
|
||||
const field = state.intakeForm.find((f) => f.variable_name === variableName)
|
||||
const field = state.intakeForm[index]
|
||||
if (field) {
|
||||
// If field_type changed, auto-generate a new variable name
|
||||
if (updates.field_type && updates.field_type !== field.field_type) {
|
||||
const otherFields = state.intakeForm.filter((_, i) => i !== index)
|
||||
updates.variable_name = generateVariableName(updates.field_type, otherFields)
|
||||
}
|
||||
Object.assign(field, updates)
|
||||
state.isDirty = true
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface TreeSnapshot extends TreeStructure {
|
||||
description?: string
|
||||
category?: string
|
||||
version?: number
|
||||
tree_type?: string
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
@@ -58,6 +59,7 @@ export interface Session {
|
||||
exported: boolean
|
||||
scratchpad: string
|
||||
next_steps: string
|
||||
session_variables: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SessionCreate {
|
||||
@@ -125,3 +127,45 @@ export interface SaveAsTreeResponse {
|
||||
tree_name: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// Session Sharing
|
||||
export type SessionShareVisibility = 'public' | 'account'
|
||||
|
||||
export interface SessionShareCreate {
|
||||
visibility: SessionShareVisibility
|
||||
share_name?: string
|
||||
expires_at?: string // ISO datetime string
|
||||
}
|
||||
|
||||
export interface SessionShare {
|
||||
id: string
|
||||
session_id: string
|
||||
account_id: string
|
||||
share_token: string
|
||||
share_name: string | null
|
||||
visibility: SessionShareVisibility
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
expires_at: string | null
|
||||
view_count: number
|
||||
last_viewed_at: string | null
|
||||
is_active: boolean
|
||||
share_url: string | null
|
||||
}
|
||||
|
||||
export interface SharedSessionView {
|
||||
session_id: string
|
||||
tree_name: string
|
||||
tree_description: string | null
|
||||
tree_structure: TreeStructure & Record<string, unknown>
|
||||
path_taken: string[]
|
||||
decisions: DecisionRecord[]
|
||||
custom_steps: CustomStep[]
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
ticket_number: string | null
|
||||
client_name: string | null
|
||||
share_name: string | null
|
||||
visibility: SessionShareVisibility
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface TreeStructure {
|
||||
export type TreeType = 'troubleshooting' | 'procedural'
|
||||
|
||||
export type IntakeFieldType =
|
||||
| 'text' | 'textarea' | 'number' | 'ip_address' | 'email'
|
||||
| 'text' | 'textarea' | 'number' | 'ip_address' | 'email' | 'url'
|
||||
| 'select' | 'multi_select' | 'checkbox' | 'password'
|
||||
|
||||
export type StepContentType = 'action' | 'informational' | 'verification' | 'warning'
|
||||
@@ -105,7 +105,7 @@ export interface StepVerification {
|
||||
|
||||
export interface ProceduralStep {
|
||||
id: string
|
||||
type: 'procedure_step' | 'procedure_end'
|
||||
type: 'procedure_step' | 'procedure_end' | 'section_header'
|
||||
title: string
|
||||
description?: string
|
||||
content_type?: StepContentType
|
||||
|
||||
Reference in New Issue
Block a user