From 57f429f33b5f3d8511cc3520e3f6521a5ac17ee2 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 23:08:17 -0500 Subject: [PATCH] feat: session sharing frontend (#76) * feat: add session sharing types, API client, and utilities Co-Authored-By: Claude Opus 4.6 * feat: add SessionTimeline and ActionMenu reusable components SessionTimeline extracts timeline/checklist rendering from SessionDetailPage into a reusable component for both authenticated and public session views. ActionMenu provides a dropdown action menu with keyboard/click-outside dismiss. Co-Authored-By: Claude Opus 4.6 * feat: add ShareSessionModal and integrate into SessionDetailPage Co-Authored-By: Claude Opus 4.6 * feat: add Share Progress popover to TreeNavigationPage Replace the single "Copy for Ticket" button with a "Share Progress" popover that offers three actions: Copy Progress Summary (existing PSA export flow), Copy Share Link (auto-creates account-only share if needed), and Manage Share Links (opens ShareSessionModal). Co-Authored-By: Claude Opus 4.6 * feat: add public SharedSessionPage with tree preview Add the public-facing shared session page at /share/:shareToken that renders shared sessions without authentication. Includes error handling for 401 (redirect to login), 403 (access denied), 404 (not found), and 410 (expired). The page features a minimal header, session metadata, SessionTimeline component, and a new SharedSessionTreePreview component that renders the decision tree structure with the path taken highlighted. Co-Authored-By: Claude Opus 4.6 * feat: add My Shares management page with nav link Co-Authored-By: Claude Opus 4.6 * fix: address code review issues in session sharing - Add useCallback for loadShares in ShareSessionModal (React hook deps) - Use TreeStructure type instead of Record for type safety - Fix login redirect format to match LoginPage's expected state shape Co-Authored-By: Claude Opus 4.6 * test: add focused tests for session sharing utilities and API Co-Authored-By: Claude Opus 4.6 * fix: resolve tree_structure type compatibility for shared session views - Use TreeStructure & Record intersection for JSONB flexibility - Add explicit cast in SharedSessionTreePreview for recursive node rendering Co-Authored-By: Claude Opus 4.6 * docs: add session sharing learnings to CLAUDE.md Add gotchas #12 (TreeStructure vs Tree types) and #13 (login redirect state format), note about npm run build strictness, and public route pattern to Common Tasks. Co-Authored-By: Claude Opus 4.6 * feat: procedural editor UX improvements Add URL intake field type, fix variable name editing collapsing fields (index-based keys/updates), auto-generate variable names by field type, add section header as first-class step type, and simplify step editor with "More Options" collapsible for advanced fields. Co-Authored-By: Claude Opus 4.6 * fix: allow section_header step type in validation, improve tag input - Add 'section_header' to VALID_STEP_TYPES in backend validation so procedural flows with section headers can be published - Replace procedural editor's inline tag input with TagInput component (supports autocomplete, Tab, comma, semicolon, and paste splitting) - Add semicolon delimiter support to TagInput component Co-Authored-By: Claude Opus 4.6 * fix: add type-aware routing for procedural flows Centralizes tree navigation routing via getTreeNavigatePath helper. Fixes all pages to route procedural sessions to /flows/:id/navigate instead of /trees/:id/navigate. Adds safety redirect in troubleshooting navigator and resume support in procedural navigator. Co-Authored-By: Claude Opus 4.6 * fix: remove unused index prop from IntakeFieldEditor Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CLAUDE.md | 7 +- backend/app/core/tree_validation.py | 2 +- backend/app/schemas/tree.py | 2 +- frontend/src/api/sessions.test.ts | 72 +++ frontend/src/api/sessions.ts | 22 +- frontend/src/components/common/ActionMenu.tsx | 100 ++++ frontend/src/components/common/TagInput.tsx | 25 +- frontend/src/components/layout/AppLayout.tsx | 1 + .../procedural-editor/IntakeFieldEditor.tsx | 1 + .../procedural-editor/IntakeFormBuilder.tsx | 6 +- .../procedural-editor/StepEditor.tsx | 283 ++++++------ .../components/procedural-editor/StepList.tsx | 84 +++- .../components/procedural/IntakeFormModal.tsx | 12 + .../components/session/SessionTimeline.tsx | 207 +++++++++ .../components/session/ShareSessionModal.tsx | 431 ++++++++++++++++++ .../session/SharedSessionTreePreview.tsx | 89 ++++ frontend/src/lib/routing.ts | 31 ++ frontend/src/lib/sessionShare.test.ts | 85 ++++ frontend/src/lib/sessionShare.ts | 27 ++ frontend/src/pages/MySharesPage.tsx | 240 ++++++++++ frontend/src/pages/ProceduralEditorPage.tsx | 46 +- .../src/pages/ProceduralNavigationPage.tsx | 46 +- frontend/src/pages/QuickStartPage.tsx | 14 +- frontend/src/pages/SessionDetailPage.tsx | 215 ++------- frontend/src/pages/SessionHistoryPage.tsx | 3 +- frontend/src/pages/SharedSessionPage.tsx | 263 +++++++++++ frontend/src/pages/TreeLibraryPage.tsx | 3 +- frontend/src/pages/TreeNavigationPage.tsx | 154 ++++++- frontend/src/router.tsx | 21 + frontend/src/store/proceduralEditorStore.ts | 85 +++- frontend/src/types/session.ts | 44 ++ frontend/src/types/tree.ts | 4 +- 32 files changed, 2199 insertions(+), 426 deletions(-) create mode 100644 frontend/src/api/sessions.test.ts create mode 100644 frontend/src/components/common/ActionMenu.tsx create mode 100644 frontend/src/components/session/SessionTimeline.tsx create mode 100644 frontend/src/components/session/ShareSessionModal.tsx create mode 100644 frontend/src/components/session/SharedSessionTreePreview.tsx create mode 100644 frontend/src/lib/routing.ts create mode 100644 frontend/src/lib/sessionShare.test.ts create mode 100644 frontend/src/lib/sessionShare.ts create mode 100644 frontend/src/pages/MySharesPage.tsx create mode 100644 frontend/src/pages/SharedSessionPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index fadde848..e8483730 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 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` diff --git a/backend/app/core/tree_validation.py b/backend/app/core/tree_validation.py index 47afed51..8d079a1f 100644 --- a/backend/app/core/tree_validation.py +++ b/backend/app/core/tree_validation.py @@ -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"} diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index 11e6b746..0da6c79b 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -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' ] diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts new file mode 100644 index 00000000..b1cb0e12 --- /dev/null +++ b/frontend/src/api/sessions.test.ts @@ -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 + post: ReturnType + delete: ReturnType +} + +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) + }) +}) diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 43691ef1..635d593a 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -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(`/sessions/${id}/save-as-tree`, data) return response.data }, + + // Session Sharing + async createShare(sessionId: string, data: SessionShareCreate): Promise { + const response = await apiClient.post(`/sessions/${sessionId}/shares`, data) + return response.data + }, + + async listMyShares(): Promise { + const response = await apiClient.get('/shares/my-shares') + return response.data + }, + + async revokeShare(shareId: string): Promise { + await apiClient.delete(`/shares/${shareId}`) + }, + + async getSharedSession(shareToken: string): Promise { + const response = await apiClient.get(`/share/${shareToken}`) + return response.data + }, } export default sessionsApi diff --git a/frontend/src/components/common/ActionMenu.tsx b/frontend/src/components/common/ActionMenu.tsx new file mode 100644 index 00000000..f7f5a20d --- /dev/null +++ b/frontend/src/components/common/ActionMenu.tsx @@ -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(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 ( +
+ + + {isOpen && ( +
+ {actions.map((action, index) => { + const Icon = action.icon + return ( + + ) + })} +
+ )} +
+ ) +} + +export type { MenuAction, ActionMenuProps } + +export default ActionMenu diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx index c6c0e805..76f21b59 100644 --- a/frontend/src/components/common/TagInput.tsx +++ b/frontend/src/components/common/TagInput.tsx @@ -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 */}

