fix: surface errors and polish UX across Step Library and Batch pages
- StepLibraryPage: replace silent console.error with toast.error on edit/save failures; replace manual setTimeout toast with toast.success helper - BatchStatusPage: add error state with retry button for failed session loads instead of showing ambiguous "No sessions found" - StepDetailModal: guard clipboard copy against browser denial (no false "Copied!" state on permission error); fix dead "See all reviews" button with inline expand/collapse toggle - StepLibraryBrowser: add "Try again" retry button to error state; retry increments a counter that re-triggers both load effects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
|||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [copiedCommandIndex, setCopiedCommandIndex] = useState<number | null>(null)
|
const [copiedCommandIndex, setCopiedCommandIndex] = useState<number | null>(null)
|
||||||
|
const [showAllReviews, setShowAllReviews] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadStepDetails = async () => {
|
const loadStepDetails = async () => {
|
||||||
@@ -53,9 +54,13 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
|||||||
}, [stepId])
|
}, [stepId])
|
||||||
|
|
||||||
const handleCopyCommand = async (command: string, index: number) => {
|
const handleCopyCommand = async (command: string, index: number) => {
|
||||||
await navigator.clipboard.writeText(command)
|
try {
|
||||||
setCopiedCommandIndex(index)
|
await navigator.clipboard.writeText(command)
|
||||||
setTimeout(() => setCopiedCommandIndex(null), 2000)
|
setCopiedCommandIndex(index)
|
||||||
|
setTimeout(() => setCopiedCommandIndex(null), 2000)
|
||||||
|
} catch {
|
||||||
|
// Clipboard access denied — don't show false "Copied!" state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInsert = () => {
|
const handleInsert = () => {
|
||||||
@@ -66,7 +71,8 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
|||||||
|
|
||||||
const Icon = step ? stepTypeIcons[step.step_type as keyof typeof stepTypeIcons] : HelpCircle
|
const Icon = step ? stepTypeIcons[step.step_type as keyof typeof stepTypeIcons] : HelpCircle
|
||||||
const hasRating = step && step.rating_count > 0
|
const hasRating = step && step.rating_count > 0
|
||||||
const topReviews = reviews.filter(r => r.review_text).slice(0, 3)
|
const allTextReviews = reviews.filter(r => r.review_text)
|
||||||
|
const visibleReviews = showAllReviews ? allTextReviews : allTextReviews.slice(0, 3)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||||
@@ -228,18 +234,21 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reviews */}
|
{/* Reviews */}
|
||||||
{topReviews.length > 0 && (
|
{visibleReviews.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Reviews</h3>
|
<h3 className="text-sm font-semibold text-foreground">Reviews</h3>
|
||||||
{reviews.length > 3 && (
|
{allTextReviews.length > 3 && (
|
||||||
<button className="text-xs text-muted-foreground hover:text-foreground hover:underline">
|
<button
|
||||||
See all {reviews.length} reviews
|
onClick={() => setShowAllReviews(v => !v)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{showAllReviews ? 'Show less' : `See all ${allTextReviews.length} reviews`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{topReviews.map(review => (
|
{visibleReviews.map(review => (
|
||||||
<div key={review.id} className="rounded-lg border border-border bg-accent/50 p-3">
|
<div key={review.id} className="rounded-lg border border-border bg-accent/50 p-3">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
|||||||
// UI state
|
// UI state
|
||||||
const [previewStepId, setPreviewStepId] = useState<string | null>(null)
|
const [previewStepId, setPreviewStepId] = useState<string | null>(null)
|
||||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({})
|
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({})
|
||||||
|
const [retryCount, setRetryCount] = useState(0)
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,7 +60,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadInitialData()
|
loadInitialData()
|
||||||
}, [])
|
}, [retryCount])
|
||||||
|
|
||||||
// Load steps when filters change
|
// Load steps when filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,7 +93,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadSteps()
|
loadSteps()
|
||||||
}, [searchQuery, selectedCategoryId, selectedStepType, minRating, sortBy, selectedTag, refreshKey])
|
}, [searchQuery, selectedCategoryId, selectedStepType, minRating, sortBy, selectedTag, refreshKey, retryCount])
|
||||||
|
|
||||||
// Group steps by visibility
|
// Group steps by visibility
|
||||||
const groupedSteps = useMemo(() => {
|
const groupedSteps = useMemo(() => {
|
||||||
@@ -250,8 +251,14 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
|||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="rounded-lg border border-red-400/20 bg-red-400/10 p-4 text-center text-sm text-red-400">
|
<div className="rounded-lg border border-red-400/20 bg-red-400/10 p-4 text-center">
|
||||||
{error}
|
<p className="text-sm text-red-400 mb-3">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setRetryCount(c => c + 1)}
|
||||||
|
className="rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : steps.length === 0 ? (
|
) : steps.length === 0 ? (
|
||||||
<div className="rounded-lg border border-border bg-accent/50 p-12 text-center">
|
<div className="rounded-lg border border-border bg-accent/50 p-12 text-center">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function BatchStatusPage() {
|
|||||||
const [sessions, setSessions] = useState<Session[]>([])
|
const [sessions, setSessions] = useState<Session[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
const [batchDate, setBatchDate] = useState<Date | null>(null)
|
const [batchDate, setBatchDate] = useState<Date | null>(null)
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
@@ -30,9 +31,12 @@ export default function BatchStatusPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await sessionsApi.list({ batch_id: batchId, size: 100 })
|
const data = await sessionsApi.list({ batch_id: batchId, size: 100 })
|
||||||
setSessions(Array.isArray(data) ? data : [])
|
setSessions(Array.isArray(data) ? data : [])
|
||||||
|
setLoadError(null)
|
||||||
if (data.length > 0 && data[0].started_at) {
|
if (data.length > 0 && data[0].started_at) {
|
||||||
setBatchDate(new Date(data[0].started_at))
|
setBatchDate(new Date(data[0].started_at))
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
setLoadError('Failed to load batch sessions')
|
||||||
} finally {
|
} finally {
|
||||||
if (showRefreshing) setIsRefreshing(false)
|
if (showRefreshing) setIsRefreshing(false)
|
||||||
}
|
}
|
||||||
@@ -48,6 +52,8 @@ export default function BatchStatusPage() {
|
|||||||
loadSessions(),
|
loadSessions(),
|
||||||
])
|
])
|
||||||
setTree(treeData)
|
setTree(treeData)
|
||||||
|
} catch {
|
||||||
|
setLoadError('Failed to load batch data')
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -178,7 +184,17 @@ export default function BatchStatusPage() {
|
|||||||
|
|
||||||
{/* Target cards */}
|
{/* Target cards */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sessions.length === 0 ? (
|
{loadError ? (
|
||||||
|
<div className="rounded-lg border border-red-400/20 bg-red-400/10 p-4 text-center">
|
||||||
|
<p className="text-sm text-red-400 mb-3">{loadError}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => loadSessions(true)}
|
||||||
|
className="rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
<p className="text-center text-[0.875rem] text-muted-foreground py-8">
|
<p className="text-center text-[0.875rem] text-muted-foreground py-8">
|
||||||
No sessions found for this batch.
|
No sessions found for this batch.
|
||||||
</p>
|
</p>
|
||||||
@@ -207,3 +223,4 @@ export default function BatchStatusPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePermissions } from '@/hooks/usePermissions'
|
|||||||
import { stepsApi } from '@/api/steps'
|
import { stepsApi } from '@/api/steps'
|
||||||
import { StepLibraryBrowser } from '@/components/step-library/StepLibraryBrowser'
|
import { StepLibraryBrowser } from '@/components/step-library/StepLibraryBrowser'
|
||||||
import { StepFormModal } from '@/components/step-library/StepFormModal'
|
import { StepFormModal } from '@/components/step-library/StepFormModal'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
import type { Step, StepListItem } from '@/types/step'
|
import type { Step, StepListItem } from '@/types/step'
|
||||||
|
|
||||||
export default function StepLibraryPage() {
|
export default function StepLibraryPage() {
|
||||||
@@ -20,9 +21,6 @@ export default function StepLibraryPage() {
|
|||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Toast for "Save to My Library"
|
|
||||||
const [saveToast, setSaveToast] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Increment to trigger StepLibraryBrowser reload
|
// Increment to trigger StepLibraryBrowser reload
|
||||||
const [refreshKey, setRefreshKey] = useState(0)
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
const refresh = () => setRefreshKey(k => k + 1)
|
const refresh = () => setRefreshKey(k => k + 1)
|
||||||
@@ -32,8 +30,8 @@ export default function StepLibraryPage() {
|
|||||||
try {
|
try {
|
||||||
const full = await stepsApi.get(step.id)
|
const full = await stepsApi.get(step.id)
|
||||||
setEditingStep(full)
|
setEditingStep(full)
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('Failed to load step for edit:', err)
|
toast.error('Failed to load step for editing')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +66,10 @@ export default function StepLibraryPage() {
|
|||||||
category_id: full.category_id,
|
category_id: full.category_id,
|
||||||
tags: full.tags,
|
tags: full.tags,
|
||||||
})
|
})
|
||||||
setSaveToast(`"${full.title}" saved to My Steps`)
|
toast.success(`"${full.title}" saved to My Steps`)
|
||||||
setTimeout(() => setSaveToast(null), 3000)
|
|
||||||
refresh()
|
refresh()
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('Failed to save step:', err)
|
toast.error('Failed to save step to your library')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,12 +165,6 @@ export default function StepLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Save Toast */}
|
|
||||||
{saveToast && (
|
|
||||||
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-border bg-card px-4 py-2 text-sm text-foreground shadow-lg">
|
|
||||||
{saveToast}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user