This commit is contained in:
@@ -20,6 +20,10 @@ export class SupabaseAuthService implements AuthService {
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
// Auditoría Sprint 5 Etapa 1: este getSession() NO se reemplaza por getSessionSafe()
|
||||
// porque necesita el objeto session en sí (no es una barrera descartable) y getCurrentUser()
|
||||
// no tiene ningún caller en la app hoy (AuthContext usa onAuthStateChange). Cambios de auth
|
||||
// están fuera de scope de esta etapa — se documenta la decisión, no se toca el comportamiento.
|
||||
async getCurrentUser(): Promise<AuthUser | null> {
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
if (!session?.user) return null
|
||||
|
||||
30
src/components/ErrorBoundary.tsx
Normal file
30
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
interface Props { children: ReactNode; fallback?: ReactNode }
|
||||
interface State { hasError: boolean; error: Error | null }
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? (
|
||||
<div className="flex flex-col items-center justify-center h-screen gap-2 p-8">
|
||||
<p className="text-sm font-medium text-destructive">Algo salió mal al cargar el workspace.</p>
|
||||
<p className="text-xs text-muted-foreground">{this.state.error?.message}</p>
|
||||
<button
|
||||
className="text-xs underline text-muted-foreground"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Recargar la página
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
|
||||
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
|
||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||
@@ -30,8 +30,8 @@ export function useReportData(processId: string): ReportData {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Barrera única: garantiza sesión restaurada antes del fan-out en paralelo
|
||||
await supabase.auth.getSession()
|
||||
// Barrera única con timeout: garantiza sesión restaurada antes del fan-out en paralelo
|
||||
await getSessionSafe()
|
||||
const [proc, sim, acts, res] = await Promise.all([
|
||||
supabaseProcessRepo.getById(processId),
|
||||
supabaseSimulationRepo.getLatestByProcess(processId),
|
||||
|
||||
@@ -9,18 +9,19 @@ import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { BpmnCanvas, type BpmnElementCategory } from './BpmnCanvas'
|
||||
import { ActivityPanel } from './ActivityPanel'
|
||||
import { GatewayPanel, isXorSumValid } from './GatewayPanel'
|
||||
import { ResourcesPanel } from './ResourcesPanel'
|
||||
import { GlobalSettingsPanel } from './GlobalSettingsPanel'
|
||||
import { WorkspaceSkeleton } from './WorkspaceSkeleton'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { useSimulationStore } from '@/store/simulation-store'
|
||||
import { useSimulate } from '../simulation/useSimulate'
|
||||
import { useLibraryStore } from '@/store/library-store'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
|
||||
type SelectedCategory = 'activity' | 'gateway' | null
|
||||
|
||||
@@ -30,7 +31,6 @@ export function WorkspacePage() {
|
||||
const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore()
|
||||
const { isRunning, resultActual, error: simError, selectActivity, loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore()
|
||||
const { simulate } = useSimulate()
|
||||
const { toast } = useToast()
|
||||
const { user } = useAuth()
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
@@ -50,31 +50,16 @@ export function WorkspacePage() {
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState(false)
|
||||
const getAllTags = useLibraryStore((state) => state.getAllTags)
|
||||
|
||||
// Usamos un ref para saber si loadProcess ya terminó al menos una vez.
|
||||
// Esto evita que el redirect se dispare en el render inicial donde
|
||||
// isLoading=false y currentProcess=null (antes del primer efecto).
|
||||
const hasLoadedOnce = useRef(false)
|
||||
|
||||
useEffect(() => { loadProcess(processId) }, [processId, loadProcess])
|
||||
|
||||
// Cargar el último executedAt persistido para comparar contra updatedAt (indicador stale)
|
||||
// Cargar el último executedAt persistido para comparar contra updatedAt (indicador stale).
|
||||
// Depende de currentProcess?.id (no del objeto) a propósito: evita re-disparar el efecto
|
||||
// en cada edición de campos del proceso, solo cuando cambia de proceso.
|
||||
useEffect(() => {
|
||||
if (currentProcess) loadLatestForProcess(currentProcess.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProcess?.id, loadLatestForProcess])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) { hasLoadedOnce.current = true; return }
|
||||
if (!hasLoadedOnce.current) return // carga no iniciada aún
|
||||
if ((error || !currentProcess) && processId) {
|
||||
toast({
|
||||
title: 'Proceso no encontrado',
|
||||
description: 'El proceso que buscás no existe o fue eliminado.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
navigate({ to: '/' })
|
||||
}
|
||||
}, [isLoading, error, currentProcess, processId, toast, navigate])
|
||||
|
||||
// Validación XOR: todos los gateways exclusivos deben sumar 1.0
|
||||
const xorValidation = useMemo(() => {
|
||||
const invalidIds = gateways
|
||||
@@ -166,16 +151,27 @@ export function WorkspacePage() {
|
||||
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
if (isLoading && !currentProcess) {
|
||||
return <WorkspaceSkeleton />
|
||||
}
|
||||
|
||||
if (error || !currentProcess) {
|
||||
return null // el useEffect ya inició el redirect con toast
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center gap-4">
|
||||
<AlertCircle className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground max-w-sm text-center">
|
||||
{error ?? 'No se pudo cargar el proceso. Revisá tu conexión.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => loadProcess(processId)}>
|
||||
Reintentar
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => navigate({ to: '/' })}>
|
||||
Volver a la biblioteca
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Label del tab "elemento" según lo seleccionado
|
||||
@@ -483,3 +479,11 @@ export function WorkspacePage() {
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspacePageWithBoundary() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<WorkspacePage />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
45
src/features/workspace/WorkspaceSkeleton.tsx
Normal file
45
src/features/workspace/WorkspaceSkeleton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
|
||||
// Preserva el layout real del workspace (topbar + canvas + panel lateral) mientras
|
||||
// loadProcess() está en curso, en vez de un spinner genérico sobre pantalla en blanco.
|
||||
export function WorkspaceSkeleton() {
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-slate-100 overflow-hidden">
|
||||
<AppHeader />
|
||||
|
||||
{/* Topbar */}
|
||||
<header className="min-h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0">
|
||||
<div className="h-8 w-8 rounded-md bg-muted animate-pulse" />
|
||||
<div className="h-5 w-px bg-slate-200" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<div className="h-3.5 w-48 rounded bg-muted animate-pulse" />
|
||||
<div className="h-2.5 w-28 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="h-8 w-24 rounded-md bg-muted animate-pulse" />
|
||||
</header>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Canvas BPMN */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full w-full rounded-lg bg-muted animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Panel lateral */}
|
||||
<div className="shrink-0 w-72 bg-white border-l border-slate-200 p-4 space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-7 w-16 rounded-md bg-muted animate-pulse" />
|
||||
<div className="h-7 w-16 rounded-md bg-muted animate-pulse" />
|
||||
<div className="h-7 w-16 rounded-md bg-muted animate-pulse" />
|
||||
</div>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="h-3 w-20 rounded bg-muted animate-pulse" />
|
||||
<div className="h-2.5 w-full rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useEffect } from 'react'
|
||||
import { createRouter, createRootRoute, createRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { WorkspacePage } from '@/features/workspace/WorkspacePage'
|
||||
import { WorkspacePageWithBoundary } from '@/features/workspace/WorkspacePage'
|
||||
import { ImportPage } from '@/features/import/ImportPage'
|
||||
import { LibraryPage } from '@/features/library/LibraryPage'
|
||||
import { SettingsPage } from '@/features/settings/SettingsPage'
|
||||
@@ -80,7 +80,7 @@ const importRoute = createRoute({
|
||||
const workspaceRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/workspace/$processId',
|
||||
component: WorkspacePage,
|
||||
component: WorkspacePageWithBoundary,
|
||||
})
|
||||
|
||||
const reportRoute = createRoute({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { getSessionSafe } from '@/lib/supabase'
|
||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
|
||||
import { supabaseGatewayRepo } from '@/persistence/supabase/gateway-repo'
|
||||
@@ -36,8 +36,9 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
loadProcess: async (processId) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
// Barrera única: garantiza sesión restaurada antes del fan-out en paralelo
|
||||
await supabase.auth.getSession()
|
||||
// Barrera única con timeout: garantiza sesión restaurada antes del fan-out en paralelo
|
||||
// sin bloquear isLoading indefinidamente si el cliente Supabase se cuelga (ver Sprint 4 Etapa 5B)
|
||||
await getSessionSafe()
|
||||
const [process, activities, gateways, resources] = await Promise.all([
|
||||
supabaseProcessRepo.getById(processId),
|
||||
supabaseActivityRepo.getByProcess(processId),
|
||||
@@ -53,7 +54,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
|
||||
setCurrentProcess: async (process, userId) => {
|
||||
await supabaseProcessRepo.save(process, userId)
|
||||
await supabase.auth.getSession()
|
||||
await getSessionSafe()
|
||||
const [activities, gateways, resources] = await Promise.all([
|
||||
supabaseActivityRepo.getByProcess(process.id),
|
||||
supabaseGatewayRepo.getByProcess(process.id),
|
||||
|
||||
Reference in New Issue
Block a user