Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
"jwt-decode": "^4.0.0",
"lodash": "^4.18.1",
"oidc-client-ts": "^3.5.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-error-boundary": "^6.1.2",
"react-helmet": "^6.1.0",
"react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.4.2",
Expand All @@ -37,15 +38,15 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react-helmet": "^6.1.11",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.0.4",
"core-js": "^3.49.0",
"eslint": "^10.7.0",
"globals": "^17.7.0",
"postcss": "^8.5.20",
"postcss": "^8.5.22",
"react-refresh": "^0.18.0",
"tailwindcss": "4.3.1",
"tailwindcss-scoped-preflight": "^4.0.6",
"typescript-eslint": "^8.64.0",
"typescript-eslint": "^8.65.0",
"vite": "^8.1.5"
},
"packageManager": "pnpm@11.8.0"
Expand Down
1,624 changes: 815 additions & 809 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

15 changes: 10 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ScaleLoadingSpinner } from "@telekom/scale-components-react";
import { Suspense } from "react";
import { OIDCProvider } from "./Components/Auth";
import { AppErrorBoundary } from "./Components/ErrorBoundary";
import { BrowserRouter } from "./Components/Router";
import { Layout } from "./Pages";
import { StatusContext } from "./Services/Status";
Expand All @@ -18,14 +19,18 @@ import { StatusContext } from "./Services/Status";
*/
export function App() {
return (
<Suspense fallback={<ScaleLoadingSpinner />}>
<AppErrorBoundary variant="root">
<BrowserRouter>
<OIDCProvider>
<StatusContext>
<Layout />
</StatusContext>
<AppErrorBoundary variant="data">
<Suspense fallback={<ScaleLoadingSpinner />}>
<StatusContext>
<Layout />
</StatusContext>
</Suspense>
</AppErrorBoundary>
</OIDCProvider>
</BrowserRouter>
</Suspense>
</AppErrorBoundary>
)
}
34 changes: 10 additions & 24 deletions src/Components/Auth/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Link, Toast, ToastFooter, ToastTitle, ToastTrigger, useToastController } from "@fluentui/react-components";
import { Link } from "@fluentui/react-components";
import { useMount } from "ahooks";
import { ReactNode, useEffect } from "react";
import { AuthProvider, useAuth } from "react-oidc-context";
import { Logger } from "~/Helpers/Logger";
import { useAppToast } from "~/Helpers/useAppToast";
import { useRouter } from "../Router";
import { UserMgr } from "./UserMgr";

