feat: session sharing frontend (#76)
* feat: add session sharing types, API client, and utilities Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SessionTimeline and ActionMenu reusable components SessionTimeline extracts timeline/checklist rendering from SessionDetailPage into a reusable component for both authenticated and public session views. ActionMenu provides a dropdown action menu with keyboard/click-outside dismiss. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add ShareSessionModal and integrate into SessionDetailPage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Share Progress popover to TreeNavigationPage Replace the single "Copy for Ticket" button with a "Share Progress" popover that offers three actions: Copy Progress Summary (existing PSA export flow), Copy Share Link (auto-creates account-only share if needed), and Manage Share Links (opens ShareSessionModal). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add public SharedSessionPage with tree preview Add the public-facing shared session page at /share/:shareToken that renders shared sessions without authentication. Includes error handling for 401 (redirect to login), 403 (access denied), 404 (not found), and 410 (expired). The page features a minimal header, session metadata, SessionTimeline component, and a new SharedSessionTreePreview component that renders the decision tree structure with the path taken highlighted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add My Shares management page with nav link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review issues in session sharing - Add useCallback for loadShares in ShareSessionModal (React hook deps) - Use TreeStructure type instead of Record<string, unknown> for type safety - Fix login redirect format to match LoginPage's expected state shape Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add focused tests for session sharing utilities and API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve tree_structure type compatibility for shared session views - Use TreeStructure & Record<string, unknown> intersection for JSONB flexibility - Add explicit cast in SharedSessionTreePreview for recursive node rendering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add session sharing learnings to CLAUDE.md Add gotchas #12 (TreeStructure vs Tree types) and #13 (login redirect state format), note about npm run build strictness, and public route pattern to Common Tasks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: procedural editor UX improvements Add URL intake field type, fix variable name editing collapsing fields (index-based keys/updates), auto-generate variable names by field type, add section header as first-class step type, and simplify step editor with "More Options" collapsible for advanced fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: allow section_header step type in validation, improve tag input - Add 'section_header' to VALID_STEP_TYPES in backend validation so procedural flows with section headers can be published - Replace procedural editor's inline tag input with TagInput component (supports autocomplete, Tab, comma, semicolon, and paste splitting) - Add semicolon delimiter support to TagInput component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add type-aware routing for procedural flows Centralizes tree navigation routing via getTreeNavigatePath helper. Fixes all pages to route procedural sessions to /flows/:id/navigate instead of /trees/:id/navigate. Adds safety redirect in troubleshooting navigator and resume support in procedural navigator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unused index prop from IntakeFieldEditor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #76.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user