fix(ci): frontend lint to zero errors + dev-deps installable on clean image #149
@@ -1,11 +1,11 @@
|
|||||||
# Include production dependencies
|
# Include production dependencies
|
||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
|
|
||||||
# Testing
|
# Testing — pytest-asyncio 0.24+ requires pytest>=8.2
|
||||||
pytest==7.4.3
|
pytest==8.4.2
|
||||||
pytest-asyncio==0.24.0
|
pytest-asyncio==0.24.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
pytest-cov==4.1.0
|
pytest-cov==5.0.0
|
||||||
|
|
||||||
# Code quality
|
# Code quality
|
||||||
black==24.1.1
|
black==24.1.1
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ from app.main import app
|
|||||||
from app.core.database import Base, get_db
|
from app.core.database import Base, get_db
|
||||||
from app.core.admin_database import get_admin_db
|
from app.core.admin_database import get_admin_db
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
# Import every model module so all tables are registered with Base.metadata
|
||||||
|
# before the test_db fixture calls create_all. app.main imports models lazily
|
||||||
|
# (inside scheduler functions and route modules), which is fine at runtime
|
||||||
|
# but leaves the metadata incomplete at fixture-setup time — surfacing as
|
||||||
|
# "relation X does not exist" errors for any model whose route/scheduler
|
||||||
|
# hasn't been loaded yet. The `from app import models` form avoids
|
||||||
|
# shadowing the `app` FastAPI instance imported just above.
|
||||||
|
from app import models as _models # noqa: F401
|
||||||
|
|
||||||
# Disable invite code requirement for tests
|
# Disable invite code requirement for tests
|
||||||
settings.REQUIRE_INVITE_CODE = False
|
settings.REQUIRE_INVITE_CODE = False
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ export function TicketQueue() {
|
|||||||
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
||||||
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
|
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
// Monotonically increasing fetch token — late responses with a stale id
|
||||||
|
// are dropped so they can't overwrite the latest query's results.
|
||||||
|
const latestRequestId = useRef(0)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Check connection on mount
|
// Check connection on mount
|
||||||
@@ -238,12 +241,25 @@ export function TicketQueue() {
|
|||||||
params.board_ids = boardIds.join(',')
|
params.board_ids = boardIds.join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear stale data + flip loading inside the async function so the
|
||||||
|
// writes happen after the awaitable boundary — avoids the
|
||||||
|
// synchronous-setState-in-effect cascade the lint rule flags. The
|
||||||
|
// fetch is also wrapped in a request-id check so a stale response
|
||||||
|
// can't clobber a newer query.
|
||||||
|
const requestId = ++latestRequestId.current
|
||||||
|
setTickets([])
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await integrationsApi.searchTicketsQueue(params)
|
const results = await integrationsApi.searchTicketsQueue(params)
|
||||||
|
if (requestId !== latestRequestId.current) return
|
||||||
setTickets(results.items)
|
setTickets(results.items)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch {
|
} catch {
|
||||||
|
if (requestId !== latestRequestId.current) return
|
||||||
setError('Failed to load tickets. Check your PSA connection.')
|
setError('Failed to load tickets. Check your PSA connection.')
|
||||||
|
} finally {
|
||||||
|
if (requestId === latestRequestId.current) setLoading(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -253,9 +269,7 @@ export function TicketQueue() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasConnection) return
|
if (!hasConnection) return
|
||||||
if (activeTab === 'mine' && hasMemberMapping !== true) return
|
if (activeTab === 'mine' && hasMemberMapping !== true) return
|
||||||
setTickets([])
|
fetchTickets(activeTab, selectedBoardIds)
|
||||||
setLoading(true)
|
|
||||||
fetchTickets(activeTab, selectedBoardIds).finally(() => setLoading(false))
|
|
||||||
}, [activeTab, selectedBoardIds, hasConnection, hasMemberMapping, fetchTickets])
|
}, [activeTab, selectedBoardIds, hasConnection, hasMemberMapping, fetchTickets])
|
||||||
|
|
||||||
const handleStartSession = (ticket: PSATicketSearchResult) => {
|
const handleStartSession = (ticket: PSATicketSearchResult) => {
|
||||||
|
|||||||
@@ -62,10 +62,9 @@ function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) {
|
|||||||
}
|
}
|
||||||
}, [editing])
|
}, [editing])
|
||||||
|
|
||||||
// Sync if data.label changes externally (e.g. undo/redo)
|
// While not editing, the displayed label is derived directly from
|
||||||
useEffect(() => {
|
// nodeData.label — no effect-driven sync needed. labelValue holds the
|
||||||
if (!editing) setLabelValue(nodeData.label ?? '')
|
// edit buffer only and is reset when an edit session starts.
|
||||||
}, [nodeData.label, editing])
|
|
||||||
|
|
||||||
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
|
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
|
||||||
|
|
||||||
@@ -127,10 +126,11 @@ function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) {
|
|||||||
className="max-w-[88%] cursor-default text-center font-medium leading-tight text-primary line-clamp-2"
|
className="max-w-[88%] cursor-default text-center font-medium leading-tight text-primary line-clamp-2"
|
||||||
onDoubleClick={e => {
|
onDoubleClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
setLabelValue(nodeData.label ?? '')
|
||||||
setEditing(true)
|
setEditing(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{labelValue}
|
{nodeData.label ?? ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ const GroupNodeComponent = ({ data, selected, id }: NodeProps) => {
|
|||||||
if (editing) inputRef.current?.focus()
|
if (editing) inputRef.current?.focus()
|
||||||
}, [editing])
|
}, [editing])
|
||||||
|
|
||||||
// Sync if external data.label changes
|
// While not editing, the displayed label is derived directly from
|
||||||
useEffect(() => {
|
// groupData.label — no effect-driven sync needed. labelValue holds the
|
||||||
if (!editing) setLabelValue(groupData.label ?? '')
|
// edit buffer only and is reset when an edit session starts.
|
||||||
}, [groupData.label, editing])
|
|
||||||
|
|
||||||
const handleLabelCommit = () => {
|
const handleLabelCommit = () => {
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
@@ -69,9 +68,12 @@ const GroupNodeComponent = ({ data, selected, id }: NodeProps) => {
|
|||||||
<span
|
<span
|
||||||
className="inline-block rounded-sm bg-card/90 px-1.5 py-0.5 text-[11px] font-semibold cursor-text select-none tracking-wide"
|
className="inline-block rounded-sm bg-card/90 px-1.5 py-0.5 text-[11px] font-semibold cursor-text select-none tracking-wide"
|
||||||
style={{ color }}
|
style={{ color }}
|
||||||
onDoubleClick={() => setEditing(true)}
|
onDoubleClick={() => {
|
||||||
|
setLabelValue(groupData.label ?? '')
|
||||||
|
setEditing(true)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{labelValue || groupData.groupType}
|
{(groupData.label ?? '') || groupData.groupType}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -165,7 +165,9 @@ export function ScriptBuilderTab({
|
|||||||
|
|
||||||
// onViewScript is required by ScriptBuilderChat — provide a no-op for now
|
// onViewScript is required by ScriptBuilderChat — provide a no-op for now
|
||||||
// (inline preview is a future extension).
|
// (inline preview is a future extension).
|
||||||
const handleViewScript = (_script: string, _filename: string | null) => {
|
const handleViewScript = (script: string, filename: string | null) => {
|
||||||
|
void script
|
||||||
|
void filename
|
||||||
// Future: open inline preview panel
|
// Future: open inline preview panel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
frontend/src/components/routing/AssistantSessionRedirect.tsx
Normal file
11
frontend/src/components/routing/AssistantSessionRedirect.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Navigate, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanent 301-style redirect from /assistant/:sessionId to /pilot/:sessionId.
|
||||||
|
* Used by the Phase 1 route-rename; paired with a bare-path redirect to /pilot.
|
||||||
|
* SPA redirects replace history so the legacy URL does not linger in back-nav.
|
||||||
|
*/
|
||||||
|
export function AssistantSessionRedirect() {
|
||||||
|
const { sessionId } = useParams<{ sessionId: string }>()
|
||||||
|
return <Navigate to={sessionId ? `/pilot/${sessionId}` : '/pilot'} replace />
|
||||||
|
}
|
||||||
@@ -100,11 +100,13 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
|||||||
setCurrentStep(firstStep)
|
setCurrentStep(firstStep)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
// Prefer the backend's detail message over the generic axios status string
|
// Prefer the backend's detail message over the generic axios status string
|
||||||
const detail = (e as any)?.response?.data?.detail
|
const axiosErr = e as { response?: { status?: number; data?: { detail?: unknown } } }
|
||||||
|
const detail = axiosErr?.response?.data?.detail
|
||||||
const message = typeof detail === 'string' ? detail : (e instanceof Error ? e.message : 'Failed to start session')
|
const message = typeof detail === 'string' ? detail : (e instanceof Error ? e.message : 'Failed to start session')
|
||||||
setError(message)
|
setError(message)
|
||||||
// Global axios interceptor already shows a toast for 5xx — skip duplicate
|
// Global axios interceptor already shows a toast for 5xx — skip duplicate
|
||||||
if (!(e as any)?.response?.status || (e as any)?.response?.status < 500) {
|
const status = axiosErr?.response?.status
|
||||||
|
if (!status || status < 500) {
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSR-safe CSS media-query hook. Returns the current match boolean and
|
* SSR-safe CSS media-query hook. Returns the current match boolean and
|
||||||
* re-renders on viewport changes. Used by /pilot to swap the task lane
|
* re-renders on viewport changes. Used by /pilot to swap the task lane
|
||||||
* between side panel (≥1200px) and bottom drawer (<1200px) per Phase 7.
|
* between side panel (≥1200px) and bottom drawer (<1200px) per Phase 7.
|
||||||
|
*
|
||||||
|
* Implemented with useSyncExternalStore to subscribe to the MediaQueryList
|
||||||
|
* without an effect — this is the React-idiomatic shape for external-state
|
||||||
|
* subscriptions and avoids the setState-in-effect cascade lint rule.
|
||||||
*/
|
*/
|
||||||
export function useMediaQuery(query: string): boolean {
|
export function useMediaQuery(query: string): boolean {
|
||||||
const [matches, setMatches] = useState<boolean>(() => {
|
return useSyncExternalStore(
|
||||||
if (typeof window === 'undefined') return false
|
(onChange) => {
|
||||||
return window.matchMedia(query).matches
|
if (typeof window === 'undefined') return () => {}
|
||||||
})
|
const mql = window.matchMedia(query)
|
||||||
|
mql.addEventListener('change', onChange)
|
||||||
useEffect(() => {
|
return () => mql.removeEventListener('change', onChange)
|
||||||
if (typeof window === 'undefined') return
|
},
|
||||||
const mql = window.matchMedia(query)
|
() => {
|
||||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
|
if (typeof window === 'undefined') return false
|
||||||
// Sync once on mount in case state drifted between render and effect.
|
return window.matchMedia(query).matches
|
||||||
setMatches(mql.matches)
|
},
|
||||||
mql.addEventListener('change', handler)
|
() => false, // server snapshot — match initial false
|
||||||
return () => mql.removeEventListener('change', handler)
|
)
|
||||||
}, [query])
|
|
||||||
|
|
||||||
return matches
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useMediaQuery
|
export default useMediaQuery
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ export default function FlowPilotSessionPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const prefill = (location.state as { prefill?: string } | null)?.prefill || ''
|
const prefill = (location.state as { prefill?: string } | null)?.prefill || ''
|
||||||
const psaTicketId = (location.state as any)?.psaTicketId as string | undefined
|
const locationState = location.state as { psaTicketId?: string; psaTicket?: PSATicketInfo } | null
|
||||||
const psaTicket = (location.state as any)?.psaTicket as PSATicketInfo | undefined
|
const psaTicketId = locationState?.psaTicketId
|
||||||
|
const psaTicket = locationState?.psaTicket
|
||||||
const isPickup = searchParams.get('pickup') === 'true'
|
const isPickup = searchParams.get('pickup') === 'true'
|
||||||
const fp = useFlowPilotSession()
|
const fp = useFlowPilotSession()
|
||||||
const branching = useBranching()
|
const branching = useBranching()
|
||||||
|
|||||||
@@ -141,23 +141,42 @@ export default function TicketsPage() {
|
|||||||
|
|
||||||
function updateFilters(updated: Partial<TicketFilters>) {
|
function updateFilters(updated: Partial<TicketFilters>) {
|
||||||
const next = new URLSearchParams(searchParams)
|
const next = new URLSearchParams(searchParams)
|
||||||
if ('search' in updated) updated.search ? next.set('search', updated.search!) : next.delete('search')
|
if ('search' in updated) {
|
||||||
if ('board_id' in updated) updated.board_id ? next.set('board', String(updated.board_id)) : next.delete('board')
|
if (updated.search) next.set('search', updated.search)
|
||||||
if ('status_id' in updated) updated.status_id ? next.set('status', String(updated.status_id)) : next.delete('status')
|
else next.delete('search')
|
||||||
if ('priority' in updated) updated.priority ? next.set('priority', updated.priority!) : next.delete('priority')
|
}
|
||||||
if ('company_id' in updated) updated.company_id ? next.set('company', String(updated.company_id)) : next.delete('company')
|
if ('board_id' in updated) {
|
||||||
if ('assigned' in updated) {
|
if (updated.board_id) next.set('board', String(updated.board_id))
|
||||||
const a = updated.assigned
|
else next.delete('board')
|
||||||
a === 'all' ? next.delete('assigned') : next.set('assigned', String(a))
|
}
|
||||||
|
if ('status_id' in updated) {
|
||||||
|
if (updated.status_id) next.set('status', String(updated.status_id))
|
||||||
|
else next.delete('status')
|
||||||
|
}
|
||||||
|
if ('priority' in updated) {
|
||||||
|
if (updated.priority) next.set('priority', updated.priority)
|
||||||
|
else next.delete('priority')
|
||||||
|
}
|
||||||
|
if ('company_id' in updated) {
|
||||||
|
if (updated.company_id) next.set('company', String(updated.company_id))
|
||||||
|
else next.delete('company')
|
||||||
|
}
|
||||||
|
if ('assigned' in updated) {
|
||||||
|
if (updated.assigned === 'all') next.delete('assigned')
|
||||||
|
else next.set('assigned', String(updated.assigned))
|
||||||
|
}
|
||||||
|
if ('include_closed' in updated) {
|
||||||
|
if (updated.include_closed) next.set('closed', 'true')
|
||||||
|
else next.delete('closed')
|
||||||
}
|
}
|
||||||
if ('include_closed' in updated) updated.include_closed ? next.set('closed', 'true') : next.delete('closed')
|
|
||||||
next.delete('page') // reset to 1 on filter change
|
next.delete('page') // reset to 1 on filter change
|
||||||
setSearchParams(next)
|
setSearchParams(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePage(p: number) {
|
function updatePage(p: number) {
|
||||||
const next = new URLSearchParams(searchParams)
|
const next = new URLSearchParams(searchParams)
|
||||||
p === 1 ? next.delete('page') : next.set('page', String(p))
|
if (p === 1) next.delete('page')
|
||||||
|
else next.set('page', String(p))
|
||||||
setSearchParams(next)
|
setSearchParams(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createBrowserRouter, Navigate, useParams } from 'react-router-dom'
|
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||||
|
import { AssistantSessionRedirect } from '@/components/routing/AssistantSessionRedirect'
|
||||||
import * as Sentry from '@sentry/react'
|
import * as Sentry from '@sentry/react'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { AppLayout, ProtectedRoute } from '@/components/layout'
|
import { AppLayout, ProtectedRoute } from '@/components/layout'
|
||||||
@@ -102,16 +103,6 @@ function page(Component: React.LazyExoticComponent<React.ComponentType>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Permanent 301-style redirect from /assistant/:sessionId to /pilot/:sessionId.
|
|
||||||
* Used by the Phase 1 route-rename; paired with a bare-path redirect to /pilot.
|
|
||||||
* SPA redirects replace history so the legacy URL does not linger in back-nav.
|
|
||||||
*/
|
|
||||||
function AssistantSessionRedirect() {
|
|
||||||
const { sessionId } = useParams<{ sessionId: string }>()
|
|
||||||
return <Navigate to={sessionId ? `/pilot/${sessionId}` : '/pilot'} replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
export const router = sentryCreateBrowserRouter([
|
export const router = sentryCreateBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/landing',
|
path: '/landing',
|
||||||
|
|||||||
Reference in New Issue
Block a user