Expand Down Expand Up @@ -32,7 +33,7 @@ const log = new Logger("Auth");
function AuthHandler() {
const auth = useAuth();
const { Paths } = useRouter();
const { dispatchToast } = useToastController();
const toast = useAppToast();

useMount(() => {
if (Paths.at(0) === "signin-oidc") {
Expand All @@ -42,28 +43,13 @@ function AuthHandler() {
});

function dispatch() {
dispatchToast(
<Toast>
<ToastTitle>
Login Expired
</ToastTitle>

<ToastFooter>
<Link
onClick={() => auth.signinRedirect()}>
Login Again
</Link>

<ToastTrigger>
<Link
href="/">
Dismiss
</Link>
</ToastTrigger>
</ToastFooter>
</Toast>,
{ intent: "warning", timeout: -1 }
);
toast.showWarning("Login Expired", {
action: (
<Link onClick={() => auth.signinRedirect()}>
Login Again
</Link>
),
});

auth.signoutSilent();
}
Expand Down
29 changes: 11 additions & 18 deletions src/Components/Auth/useAccessToken.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,25 @@
import { Link, Toast, ToastFooter, ToastTitle, useToastController } from "@fluentui/react-components";
import { Link } from "@fluentui/react-components";
import { useAuth } from "react-oidc-context";
import { useAppToast } from "~/Helpers/useAppToast";

/**
* @author Aloento
* @since 1.0.0
* @version 0.1.0
* @version 0.2.0
*/
export function useAccessToken() {
const auth = useAuth();
const { dispatchToast } = useToastController();
const toast = useAppToast();

function getToken() {
if (auth.user?.expired) {
dispatchToast(
<Toast>
<ToastTitle>
You're not logged in.
</ToastTitle>

<ToastFooter>
<Link
onClick={() => auth.signinRedirect()}>
Login
</Link>
</ToastFooter>
</Toast>,
{ intent: "warning" }
);
toast.showWarning("You're not logged in.", {
action: (
<Link onClick={() => auth.signinRedirect()}>
Login
</Link>
),
});

throw new Error("You're not logged in.");
}
Expand Down
96 changes: 96 additions & 0 deletions src/Components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { ScaleLoadingSpinner } from "@telekom/scale-components-react";
import { useCallback, useRef } from "react";
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";

/**
* Root-level error boundary fallback.
* Shown when an uncaught render error bubbles to the top of the app.
*/
function RootFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-zinc-50 p-8">
<div className="flex flex-col items-center gap-4 rounded-lg bg-white p-8 shadow-md max-w-md text-center">
<div className="text-red-500 text-5xl">!</div>
<h1 className="text-xl font-semibold text-gray-900">Something went wrong</h1>
<p className="text-sm text-gray-600 break-all">
{error instanceof Error ? error.message : "An unexpected error occurred."}
</p>
<div className="flex gap-3">
<button
onClick={resetErrorBoundary}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 cursor-pointer"
>
Reload
</button>
<a href="/" className="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
Return Home
</a>
</div>
</div>
</div>
);
}

/**
* Data-load error boundary fallback.
* Shown when API data fetching fails inside StatusContext.
* Offers Retry, View Cached (if cached data exists), and Return Home.
*/
function DataLoadFallback({ error, resetErrorBoundary }: FallbackProps) {
const retried = useRef(false);

const handleRetry = useCallback(() => {
if (!retried.current) {
retried.current = true;
resetErrorBoundary();
// Reset after a short delay to allow re-entry
setTimeout(() => { retried.current = false; }, 2000);
}
}, [resetErrorBoundary]);

return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-zinc-50 p-8">
<ScaleLoadingSpinner size="large" text="Loading data..." />
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-sm text-red-600">
{error instanceof Error ? error.message : "Failed to load data."}
</p>
<div className="flex gap-3">
<button
onClick={handleRetry}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 cursor-pointer"
>
Retry
</button>
<a href="/" className="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
Return Home
</a>
</div>
</div>
</div>
);
}

export interface ErrorBoundaryProps {
children: React.ReactNode;
/** Use the data-load variant (includes spinner + simpler UI) */
variant?: "root" | "data";
/** Called when the error boundary resets */
onReset?: () => void;
}

/**
* Thin wrapper around react-error-boundary providing two variants:
* - `root`: full-page "Something went wrong" (for App.tsx)
* - `data`: data-loading failure with retry (for StatusContext)
*/
export function AppErrorBoundary({ children, variant = "root", onReset }: ErrorBoundaryProps) {
return (
<ErrorBoundary
FallbackComponent={variant === "data" ? DataLoadFallback : RootFallback}
onReset={onReset}
>
{children}
</ErrorBoundary>
);
}
48 changes: 23 additions & 25 deletions src/Components/Event/useEditForm.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Toast, ToastBody, ToastTitle, useToastController } from "@fluentui/react-components";
import { useRequest } from "ahooks";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { fetchPlus } from "~/Helpers/fetchPlus";
import { useAppToast } from "~/Helpers/useAppToast";
import { useStatus } from "~/Services/Status";
import { StatusEnum } from "~/Services/Status.Entities";
import { Models } from "~/Services/Status.Models";
import { IStatusContext, Models } from "~/Services/Status.Models";
import { useAccessToken } from "../Auth/useAccessToken";
import { EventStatus, EventType, GetEventImpact, GetStatusString, IsIncident, IsOpenStatus } from "./Enums";

Expand Down Expand Up @@ -217,15 +218,19 @@ export function useEditForm(event: Models.IEvent) {
}, [start, end]);

const getToken = useAccessToken();
const { dispatchToast } = useToastController();
const toast = useAppToast();
const { DB, Update } = useStatus();
const snapshotRef = useRef<IStatusContext | null>(null);

const { runAsync, loading } = useRequest(async () => {
if (![setTitle(), setType(), setUpdate(), setDescription(), setContactEmail(), setStatus(), setStart(), setEnd(), setUpdateAt()].every(Boolean)) {
throw new Error("Validation failed.");
}
const url = process.env.SD_BACKEND_URL!;

// Save snapshot for rollback (deep copy to avoid shared array references)
snapshotRef.current = structuredClone(DB);

const body: Record<string, any> = {
title,
status: GetStatusString(status!),
Expand Down Expand Up @@ -262,26 +267,11 @@ export function useEditForm(event: Models.IEvent) {
}
}

const raw = await fetch(`${url}/v2/events/${event.Id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${getToken()}`
},
body: JSON.stringify(body)
});

if (!raw.ok) {
const message = await raw.text();
dispatchToast(
<Toast>
<ToastTitle>Failed to update event</ToastTitle>
<ToastBody>{message}</ToastBody>
</Toast>,
{ intent: "warning" }
);
throw new Error("Failed to update event: " + message);
}
await fetchPlus.patchJson(
`${url}/v2/events/${event.Id}`,
body,
{ token: getToken() }
);

const eventIndex = DB.Events.findIndex(e => e.Id === event.Id);
if (eventIndex !== -1) {
Expand Down Expand Up @@ -311,7 +301,15 @@ export function useEditForm(event: Models.IEvent) {
_setStatus(undefined);
_setUpdateAt(new Date());
}, {
manual: true
manual: true,
onError: (err) => {
// Rollback on failure
if (snapshotRef.current) {
Update(snapshotRef.current);
}
const message = err instanceof Error ? err.message : "An error occurred";
toast.showError("Failed to update event", { body: message });
},
});

return {
Expand Down
Loading
Loading