- {tags.length}/{maxTags} tags. Press Enter or comma to add. + {tags.length}/{maxTags} tags. Press Enter, Tab, comma, or semicolon to add.

) diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 5240506c..8ee7639f 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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' }] : []), ] diff --git a/frontend/src/components/procedural-editor/IntakeFieldEditor.tsx b/frontend/src/components/procedural-editor/IntakeFieldEditor.tsx index 0a9c94a6..2082000f 100644 --- a/frontend/src/components/procedural-editor/IntakeFieldEditor.tsx +++ b/frontend/src/components/procedural-editor/IntakeFieldEditor.tsx @@ -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' }, diff --git a/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx b/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx index 0e337179..c0d8700a 100644 --- a/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx +++ b/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx @@ -36,10 +36,10 @@ export function IntakeFormBuilder() {
{intakeForm.map((field, index) => ( updateField(field.variable_name, updates)} - onRemove={() => removeField(field.variable_name)} + onUpdate={(updates) => updateField(index, updates)} + onRemove={() => removeField(index)} /> ))}
diff --git a/frontend/src/components/procedural-editor/StepEditor.tsx b/frontend/src/components/procedural-editor/StepEditor.tsx index 2d958944..c5d80db0 100644 --- a/frontend/src/components/procedural-editor/StepEditor.tsx +++ b/frontend/src/components/procedural-editor/StepEditor.tsx @@ -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 ( +
+
+ Edit Section Header + +
+
+ + 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" + /> +
+
+ ) + } + return (
{/* Header */} @@ -48,55 +78,18 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa />
- {/* Content type + Section header row */} -
-
- -
- {CONTENT_TYPE_OPTIONS.map((opt) => ( - - ))} -
-
- -
- - 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" - /> -
-
- - {/* Section Header */} -
+ {/* Est. Minutes */} +
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" />
@@ -127,23 +120,6 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa )}
- {/* Warning text */} - {(step.content_type === 'warning' || step.warning_text) && ( -
- -