From 10ea95678b3928bdfc44f72903b446a21fd9daf1 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 12 Feb 2026 00:55:04 +0530 Subject: [PATCH] feat: implement global loading indicators and refactor activity feed tests --- frontend/cypress/e2e/activity_feed.cy.ts | 6 ++-- frontend/cypress/e2e/clerk_login.cy.ts | 3 +- frontend/cypress/e2e/organizations.cy.ts | 3 +- frontend/cypress/support/commands.ts | 17 ++++++++++++ frontend/src/app/layout.tsx | 6 +++- frontend/src/app/loading.tsx | 13 +++++++++ frontend/src/components/ui/global-loader.tsx | 29 ++++++++++++++++++++ 7 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/loading.tsx create mode 100644 frontend/src/components/ui/global-loader.tsx diff --git a/frontend/cypress/e2e/activity_feed.cy.ts b/frontend/cypress/e2e/activity_feed.cy.ts index ff266bb1..93208651 100644 --- a/frontend/cypress/e2e/activity_feed.cy.ts +++ b/frontend/cypress/e2e/activity_feed.cy.ts @@ -31,7 +31,8 @@ describe("/activity feed", () => { } function assertSignedInAndLanded() { - cy.contains(/live feed/i, { timeout: 30_000 }).should("be.visible"); + cy.waitForAppLoaded(); + cy.contains(/live feed/i).should("be.visible"); } it("auth negative: signed-out user cannot access /activity", () => { @@ -70,7 +71,6 @@ describe("/activity feed", () => { cy.visit("/activity"); assertSignedInAndLanded(); - cy.wait("@activityList"); cy.contains("CI hardening").should("be.visible"); cy.contains("Hello world").should("be.visible"); }); @@ -91,7 +91,6 @@ describe("/activity feed", () => { cy.visit("/activity"); assertSignedInAndLanded(); - cy.wait("@activityList"); cy.contains(/waiting for new comments/i).should("be.visible"); }); @@ -111,7 +110,6 @@ describe("/activity feed", () => { cy.visit("/activity"); assertSignedInAndLanded(); - cy.wait("@activityList"); cy.contains(/unable to load feed|boom/i).should("be.visible"); }); }); diff --git a/frontend/cypress/e2e/clerk_login.cy.ts b/frontend/cypress/e2e/clerk_login.cy.ts index 8b091d0d..2f928ac0 100644 --- a/frontend/cypress/e2e/clerk_login.cy.ts +++ b/frontend/cypress/e2e/clerk_login.cy.ts @@ -10,6 +10,7 @@ describe("Clerk login", () => { // After login, user should be able to access protected route. cy.visit("/activity"); - cy.contains(/live feed/i, { timeout: 30_000 }).should("be.visible"); + cy.waitForAppLoaded(); + cy.contains(/live feed/i).should("be.visible"); }); }); diff --git a/frontend/cypress/e2e/organizations.cy.ts b/frontend/cypress/e2e/organizations.cy.ts index 1af5983d..c2ab5303 100644 --- a/frontend/cypress/e2e/organizations.cy.ts +++ b/frontend/cypress/e2e/organizations.cy.ts @@ -14,7 +14,8 @@ describe("Organizations (PR #61)", () => { cy.clerkSignIn({ strategy: "email_code", identifier: email }); cy.visit("/organization"); - cy.contains(/members\s*&\s*invites/i, { timeout: 30_000 }).should("be.visible"); + cy.waitForAppLoaded(); + cy.contains(/members\s*&\s*invites/i).should("be.visible"); // Deterministic assertion across roles: // - if user is admin: invite button enabled diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 28b51cf8..0848d34a 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -6,6 +6,8 @@ type ClerkOtpLoginOptions = { otp: string; }; +const APP_LOAD_TIMEOUT_MS = 30_000; + function getEnv(name: string, fallback?: string): string { const value = Cypress.env(name) as string | undefined; if (value) return value; @@ -40,6 +42,16 @@ function normalizeOrigin(value: string): string { } } +Cypress.Commands.add("waitForAppLoaded", () => { + cy.get("[data-cy='route-loader']", { + timeout: APP_LOAD_TIMEOUT_MS, + }).should("not.exist"); + + cy.get("[data-cy='global-loader']", { + timeout: APP_LOAD_TIMEOUT_MS, + }).should("have.attr", "aria-hidden", "true"); +}); + Cypress.Commands.add("loginWithClerkOtp", () => { const clerkOrigin = normalizeOrigin( getEnv("CLERK_ORIGIN", clerkOriginFromPublishableKey()), @@ -194,6 +206,11 @@ declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { interface Chainable { + /** + * Waits for route-level and global app loaders to disappear. + */ + waitForAppLoaded(): Chainable; + /** * Logs in via the real Clerk SignIn page using deterministic OTP credentials. * diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 12028ad9..483aef06 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -7,6 +7,7 @@ import { DM_Serif_Display, IBM_Plex_Sans, Sora } from "next/font/google"; import { AuthProvider } from "@/components/providers/AuthProvider"; import { QueryProvider } from "@/components/providers/QueryProvider"; +import { GlobalLoader } from "@/components/ui/global-loader"; export const metadata: Metadata = { title: "OpenClaw Mission Control", @@ -41,7 +42,10 @@ export default function RootLayout({ children }: { children: ReactNode }) { className={`${bodyFont.variable} ${headingFont.variable} ${displayFont.variable} min-h-screen bg-app text-strong antialiased`} > - {children} + + + {children} + diff --git a/frontend/src/app/loading.tsx b/frontend/src/app/loading.tsx new file mode 100644 index 00000000..7cd3c53c --- /dev/null +++ b/frontend/src/app/loading.tsx @@ -0,0 +1,13 @@ +export default function Loading() { + return ( +
+
+
+

Loading mission control...

+
+
+ ); +} diff --git a/frontend/src/components/ui/global-loader.tsx b/frontend/src/components/ui/global-loader.tsx new file mode 100644 index 00000000..1c01bd23 --- /dev/null +++ b/frontend/src/components/ui/global-loader.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useIsFetching, useIsMutating } from "@tanstack/react-query"; + +export function GlobalLoader() { + const fetchingCount = useIsFetching({ + predicate: (query) => + query.state.fetchStatus === "fetching" && query.state.data === undefined, + }); + const mutatingCount = useIsMutating(); + const visible = fetchingCount + mutatingCount > 0; + + return ( +
+
+
+
+ Loading +
+ ); +}