fix(web): add user-facing dependency states
This commit is contained in:
parent
40c03441ee
commit
a72d6b79d3
@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { CreateNoteModal } from "@/components/CreateNoteModal";
|
import { CreateNoteModal } from "@/components/CreateNoteModal";
|
||||||
|
import { StateNotice } from "@/components/StateNotice";
|
||||||
import { Button, Card } from "@/components/ui/Primitives";
|
import { Button, Card } from "@/components/ui/Primitives";
|
||||||
import { listNoteSummaries, listWorkspaceSummaries, seedOnboardingWorkspace } from "@/lib/notes-client";
|
import { listNoteSummaries, listWorkspaceSummaries, seedOnboardingWorkspace } from "@/lib/notes-client";
|
||||||
import { listApprovalQueue } from "@/lib/review-client";
|
import { listApprovalQueue } from "@/lib/review-client";
|
||||||
import { listSavedViews, type SavedView } from "@/lib/saved-views-client";
|
import { listSavedViews, type SavedView } from "@/lib/saved-views-client";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { getEmptyState, toUserFacingState, type UserFacingState } from "@/lib/user-facing-states";
|
||||||
import { IntakeUrlBar } from "@/components/IntakeUrlBar";
|
import { IntakeUrlBar } from "@/components/IntakeUrlBar";
|
||||||
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
||||||
|
|
||||||
@ -30,7 +32,7 @@ function DashboardContent() {
|
|||||||
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
|
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
|
||||||
const [apiSavedViews, setApiSavedViews] = useState<SavedView[]>([]);
|
const [apiSavedViews, setApiSavedViews] = useState<SavedView[]>([]);
|
||||||
const [pendingReviewCount, setPendingReviewCount] = useState(0);
|
const [pendingReviewCount, setPendingReviewCount] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [errorState, setErrorState] = useState<UserFacingState | null>(null);
|
||||||
const [showCreateNote, setShowCreateNote] = useState(false);
|
const [showCreateNote, setShowCreateNote] = useState(false);
|
||||||
const [seeding, setSeeding] = useState(false);
|
const [seeding, setSeeding] = useState(false);
|
||||||
|
|
||||||
@ -41,25 +43,28 @@ function DashboardContent() {
|
|||||||
}
|
}
|
||||||
}, [searchParams, router]);
|
}, [searchParams, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadDashboard = useCallback(async () => {
|
||||||
void (async () => {
|
try {
|
||||||
try {
|
setErrorState(null);
|
||||||
const [nextNotes, nextWorkspaces, nextApprovalQueue, saved] = await Promise.all([
|
const [nextNotes, nextWorkspaces, nextApprovalQueue, saved] = await Promise.all([
|
||||||
listNoteSummaries(),
|
listNoteSummaries(),
|
||||||
listWorkspaceSummaries(),
|
listWorkspaceSummaries(),
|
||||||
listApprovalQueue(),
|
listApprovalQueue(),
|
||||||
listSavedViews().catch(() => [] as SavedView[]),
|
listSavedViews().catch(() => [] as SavedView[]),
|
||||||
]);
|
]);
|
||||||
setNotes(nextNotes);
|
setNotes(nextNotes);
|
||||||
setWorkspaces(nextWorkspaces);
|
setWorkspaces(nextWorkspaces);
|
||||||
setPendingReviewCount(nextApprovalQueue.length);
|
setPendingReviewCount(nextApprovalQueue.length);
|
||||||
setApiSavedViews(saved);
|
setApiSavedViews(saved);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Unable to load dashboard data");
|
setErrorState(toUserFacingState(err, "backend"));
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadDashboard();
|
||||||
|
}, [loadDashboard]);
|
||||||
|
|
||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{
|
{
|
||||||
id: "workspace-all",
|
id: "workspace-all",
|
||||||
@ -186,7 +191,11 @@ function DashboardContent() {
|
|||||||
toast.success("Sample workspace created");
|
toast.success("Sample workspace created");
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
})
|
})
|
||||||
.catch((e) => toast.error(e instanceof Error ? e.message : "Seed failed"))
|
.catch((e) => {
|
||||||
|
const state = toUserFacingState(e, "backend");
|
||||||
|
setErrorState(state);
|
||||||
|
toast.error(state.message);
|
||||||
|
})
|
||||||
.finally(() => setSeeding(false));
|
.finally(() => setSeeding(false));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -280,8 +289,11 @@ function DashboardContent() {
|
|||||||
|
|
||||||
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
|
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
|
||||||
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>Recent note activity</div>
|
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>Recent note activity</div>
|
||||||
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
|
{errorState ? <StateNotice state={errorState} onAction={() => void loadDashboard()} compact /> : null}
|
||||||
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
||||||
|
{recentNotes.length === 0 && !errorState ? (
|
||||||
|
<StateNotice state={getEmptyState("backend", "recent notes")} compact />
|
||||||
|
) : null}
|
||||||
{recentNotes.map((note) => (
|
{recentNotes.map((note) => (
|
||||||
<div key={note.id} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
|
<div key={note.id} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { StateNotice } from "@/components/StateNotice";
|
||||||
|
import { Button } from "@/components/ui/Primitives";
|
||||||
import { getArtifactReadUrl, uploadArtifact } from "@/lib/blob-client";
|
import { getArtifactReadUrl, uploadArtifact } from "@/lib/blob-client";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { getEmptyState, toUserFacingState, type UserFacingState } from "@/lib/user-facing-states";
|
||||||
import type { ArtifactSummary } from "@/lib/types";
|
import type { ArtifactSummary } from "@/lib/types";
|
||||||
|
|
||||||
export function ArtifactPanel({
|
export function ArtifactPanel({
|
||||||
@ -26,6 +29,7 @@ export function ArtifactPanel({
|
|||||||
const [artifactType, setArtifactType] = useState<"file" | "summary" | "extraction" | "citation" | "export">("file");
|
const [artifactType, setArtifactType] = useState<"file" | "summary" | "extraction" | "citation" | "export">("file");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [blobPath, setBlobPath] = useState("");
|
const [blobPath, setBlobPath] = useState("");
|
||||||
|
const [noticeState, setNoticeState] = useState<UserFacingState | null>(null);
|
||||||
|
|
||||||
async function handleOpenArtifact(artifact: ArtifactSummary) {
|
async function handleOpenArtifact(artifact: ArtifactSummary) {
|
||||||
if (!artifact.blobPath) {
|
if (!artifact.blobPath) {
|
||||||
@ -46,6 +50,7 @@ export function ArtifactPanel({
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setNoticeState(null);
|
||||||
try {
|
try {
|
||||||
const path = `artifacts/${crypto.randomUUID()}/${file.name}`;
|
const path = `artifacts/${crypto.randomUUID()}/${file.name}`;
|
||||||
const result = await uploadArtifact(file, path);
|
const result = await uploadArtifact(file, path);
|
||||||
@ -57,7 +62,9 @@ export function ArtifactPanel({
|
|||||||
});
|
});
|
||||||
toast.success("File uploaded");
|
toast.success("File uploaded");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Upload failed");
|
const state = toUserFacingState(err, "blob");
|
||||||
|
setNoticeState(state);
|
||||||
|
toast.error(state.message);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@ -80,6 +87,7 @@ export function ArtifactPanel({
|
|||||||
setArtifactType("file");
|
setArtifactType("file");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setBlobPath("");
|
setBlobPath("");
|
||||||
|
setNoticeState(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -133,34 +141,35 @@ export function ArtifactPanel({
|
|||||||
/>
|
/>
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-2)" }}>
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-2)" }}>
|
||||||
<input ref={fileInputRef} type="file" hidden onChange={handleFileUpload} />
|
<input ref={fileInputRef} type="file" hidden onChange={handleFileUpload} />
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="surface-muted"
|
|
||||||
style={{ padding: "8px 12px", border: "none", fontSize: "var(--nl-fs-sm)" }}
|
|
||||||
>
|
>
|
||||||
{uploading ? "Uploading…" : "Upload file"}
|
{uploading ? "Uploading…" : "Upload file"}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleCreateArtifact();
|
void handleCreateArtifact();
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "var(--nl-radius-md)",
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: "var(--nl-accent-primary)",
|
|
||||||
color: "var(--nl-text-primary)",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isCreating ? "Adding…" : "Add artifact"}
|
{isCreating ? "Adding…" : "Add artifact"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{noticeState ? (
|
||||||
|
<StateNotice
|
||||||
|
state={noticeState}
|
||||||
|
compact
|
||||||
|
onAction={noticeState.actionLabel ? () => fileInputRef.current?.click() : undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<StateNotice state={getEmptyState("blob", "artifacts")} compact />
|
||||||
|
) : null}
|
||||||
{artifacts.map((artifact) => (
|
{artifacts.map((artifact) => (
|
||||||
<div key={artifact.id} className="surface-muted" style={{ padding: "var(--nl-space-3)", display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", alignItems: "center" }}>
|
<div key={artifact.id} className="surface-muted" style={{ padding: "var(--nl-space-3)", display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", alignItems: "center" }}>
|
||||||
<div style={{ display: "grid", gap: 4 }}>
|
<div style={{ display: "grid", gap: 4 }}>
|
||||||
|
|||||||
@ -70,4 +70,13 @@ describe("AuthGuard", () => {
|
|||||||
expect(await screen.findByText("Maintenance window")).toBeInTheDocument();
|
expect(await screen.findByText("Maintenance window")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Private app")).not.toBeInTheDocument();
|
expect(screen.queryByText("Private app")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a platform-down state when readiness cannot be checked", async () => {
|
||||||
|
checkKillSwitchMock.mockRejectedValue(new Error("HTTP 503 Service Unavailable"));
|
||||||
|
|
||||||
|
render(<AuthGuard><div>Private app</div></AuthGuard>);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Platform services are unavailable")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Private app")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,11 +5,13 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
import { refreshStoredAuthSession } from "@/lib/auth-session";
|
import { refreshStoredAuthSession } from "@/lib/auth-session";
|
||||||
import { checkKillSwitch } from "@/lib/kill-switch";
|
import { checkKillSwitch } from "@/lib/kill-switch";
|
||||||
|
import { StateNotice } from "@/components/StateNotice";
|
||||||
|
import { toUserFacingState, type UserFacingState } from "@/lib/user-facing-states";
|
||||||
|
|
||||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||||
const { isAuthenticated, isLoading: authLoading, logout } = useAuth();
|
const { isAuthenticated, isLoading: authLoading, logout } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [killSwitchMsg, setKillSwitchMsg] = useState<string | null>(null);
|
const [blockingState, setBlockingState] = useState<UserFacingState | null>(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -35,13 +37,16 @@ export function AuthGuard({ children }: { children: ReactNode }) {
|
|||||||
const { disabled, message } = await checkKillSwitch();
|
const { disabled, message } = await checkKillSwitch();
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
setKillSwitchMsg(message || "This application is currently unavailable.");
|
setBlockingState({
|
||||||
|
...toUserFacingState(new Error(message || "This application is currently disabled"), "feature"),
|
||||||
|
message: message || "This application is currently unavailable.",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setReady(true);
|
setBlockingState(toUserFacingState(error, "platform"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,12 +55,11 @@ export function AuthGuard({ children }: { children: ReactNode }) {
|
|||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [isAuthenticated, authLoading, logout, router]);
|
}, [isAuthenticated, authLoading, logout, router]);
|
||||||
|
|
||||||
if (killSwitchMsg) {
|
if (blockingState) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: "var(--nl-space-8)" }}>
|
<div style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: "var(--nl-space-8)" }}>
|
||||||
<div className="surface-card" style={{ padding: "var(--nl-space-8)", maxWidth: 480, textAlign: "center" }}>
|
<div style={{ maxWidth: 520, width: "100%" }}>
|
||||||
<h1 style={{ margin: 0, fontSize: "var(--nl-fs-xl)", color: "var(--nl-danger)" }}>Service Unavailable</h1>
|
<StateNotice state={blockingState} onAction={blockingState.actionLabel ? () => window.location.reload() : undefined} />
|
||||||
<p style={{ marginTop: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>{killSwitchMsg}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { extractSuggestedTasks } from "@/lib/extraction-client";
|
import { extractSuggestedTasks } from "@/lib/extraction-client";
|
||||||
|
import { StateNotice } from "@/components/StateNotice";
|
||||||
import { Button, Card } from "@/components/ui/Primitives";
|
import { Button, Card } from "@/components/ui/Primitives";
|
||||||
import { createNoteTask } from "@/lib/notes-client";
|
import { createNoteTask } from "@/lib/notes-client";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { getEmptyState, toUserFacingState, type UserFacingState } from "@/lib/user-facing-states";
|
||||||
import type { NoteTask } from "@/lib/types";
|
import type { NoteTask } from "@/lib/types";
|
||||||
|
|
||||||
export function ExtractedTasksPanel({
|
export function ExtractedTasksPanel({
|
||||||
@ -23,6 +25,7 @@ export function ExtractedTasksPanel({
|
|||||||
const [proposals, setProposals] = useState<NoteTask[]>([]);
|
const [proposals, setProposals] = useState<NoteTask[]>([]);
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [acceptingId, setAcceptingId] = useState<string | null>(null);
|
const [acceptingId, setAcceptingId] = useState<string | null>(null);
|
||||||
|
const [noticeState, setNoticeState] = useState<UserFacingState | null>(null);
|
||||||
|
|
||||||
const persistedTitles = useMemo(
|
const persistedTitles = useMemo(
|
||||||
() => new Set(persistedTasks.map((t) => t.title.trim().toLowerCase())),
|
() => new Set(persistedTasks.map((t) => t.title.trim().toLowerCase())),
|
||||||
@ -31,15 +34,18 @@ export function ExtractedTasksPanel({
|
|||||||
|
|
||||||
const handleScan = useCallback(async () => {
|
const handleScan = useCallback(async () => {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
|
setNoticeState(null);
|
||||||
try {
|
try {
|
||||||
const extracted = await extractSuggestedTasks(noteBody);
|
const extracted = await extractSuggestedTasks(noteBody);
|
||||||
const filtered = extracted.filter((t) => !persistedTitles.has(t.title.trim().toLowerCase()));
|
const filtered = extracted.filter((t) => !persistedTitles.has(t.title.trim().toLowerCase()));
|
||||||
setProposals(filtered);
|
setProposals(filtered);
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
toast.success("No new suggested tasks found");
|
setNoticeState(getEmptyState("extraction", "suggested tasks"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Scan failed");
|
const state = toUserFacingState(e, "extraction");
|
||||||
|
setNoticeState(state);
|
||||||
|
toast.error(state.message);
|
||||||
} finally {
|
} finally {
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
}
|
}
|
||||||
@ -60,7 +66,9 @@ export function ExtractedTasksPanel({
|
|||||||
toast.success("Task added");
|
toast.success("Task added");
|
||||||
onTaskAccepted();
|
onTaskAccepted();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Could not create task");
|
const state = toUserFacingState(e, "backend");
|
||||||
|
setNoticeState(state);
|
||||||
|
toast.error(state.message);
|
||||||
} finally {
|
} finally {
|
||||||
setAcceptingId(null);
|
setAcceptingId(null);
|
||||||
}
|
}
|
||||||
@ -89,6 +97,13 @@ export function ExtractedTasksPanel({
|
|||||||
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
||||||
Runs extraction on demand. Accept adds a backend task; dismiss only hides the suggestion for this session.
|
Runs extraction on demand. Accept adds a backend task; dismiss only hides the suggestion for this session.
|
||||||
</p>
|
</p>
|
||||||
|
{noticeState ? (
|
||||||
|
<StateNotice
|
||||||
|
state={noticeState}
|
||||||
|
compact
|
||||||
|
onAction={noticeState.actionLabel ? () => void handleScan() : undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{proposals.length === 0 ? null : (
|
{proposals.length === 0 ? null : (
|
||||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: "var(--nl-space-2)" }}>
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: "var(--nl-space-2)" }}>
|
||||||
{proposals.map((task) => (
|
{proposals.map((task) => (
|
||||||
|
|||||||
25
web/src/components/StateNotice.test.tsx
Normal file
25
web/src/components/StateNotice.test.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { StateNotice } from "@/components/StateNotice";
|
||||||
|
|
||||||
|
describe("StateNotice", () => {
|
||||||
|
it("renders user-facing state copy and action", async () => {
|
||||||
|
const onAction = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<StateNotice
|
||||||
|
state={{
|
||||||
|
kind: "backend",
|
||||||
|
title: "Backend is unavailable",
|
||||||
|
message: "Retry when the backend is healthy.",
|
||||||
|
actionLabel: "Retry",
|
||||||
|
}}
|
||||||
|
onAction={onAction}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Backend is unavailable");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
expect(onAction).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
web/src/components/StateNotice.tsx
Normal file
40
web/src/components/StateNotice.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Card } from "@/components/ui/Primitives";
|
||||||
|
import type { UserFacingState } from "@/lib/user-facing-states";
|
||||||
|
|
||||||
|
export function StateNotice({
|
||||||
|
state,
|
||||||
|
onAction,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
state: UserFacingState;
|
||||||
|
onAction?: () => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
style={{
|
||||||
|
padding: compact ? "var(--nl-space-4)" : "var(--nl-space-5)",
|
||||||
|
display: "grid",
|
||||||
|
gap: "var(--nl-space-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
|
||||||
|
<strong style={{ color: state.kind === "feature" ? "var(--nl-warning)" : "var(--nl-text-primary)" }}>
|
||||||
|
{state.title}
|
||||||
|
</strong>
|
||||||
|
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
||||||
|
{state.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onAction && state.actionLabel ? (
|
||||||
|
<Button type="button" variant="secondary" onClick={onAction} style={{ justifySelf: "start" }}>
|
||||||
|
{state.actionLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web/src/lib/user-facing-states.test.ts
Normal file
56
web/src/lib/user-facing-states.test.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getEmptyState, toUserFacingState } from "@/lib/user-facing-states";
|
||||||
|
|
||||||
|
describe("user-facing states", () => {
|
||||||
|
it("maps backend connectivity failures to stable backend copy", () => {
|
||||||
|
expect(toUserFacingState(new Error("Failed to fetch"), "backend")).toMatchObject({
|
||||||
|
kind: "backend",
|
||||||
|
title: "Backend is unavailable",
|
||||||
|
actionLabel: "Retry",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps platform failures to platform copy", () => {
|
||||||
|
expect(toUserFacingState(new Error("HTTP 503 Service Unavailable"), "platform")).toMatchObject({
|
||||||
|
kind: "platform",
|
||||||
|
title: "Platform services are unavailable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps extraction failures to scan retry copy", () => {
|
||||||
|
expect(toUserFacingState(new Error("Network error"), "extraction")).toMatchObject({
|
||||||
|
kind: "extraction",
|
||||||
|
title: "Extraction service is unavailable",
|
||||||
|
actionLabel: "Scan again",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps blob failures to upload copy", () => {
|
||||||
|
expect(toUserFacingState(new Error("HTTP 504"), "blob")).toMatchObject({
|
||||||
|
kind: "blob",
|
||||||
|
title: "File upload failed",
|
||||||
|
actionLabel: "Choose file again",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps disabled feature responses even when another dependency kind is preferred", () => {
|
||||||
|
expect(toUserFacingState(new Error("Smart Actions are disabled"), "backend")).toMatchObject({
|
||||||
|
kind: "feature",
|
||||||
|
title: "Feature is disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps validation-style messages when the service responded", () => {
|
||||||
|
expect(toUserFacingState(new Error("Workspace name is required"), "backend")).toMatchObject({
|
||||||
|
kind: "backend",
|
||||||
|
message: "Workspace name is required",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides empty-state copy for extraction results", () => {
|
||||||
|
expect(getEmptyState("extraction", "suggested tasks")).toMatchObject({
|
||||||
|
title: "No suggested tasks found",
|
||||||
|
message: "Extraction completed, but there were no new suggestions to add.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
107
web/src/lib/user-facing-states.ts
Normal file
107
web/src/lib/user-facing-states.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
export type DependencyKind = "backend" | "platform" | "extraction" | "blob" | "feature" | "generic";
|
||||||
|
|
||||||
|
export type UserFacingState = {
|
||||||
|
kind: DependencyKind;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_COPY: Record<DependencyKind, UserFacingState> = {
|
||||||
|
backend: {
|
||||||
|
kind: "backend",
|
||||||
|
title: "Backend is unavailable",
|
||||||
|
message: "NoteLett could not reach the notes backend. Check the service status and retry.",
|
||||||
|
actionLabel: "Retry",
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
kind: "platform",
|
||||||
|
title: "Platform services are unavailable",
|
||||||
|
message: "Authentication, feature flags, or kill switch checks are not responding. Retry once the platform service is healthy.",
|
||||||
|
actionLabel: "Retry",
|
||||||
|
},
|
||||||
|
extraction: {
|
||||||
|
kind: "extraction",
|
||||||
|
title: "Extraction service is unavailable",
|
||||||
|
message: "AI task extraction could not run right now. Your note is safe; try scanning again after the service recovers.",
|
||||||
|
actionLabel: "Scan again",
|
||||||
|
},
|
||||||
|
blob: {
|
||||||
|
kind: "blob",
|
||||||
|
title: "File upload failed",
|
||||||
|
message: "Blob storage did not accept the upload. Keep the file locally and retry when storage is healthy.",
|
||||||
|
actionLabel: "Choose file again",
|
||||||
|
},
|
||||||
|
feature: {
|
||||||
|
kind: "feature",
|
||||||
|
title: "Feature is disabled",
|
||||||
|
message: "This workflow is currently disabled by a NoteLett feature flag.",
|
||||||
|
},
|
||||||
|
generic: {
|
||||||
|
kind: "generic",
|
||||||
|
title: "Something went wrong",
|
||||||
|
message: "The request could not be completed. Try again.",
|
||||||
|
actionLabel: "Retry",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEmptyState(kind: DependencyKind, noun: string): UserFacingState {
|
||||||
|
if (kind === "backend") {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
title: `No ${noun} yet`,
|
||||||
|
message: "Create your first item to start building this workspace.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "extraction") {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
title: `No ${noun} found`,
|
||||||
|
message: "Extraction completed, but there were no new suggestions to add.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
title: `No ${noun} yet`,
|
||||||
|
message: "There is nothing to show here yet.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toUserFacingState(
|
||||||
|
error: unknown,
|
||||||
|
preferredKind: DependencyKind = "generic",
|
||||||
|
): UserFacingState {
|
||||||
|
const rawMessage = error instanceof Error ? error.message : String(error ?? "");
|
||||||
|
const message = rawMessage.toLowerCase();
|
||||||
|
const status = matchStatus(message);
|
||||||
|
|
||||||
|
const kind =
|
||||||
|
message.includes("disabled") || message.includes("not enabled") || message.includes("feature flag")
|
||||||
|
? "feature"
|
||||||
|
: preferredKind;
|
||||||
|
|
||||||
|
const base = STATE_COPY[kind];
|
||||||
|
const isConnectivityIssue =
|
||||||
|
message.includes("failed to fetch") ||
|
||||||
|
message.includes("network") ||
|
||||||
|
message.includes("service unavailable") ||
|
||||||
|
status === 502 ||
|
||||||
|
status === 503 ||
|
||||||
|
status === 504;
|
||||||
|
|
||||||
|
if (kind !== "feature" && rawMessage && !isConnectivityIssue) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
message: rawMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchStatus(message: string): number | null {
|
||||||
|
const match = message.match(/\b(4\d\d|5\d\d)\b/);
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user