diff --git a/dashboard/frontend/e2e/app.spec.ts b/dashboard/frontend/e2e/app.spec.ts
index e433671..dce7ebe 100644
--- a/dashboard/frontend/e2e/app.spec.ts
+++ b/dashboard/frontend/e2e/app.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from '@playwright/test';
+import { test, expect } from "@playwright/test";
// Card 4eb58505: no hardcoded external targets.
// - Frontend navigations are relative and resolve against the playwright
@@ -17,29 +17,28 @@ import { test, expect } from '@playwright/test';
// - E2E_ENV=production flips the docs test to expect 404: the backend
// disables /api/v1/docs when settings.is_production (main.py, PR #147),
// and asserting that response is the point of the test in prod.
-const BACKEND_URL = process.env.E2E_API_URL ?? '';
-const hasApi = BACKEND_URL !== '';
+const BACKEND_URL = process.env.E2E_API_URL ?? "";
+const hasApi = BACKEND_URL !== "";
-const E2E_USERNAME = process.env.E2E_AUTH_USERNAME ?? '';
-const E2E_PASSWORD = process.env.E2E_AUTH_PASSWORD ?? '';
-const hasAuth = E2E_USERNAME !== '' && E2E_PASSWORD !== '';
+const E2E_USERNAME = process.env.E2E_AUTH_USERNAME ?? "";
+const E2E_PASSWORD = process.env.E2E_AUTH_PASSWORD ?? "";
+const hasAuth = E2E_USERNAME !== "" && E2E_PASSWORD !== "";
const AUTH_SKIP_REASON =
- 'E2E_AUTH_USERNAME / E2E_AUTH_PASSWORD not set — configure them (CI secrets or local env) to run login tests against this environment';
+ "E2E_AUTH_USERNAME / E2E_AUTH_PASSWORD not set — configure them (CI secrets or local env) to run login tests against this environment";
-const IS_PRODUCTION = (process.env.E2E_ENV ?? '') === 'production';
+const IS_PRODUCTION = (process.env.E2E_ENV ?? "") === "production";
-test.describe('QA-FRAMEWORK E2E Tests', () => {
-
- test('Backend health check', async ({ request }) => {
- test.skip(!hasApi, 'E2E_API_URL not set — requires a deployed backend');
+test.describe("QA-FRAMEWORK E2E Tests", () => {
+ test("Backend health check", async ({ request }) => {
+ test.skip(!hasApi, "E2E_API_URL not set — requires a deployed backend");
const response = await request.get(`${BACKEND_URL}/health`);
expect(response.ok()).toBeTruthy();
const data = await response.json();
- expect(data.status).toBe('healthy');
+ expect(data.status).toBe("healthy");
});
- test('Backend API docs accessible', async ({ request }) => {
- test.skip(!hasApi, 'E2E_API_URL not set — requires a deployed backend');
+ test("Backend API docs accessible", async ({ request }) => {
+ test.skip(!hasApi, "E2E_API_URL not set — requires a deployed backend");
const response = await request.get(`${BACKEND_URL}/api/v1/docs`);
if (IS_PRODUCTION) {
// Production deliberately disables the docs endpoints
@@ -50,60 +49,63 @@ test.describe('QA-FRAMEWORK E2E Tests', () => {
}
});
- test('Login API works', async ({ request }) => {
- test.skip(!hasApi, 'E2E_API_URL not set — requires a deployed backend');
+ test("Login API works", async ({ request }) => {
+ test.skip(!hasApi, "E2E_API_URL not set — requires a deployed backend");
test.skip(!hasAuth, AUTH_SKIP_REASON);
const response = await request.post(`${BACKEND_URL}/api/v1/auth/login`, {
- headers: { 'Content-Type': 'application/json' },
- data: { username: E2E_USERNAME, password: E2E_PASSWORD }
+ headers: { "Content-Type": "application/json" },
+ data: { username: E2E_USERNAME, password: E2E_PASSWORD },
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data.access_token).toBeDefined();
- expect(data.token_type).toBe('bearer');
+ expect(data.token_type).toBe("bearer");
});
- test('Get user info with token', async ({ request }) => {
- test.skip(!hasApi, 'E2E_API_URL not set — requires a deployed backend');
+ test("Get user info with token", async ({ request }) => {
+ test.skip(!hasApi, "E2E_API_URL not set — requires a deployed backend");
test.skip(!hasAuth, AUTH_SKIP_REASON);
// First login
- const loginResponse = await request.post(`${BACKEND_URL}/api/v1/auth/login`, {
- headers: { 'Content-Type': 'application/json' },
- data: { username: E2E_USERNAME, password: E2E_PASSWORD }
- });
+ const loginResponse = await request.post(
+ `${BACKEND_URL}/api/v1/auth/login`,
+ {
+ headers: { "Content-Type": "application/json" },
+ data: { username: E2E_USERNAME, password: E2E_PASSWORD },
+ },
+ );
const loginData = await loginResponse.json();
const token = loginData.access_token;
// Get user info
const meResponse = await request.get(`${BACKEND_URL}/api/v1/me`, {
- headers: { 'Authorization': `Bearer ${token}` }
+ headers: { Authorization: `Bearer ${token}` },
});
expect(meResponse.ok()).toBeTruthy();
const userData = await meResponse.json();
expect(userData.username).toBe(E2E_USERNAME);
});
- test('Frontend loads', async ({ page }) => {
- await page.goto('/');
- await expect(page.locator('body')).toBeVisible();
+ test("Frontend loads", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.locator("body")).toBeVisible();
});
- test('Login page displays correctly', async ({ page }) => {
- await page.goto('/login');
+ test("Login page displays correctly", async ({ page }) => {
+ await page.goto("/login");
// Wait for page to load
- await page.waitForLoadState('networkidle');
+ await page.waitForLoadState("networkidle");
// Check for login form elements
- const usernameInput = page.locator('input').first();
+ const usernameInput = page.locator("input").first();
await expect(usernameInput).toBeVisible();
});
- test('Full login flow', async ({ page }) => {
+ test("Full login flow", async ({ page }) => {
test.skip(!hasAuth, AUTH_SKIP_REASON);
- await page.goto('/login');
- await page.waitForLoadState('networkidle');
+ await page.goto("/login");
+ await page.waitForLoadState("networkidle");
// Fill login form using placeholder or type
- const inputs = page.locator('input');
+ const inputs = page.locator("input");
await inputs.nth(0).fill(E2E_USERNAME);
await inputs.nth(1).fill(E2E_PASSWORD);
@@ -119,8 +121,8 @@ test.describe('QA-FRAMEWORK E2E Tests', () => {
expect(url).toBeDefined();
});
- test('Billing plans accessible without auth', async ({ request }) => {
- test.skip(!hasApi, 'E2E_API_URL not set — requires a deployed backend');
+ test("Billing plans accessible without auth", async ({ request }) => {
+ test.skip(!hasApi, "E2E_API_URL not set — requires a deployed backend");
const response = await request.get(`${BACKEND_URL}/api/v1/billing/plans`);
expect(response.ok()).toBeTruthy();
const data = await response.json();
diff --git a/dashboard/frontend/e2e/smoke.spec.ts b/dashboard/frontend/e2e/smoke.spec.ts
index 8306c39..74ba1bc 100644
--- a/dashboard/frontend/e2e/smoke.spec.ts
+++ b/dashboard/frontend/e2e/smoke.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from '@playwright/test';
+import { test, expect } from "@playwright/test";
/**
* Regression smoke suite (card 4eb58505, from QA's PR #212 local smoke).
@@ -8,46 +8,64 @@ import { test, expect } from '@playwright/test';
* Catches runtime breakage of the majors migration in a real browser:
* uncaught exceptions, broken public routes, broken auth redirect.
*/
-test.describe('Smoke regression (post-migration)', () => {
- test('Landing renders with content', async ({ page }) => {
+test.describe("Smoke regression (post-migration)", () => {
+ test("Landing renders with content", async ({ page }) => {
const errors: string[] = [];
- page.on('pageerror', (e) => errors.push(String(e)));
- await page.goto('/');
- await expect(page.locator('body')).toBeVisible();
- await expect(page.locator('main, [role="main"], h1, h2').first()).toBeVisible();
- expect(errors, `uncaught JS errors on /: ${errors.join(' | ')}`).toEqual([]);
+ page.on("pageerror", (e) => errors.push(String(e)));
+ await page.goto("/");
+ await expect(page.locator("body")).toBeVisible();
+ await expect(
+ page.locator('main, [role="main"], h1, h2').first(),
+ ).toBeVisible();
+ expect(errors, `uncaught JS errors on /: ${errors.join(" | ")}`).toEqual(
+ [],
+ );
});
- test('Login renders form (2 inputs + submit)', async ({ page }) => {
+ test("Login renders form (2 inputs + submit)", async ({ page }) => {
const errors: string[] = [];
- page.on('pageerror', (e) => errors.push(String(e)));
- await page.goto('/login');
- await expect(page.locator('input').first()).toBeVisible();
- const inputs = page.locator('input');
+ page.on("pageerror", (e) => errors.push(String(e)));
+ await page.goto("/login");
+ await expect(page.locator("input").first()).toBeVisible();
+ const inputs = page.locator("input");
expect(await inputs.count()).toBeGreaterThanOrEqual(2);
- await expect(page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign")').first()).toBeVisible();
- expect(errors, `uncaught JS errors on /login: ${errors.join(' | ')}`).toEqual([]);
+ await expect(
+ page
+ .locator(
+ 'button[type="submit"], button:has-text("Login"), button:has-text("Sign")',
+ )
+ .first(),
+ ).toBeVisible();
+ expect(
+ errors,
+ `uncaught JS errors on /login: ${errors.join(" | ")}`,
+ ).toEqual([]);
});
- test('Register renders form', async ({ page }) => {
+ test("Register renders form", async ({ page }) => {
const errors: string[] = [];
- page.on('pageerror', (e) => errors.push(String(e)));
- await page.goto('/register');
- await expect(page.locator('input').first()).toBeVisible();
- expect(await page.locator('input').count()).toBeGreaterThanOrEqual(2);
- expect(errors, `uncaught JS errors on /register: ${errors.join(' | ')}`).toEqual([]);
+ page.on("pageerror", (e) => errors.push(String(e)));
+ await page.goto("/register");
+ await expect(page.locator("input").first()).toBeVisible();
+ expect(await page.locator("input").count()).toBeGreaterThanOrEqual(2);
+ expect(
+ errors,
+ `uncaught JS errors on /register: ${errors.join(" | ")}`,
+ ).toEqual([]);
});
- test('Unauthenticated /dashboard redirects to /login (RR7)', async ({ page }) => {
- await page.goto('/dashboard');
+ test("Unauthenticated /dashboard redirects to /login (RR7)", async ({
+ page,
+ }) => {
+ await page.goto("/dashboard");
await page.waitForURL(/\/login/, { timeout: 10_000 });
- expect(page.url()).toContain('/login');
+ expect(page.url()).toContain("/login");
});
- test('No 404/500 on unknown route (NotFound page)', async ({ page }) => {
+ test("No 404/500 on unknown route (NotFound page)", async ({ page }) => {
const errors: string[] = [];
- page.on('pageerror', (e) => errors.push(String(e)));
- const resp = await page.goto('/definitely-not-a-route');
+ page.on("pageerror", (e) => errors.push(String(e)));
+ const resp = await page.goto("/definitely-not-a-route");
expect(resp?.status()).toBeLessThan(500);
expect(errors).toEqual([]);
});
@@ -55,13 +73,19 @@ test.describe('Smoke regression (post-migration)', () => {
// Visual evidence for the codemod that removed margin="normal" from
// TextField (PR #212, reviewer 9a74c5b6): full-page screenshots are
// uploaded as CI artifacts from every run.
- test('Visual spot-check: Login & Register screenshots', async ({ page }) => {
- await page.goto('/login');
- await expect(page.locator('input').first()).toBeVisible();
- await page.screenshot({ path: 'test-results/spotcheck-login.png', fullPage: true });
+ test("Visual spot-check: Login & Register screenshots", async ({ page }) => {
+ await page.goto("/login");
+ await expect(page.locator("input").first()).toBeVisible();
+ await page.screenshot({
+ path: "test-results/spotcheck-login.png",
+ fullPage: true,
+ });
- await page.goto('/register');
- await expect(page.locator('input').first()).toBeVisible();
- await page.screenshot({ path: 'test-results/spotcheck-register.png', fullPage: true });
+ await page.goto("/register");
+ await expect(page.locator("input").first()).toBeVisible();
+ await page.screenshot({
+ path: "test-results/spotcheck-register.png",
+ fullPage: true,
+ });
});
});
diff --git a/dashboard/frontend/playwright.config.ts b/dashboard/frontend/playwright.config.ts
index a3945f5..c609efd 100644
--- a/dashboard/frontend/playwright.config.ts
+++ b/dashboard/frontend/playwright.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig, devices } from '@playwright/test';
+import { defineConfig, devices } from "@playwright/test";
// Card 4eb58505: E2E targets are env-driven — zero hardcoded external stacks.
// - CI (PR): pr-deploy-coolify.yml injects E2E_BASE_URL pointing at the
@@ -10,30 +10,30 @@ import { defineConfig, devices } from '@playwright/test';
const E2E_BASE_URL = process.env.E2E_BASE_URL;
export default defineConfig({
- testDir: './e2e',
+ testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
- reporter: 'html',
+ reporter: "html",
use: {
- baseURL: E2E_BASE_URL ?? 'http://localhost:4173',
- trace: 'on-first-retry',
- screenshot: 'only-on-failure',
+ baseURL: E2E_BASE_URL ?? "http://localhost:4173",
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
// no `video`: requires the ffmpeg playwright binary — not worth the CI
// dependency for this suite (screenshots + trace carry the evidence).
},
projects: [
{
- name: 'chromium',
- use: { ...devices['Desktop Chrome'] },
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
},
],
webServer: E2E_BASE_URL
? undefined
: {
- command: 'npm run build && npm run preview -- --port 4173 --strictPort',
- url: 'http://localhost:4173',
+ command: "npm run build && npm run preview -- --port 4173 --strictPort",
+ url: "http://localhost:4173",
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
diff --git a/dashboard/frontend/src/__tests__/routes.smoke.test.tsx b/dashboard/frontend/src/__tests__/routes.smoke.test.tsx
index d520d92..4c0c6d3 100644
--- a/dashboard/frontend/src/__tests__/routes.smoke.test.tsx
+++ b/dashboard/frontend/src/__tests__/routes.smoke.test.tsx
@@ -1,46 +1,49 @@
-import { describe, it, expect } from 'vitest'
-import { render } from '@testing-library/react'
-import { QueryClient, QueryClientProvider } from 'react-query'
-import App from '../App'
+import { describe, it, expect } from "vitest";
+import { render } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "react-query";
+import App from "../App";
-const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
-const renderApp = () => render(
-
-
-
-)
+const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+});
+const renderApp = () =>
+ render(
+
+
+ ,
+ );
/**
* Route smoke tests for the MUI 5 -> 9 / react-router 6 -> 7 migration.
* Verifies that public routes still render their pages after the upgrade.
*/
-describe('Route smoke (post-migration)', () => {
- it('renders Landing at /', () => {
- window.history.pushState({}, '', '/')
- const { unmount } = renderApp()
- expect(document.body.innerHTML.length).toBeGreaterThan(0)
- unmount()
- })
+describe("Route smoke (post-migration)", () => {
+ it("renders Landing at /", () => {
+ window.history.pushState({}, "", "/");
+ const { unmount } = renderApp();
+ expect(document.body.innerHTML.length).toBeGreaterThan(0);
+ unmount();
+ });
- it('renders Login at /login', () => {
- window.history.pushState({}, '', '/login')
- const { unmount } = renderApp()
- expect(document.body.innerHTML.length).toBeGreaterThan(0)
- unmount()
- })
+ it("renders Login at /login", () => {
+ window.history.pushState({}, "", "/login");
+ const { unmount } = renderApp();
+ expect(document.body.innerHTML.length).toBeGreaterThan(0);
+ unmount();
+ });
- it('renders Register at /register', () => {
- window.history.pushState({}, '', '/register')
- const { unmount } = renderApp()
- expect(document.body.innerHTML.length).toBeGreaterThan(0)
- unmount()
- })
+ it("renders Register at /register", () => {
+ window.history.pushState({}, "", "/register");
+ const { unmount } = renderApp();
+ expect(document.body.innerHTML.length).toBeGreaterThan(0);
+ unmount();
+ });
- it('unauthenticated users are redirected away from /dashboard', () => {
- window.history.pushState({}, '', '/dashboard')
- const { unmount } = renderApp()
+ it("unauthenticated users are redirected away from /dashboard", () => {
+ window.history.pushState({}, "", "/dashboard");
+ const { unmount } = renderApp();
// ProtectedRoute redirects to /login when not authenticated
- expect(window.location.pathname).toBe('/login')
- unmount()
- })
-})
+ expect(window.location.pathname).toBe("/login");
+ unmount();
+ });
+});
diff --git a/dashboard/frontend/src/components/ErrorBoundary.tsx b/dashboard/frontend/src/components/ErrorBoundary.tsx
index 0f56543..3249d8c 100644
--- a/dashboard/frontend/src/components/ErrorBoundary.tsx
+++ b/dashboard/frontend/src/components/ErrorBoundary.tsx
@@ -1,49 +1,51 @@
-import React, { Component, ErrorInfo, ReactNode } from 'react'
-import { Box, Typography, Button } from '@mui/material'
+import React, { Component, ErrorInfo, ReactNode } from "react";
+import { Box, Typography, Button } from "@mui/material";
interface Props {
- children: ReactNode
+ children: ReactNode;
}
interface State {
- hasError: boolean
- error?: Error
+ hasError: boolean;
+ error?: Error;
}
class ErrorBoundary extends Component {
public state: State = {
- hasError: false
- }
+ hasError: false,
+ };
public static getDerivedStateFromError(error: Error): State {
- return { hasError: true, error }
+ return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
- console.error('Error caught by boundary:', error, errorInfo)
+ console.error("Error caught by boundary:", error, errorInfo);
}
private handleReset = () => {
- this.setState({ hasError: false, error: undefined })
- window.location.href = '/'
- }
+ this.setState({ hasError: false, error: undefined });
+ window.location.href = "/";
+ };
public render() {
if (this.state.hasError) {
return (
Something went wrong
- {this.state.error?.message || 'An unexpected error occurred'}
+ {this.state.error?.message || "An unexpected error occurred"}
- )
+ );
}
- return this.props.children
+ return this.props.children;
}
}
-export default ErrorBoundary
\ No newline at end of file
+export default ErrorBoundary;
diff --git a/dashboard/frontend/src/components/OnboardingWizard.tsx b/dashboard/frontend/src/components/OnboardingWizard.tsx
index ea98c27..cd4a406 100644
--- a/dashboard/frontend/src/components/OnboardingWizard.tsx
+++ b/dashboard/frontend/src/components/OnboardingWizard.tsx
@@ -13,8 +13,8 @@
* State persists across page refreshes.
*/
-import React, { useState, useEffect, useCallback } from 'react'
-import { useNavigate } from 'react-router-dom'
+import React, { useState, useEffect, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
import {
Stepper,
Step,
@@ -28,7 +28,7 @@ import {
Alert,
IconButton,
Tooltip,
-} from '@mui/material'
+} from "@mui/material";
import {
Check as CheckIcon,
Close as CloseIcon,
@@ -38,124 +38,142 @@ import {
RocketLaunch as RocketIcon,
Assignment as SuiteIcon,
SkipNext as SkipNextIcon,
-} from '@mui/icons-material'
-import { useMutation, useQuery } from 'react-query'
-import { onboardingAPI, suitesAPI, executionsAPI } from '../api/client'
-import useAuthStore from '../stores/authStore'
-import toast from 'react-hot-toast'
+} from "@mui/icons-material";
+import { useMutation, useQuery } from "react-query";
+import { onboardingAPI, suitesAPI, executionsAPI } from "../api/client";
+import useAuthStore from "../stores/authStore";
+import toast from "react-hot-toast";
const STEPS = [
- { key: 'welcome', label: 'Welcome', icon: },
- { key: 'connect_repo', label: 'Connect Repo', icon: },
- { key: 'create_suite', label: 'Create Suite', icon: },
- { key: 'run_test', label: 'Run Test', icon: },
- { key: 'setup_notifications', label: 'Notifications', icon: },
-]
+ { key: "welcome", label: "Welcome", icon: },
+ { key: "connect_repo", label: "Connect Repo", icon: },
+ { key: "create_suite", label: "Create Suite", icon: },
+ { key: "run_test", label: "Run Test", icon: },
+ {
+ key: "setup_notifications",
+ label: "Notifications",
+ icon: ,
+ },
+];
interface OnboardingWizardProps {
- onComplete: () => void
+ onComplete: () => void;
}
-export const OnboardingWizard: React.FC = ({ onComplete }) => {
- const [activeStep, setActiveStep] = useState(0)
- const [completedSteps, setCompletedSteps] = useState>({})
- const navigate = useNavigate()
- const { setNeedsOnboarding } = useAuthStore()
+export const OnboardingWizard: React.FC = ({
+ onComplete,
+}) => {
+ const [activeStep, setActiveStep] = useState(0);
+ const [completedSteps, setCompletedSteps] = useState>(
+ {},
+ );
+ const navigate = useNavigate();
+ const { setNeedsOnboarding } = useAuthStore();
// Fetch current onboarding state from backend
const { data: onboardingState, isLoading } = useQuery(
- 'onboarding-state',
+ "onboarding-state",
() => onboardingAPI.getState(),
{
onSuccess: (response) => {
- const state = response.data
+ const state = response.data;
if (state.completed) {
- onComplete()
- return
+ onComplete();
+ return;
}
- setActiveStep(state.current_step || 0)
+ setActiveStep(state.current_step || 0);
if (state.steps) {
- setCompletedSteps(state.steps)
+ setCompletedSteps(state.steps);
}
},
onError: () => {
- toast.error('Failed to load onboarding state')
+ toast.error("Failed to load onboarding state");
},
- }
- )
+ },
+ );
// Mutation to update a step
const stepMutation = useMutation(
(stepName: string) => onboardingAPI.updateStep(stepName, true),
{
onSuccess: (_, stepName) => {
- setCompletedSteps((prev) => ({ ...prev, [stepName]: true }))
+ setCompletedSteps((prev) => ({ ...prev, [stepName]: true }));
// Auto-advance to next incomplete step
- const currentIndex = STEPS.findIndex((s) => s.key === stepName)
- let nextStep = currentIndex + 1
+ const currentIndex = STEPS.findIndex((s) => s.key === stepName);
+ let nextStep = currentIndex + 1;
while (nextStep < STEPS.length && completedSteps[STEPS[nextStep].key]) {
- nextStep++
+ nextStep++;
}
if (nextStep < STEPS.length) {
- setActiveStep(nextStep)
+ setActiveStep(nextStep);
} else {
// All steps done, complete onboarding
- completeMutation.mutate()
+ completeMutation.mutate();
}
- toast.success(`Step "${STEPS.find(s => s.key === stepName)?.label}" completed!`)
+ toast.success(
+ `Step "${STEPS.find((s) => s.key === stepName)?.label}" completed!`,
+ );
},
onError: () => {
- toast.error('Failed to save progress')
+ toast.error("Failed to save progress");
},
- }
- )
+ },
+ );
// Mutation to complete onboarding
- const completeMutation = useMutation(
- () => onboardingAPI.complete(),
- {
- onSuccess: () => {
- setNeedsOnboarding(false)
- toast.success('Onboarding complete! Welcome aboard! 🎉')
- onComplete()
- },
- onError: () => {
- toast.error('Failed to complete onboarding')
- },
- }
- )
+ const completeMutation = useMutation(() => onboardingAPI.complete(), {
+ onSuccess: () => {
+ setNeedsOnboarding(false);
+ toast.success("Onboarding complete! Welcome aboard! 🎉");
+ onComplete();
+ },
+ onError: () => {
+ toast.error("Failed to complete onboarding");
+ },
+ });
// Mutation to skip onboarding
- const skipMutation = useMutation(
- () => onboardingAPI.skip(),
- {
- onSuccess: () => {
- setNeedsOnboarding(false)
- toast.success('Onboarding skipped. You can find help in Settings.')
- onComplete()
- },
- onError: () => {
- toast.error('Failed to skip onboarding')
- },
- }
- )
+ const skipMutation = useMutation(() => onboardingAPI.skip(), {
+ onSuccess: () => {
+ setNeedsOnboarding(false);
+ toast.success("Onboarding skipped. You can find help in Settings.");
+ onComplete();
+ },
+ onError: () => {
+ toast.error("Failed to skip onboarding");
+ },
+ });
const handleSkip = useCallback(() => {
- skipMutation.mutate()
- }, [skipMutation])
+ skipMutation.mutate();
+ }, [skipMutation]);
if (isLoading) {
return (
-
+
- )
+ );
}
return (
-
+
{/* Header with skip option */}
-
+
Welcome to QA-FRAMEWORK 🚀
@@ -173,25 +191,29 @@ export const OnboardingWizard: React.FC = ({ onComplete }
slotProps={{
stepIcon: {
component: () => (
-
- {completedSteps[step.key] ? : step.icon}
-
+
+ {completedSteps[step.key] ? (
+
+ ) : (
+ step.icon
+ )}
+
),
},
}}
@@ -206,12 +228,10 @@ export const OnboardingWizard: React.FC = ({ onComplete }
{activeStep < STEPS.length ? (
<>
- {renderStepContent(
- activeStep,
- stepMutation,
- completedSteps,
- )}
-
+ {renderStepContent(activeStep, stepMutation, completedSteps)}
+
>
) : (
-
+
🎉 Setup Complete!
@@ -252,8 +276,8 @@ export const OnboardingWizard: React.FC = ({ onComplete }
- )
-}
+ );
+};
// --- Step Components ---
@@ -264,17 +288,21 @@ function renderStepContent(
) {
switch (step) {
case 0:
- return
+ return ;
case 1:
- return
+ return ;
case 2:
- return stepMutation.mutate('create_suite')} />
+ return (
+ stepMutation.mutate("create_suite")}
+ />
+ );
case 3:
- return stepMutation.mutate('run_test')} />
+ return stepMutation.mutate("run_test")} />;
case 4:
- return
+ return ;
default:
- return null
+ return null;
}
}
@@ -287,25 +315,55 @@ const WelcomeStep: React.FC = () => (
In just a few steps, you'll have your first test suite up and running.
Here's what we'll set up:
-
+
{[
- { icon: , title: 'Connect Repository', desc: 'Link your GitHub repo for CI/CD integration' },
- { icon: , title: 'Create Test Suite', desc: 'Set up your first automated test collection' },
- { icon: , title: 'Run First Test', desc: 'Execute a test and see the results' },
- { icon: , title: 'Notifications', desc: 'Configure alerts for test results' },
+ {
+ icon: ,
+ title: "Connect Repository",
+ desc: "Link your GitHub repo for CI/CD integration",
+ },
+ {
+ icon: ,
+ title: "Create Test Suite",
+ desc: "Set up your first automated test collection",
+ },
+ {
+ icon: ,
+ title: "Run First Test",
+ desc: "Execute a test and see the results",
+ },
+ {
+ icon: ,
+ title: "Notifications",
+ desc: "Configure alerts for test results",
+ },
].map((item) => (
-
+
{item.icon}
- {item.title}
- {item.desc}
+
+ {item.title}
+
+
+ {item.desc}
+
))}
-)
+);
const ConnectRepoStep: React.FC = () => {
- const [repoUrl, setRepoUrl] = useState('')
+ const [repoUrl, setRepoUrl] = useState("");
return (
@@ -313,8 +371,8 @@ const ConnectRepoStep: React.FC = () => {
Connect Your Repository
- Enter your GitHub repository URL to enable CI/CD integration.
- You can also connect via OAuth later in Settings.
+ Enter your GitHub repository URL to enable CI/CD integration. You can
+ also connect via OAuth later in Settings.
@@ -323,49 +381,62 @@ const ConnectRepoStep: React.FC = () => {
type="text"
placeholder="https://github.com/username/repo"
value={repoUrl}
- onChange={(e: React.ChangeEvent) => setRepoUrl(e.target.value)}
+ onChange={(e: React.ChangeEvent) =>
+ setRepoUrl(e.target.value)
+ }
sx={{
- width: '100%',
+ width: "100%",
p: 2,
- fontSize: '1rem',
- border: '1px solid',
- borderColor: 'divider',
+ fontSize: "1rem",
+ border: "1px solid",
+ borderColor: "divider",
borderRadius: 1,
- bgcolor: 'background.paper',
- outline: 'none',
- '&:focus': { borderColor: 'primary.main', boxShadow: '0 0 0 2px rgba(25, 118, 210, 0.2)' },
+ bgcolor: "background.paper",
+ outline: "none",
+ "&:focus": {
+ borderColor: "primary.main",
+ boxShadow: "0 0 0 2px rgba(25, 118, 210, 0.2)",
+ },
}}
/>
- This step is optional. You can connect a repository later from the Integrations page.
+ This step is optional. You can connect a repository later from the
+ Integrations page.
- )
-}
+ );
+};
-const CreateSuiteStep: React.FC<{ onComplete: () => void }> = ({ onComplete }) => {
- const [suiteName, setSuiteName] = useState('My First Test Suite')
- const [suiteDesc, setSuiteDesc] = useState('Automated smoke tests for critical paths')
+const CreateSuiteStep: React.FC<{ onComplete: () => void }> = ({
+ onComplete,
+}) => {
+ const [suiteName, setSuiteName] = useState("My First Test Suite");
+ const [suiteDesc, setSuiteDesc] = useState(
+ "Automated smoke tests for critical paths",
+ );
const createMutation = useMutation(
- () => suitesAPI.create({
- name: suiteName,
- description: suiteDesc,
- framework_type: 'pytest',
- config: {},
- }),
+ () =>
+ suitesAPI.create({
+ name: suiteName,
+ description: suiteDesc,
+ framework_type: "pytest",
+ config: {},
+ }),
{
onSuccess: () => {
- toast.success('Test suite created!')
- onComplete()
+ toast.success("Test suite created!");
+ onComplete();
},
onError: (error: any) => {
- toast.error(error.response?.data?.detail || 'Failed to create test suite')
+ toast.error(
+ error.response?.data?.detail || "Failed to create test suite",
+ );
},
- }
- )
+ },
+ );
return (
@@ -381,37 +452,41 @@ const CreateSuiteStep: React.FC<{ onComplete: () => void }> = ({ onComplete }) =
type="text"
placeholder="Suite name"
value={suiteName}
- onChange={(e: React.ChangeEvent) => setSuiteName(e.target.value)}
+ onChange={(e: React.ChangeEvent) =>
+ setSuiteName(e.target.value)
+ }
sx={{
- width: '100%',
+ width: "100%",
p: 2,
mb: 2,
- fontSize: '1rem',
- border: '1px solid',
- borderColor: 'divider',
+ fontSize: "1rem",
+ border: "1px solid",
+ borderColor: "divider",
borderRadius: 1,
- bgcolor: 'background.paper',
- outline: 'none',
- '&:focus': { borderColor: 'primary.main' },
+ bgcolor: "background.paper",
+ outline: "none",
+ "&:focus": { borderColor: "primary.main" },
}}
/>
) => setSuiteDesc(e.target.value)}
+ onChange={(e: React.ChangeEvent) =>
+ setSuiteDesc(e.target.value)
+ }
rows={3}
sx={{
- width: '100%',
+ width: "100%",
p: 2,
- fontSize: '1rem',
- border: '1px solid',
- borderColor: 'divider',
+ fontSize: "1rem",
+ border: "1px solid",
+ borderColor: "divider",
borderRadius: 1,
- bgcolor: 'background.paper',
- outline: 'none',
- resize: 'vertical',
- '&:focus': { borderColor: 'primary.main' },
+ bgcolor: "background.paper",
+ outline: "none",
+ resize: "vertical",
+ "&:focus": { borderColor: "primary.main" },
}}
/>
@@ -420,18 +495,20 @@ const CreateSuiteStep: React.FC<{ onComplete: () => void }> = ({ onComplete }) =
onClick={() => createMutation.mutate()}
disabled={!suiteName.trim() || createMutation.isLoading}
sx={{ mt: 2 }}
- endIcon={createMutation.isLoading ? : null}
+ endIcon={
+ createMutation.isLoading ? : null
+ }
>
- {createMutation.isLoading ? 'Creating...' : 'Create Suite'}
+ {createMutation.isLoading ? "Creating..." : "Create Suite"}
- )
-}
+ );
+};
const RunTestStep: React.FC<{ onComplete: () => void }> = ({ onComplete }) => {
const [testCode, setTestCode] = useState(
- `def test_homepage_loads():\n response = client.get('/')\n assert response.status_code == 200`
- )
+ `def test_homepage_loads():\n response = client.get('/')\n assert response.status_code == 200`,
+ );
return (
@@ -439,51 +516,48 @@ const RunTestStep: React.FC<{ onComplete: () => void }> = ({ onComplete }) => {
Run Your First Test
- Here's a sample test. You'll be able to create more complex tests
- once you're set up.
+ Here's a sample test. You'll be able to create more complex tests once
+ you're set up.
{testCode}
- ✓ Test execution will be available once you've created a test suite and added test cases.
- For now, this step is marked as complete to get you started quickly.
+ ✓ Test execution will be available once you've created a test suite and
+ added test cases. For now, this step is marked as complete to get you
+ started quickly.
-
- )
-}
+ );
+};
const SetupNotificationsStep: React.FC = () => {
const [channels, setChannels] = useState({
email: true,
slack: false,
discord: false,
- })
+ });
const toggleChannel = (channel: keyof typeof channels) => {
- setChannels((prev) => ({ ...prev, [channel]: !prev[channel] }))
- }
+ setChannels((prev) => ({ ...prev, [channel]: !prev[channel] }));
+ };
return (
@@ -491,57 +565,71 @@ const SetupNotificationsStep: React.FC = () => {
Configure Notifications
- Get notified when tests complete, fail, or need attention.
- You can always change these later in Settings.
+ Get notified when tests complete, fail, or need attention. You can
+ always change these later in Settings.
-
+
{[
- { key: 'email' as const, label: 'Email Notifications', desc: 'Receive test results via email' },
- { key: 'slack' as const, label: 'Slack Integration', desc: 'Post results to a Slack channel' },
- { key: 'discord' as const, label: 'Discord Integration', desc: 'Send alerts to Discord' },
+ {
+ key: "email" as const,
+ label: "Email Notifications",
+ desc: "Receive test results via email",
+ },
+ {
+ key: "slack" as const,
+ label: "Slack Integration",
+ desc: "Post results to a Slack channel",
+ },
+ {
+ key: "discord" as const,
+ label: "Discord Integration",
+ desc: "Send alerts to Discord",
+ },
].map((item) => (
toggleChannel(item.key)}
sx={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
p: 2,
- border: '1px solid',
- borderColor: channels[item.key] ? 'primary.main' : 'divider',
+ border: "1px solid",
+ borderColor: channels[item.key] ? "primary.main" : "divider",
borderRadius: 2,
- cursor: 'pointer',
- bgcolor: channels[item.key] ? 'primary.50' : 'transparent',
- transition: 'all 0.2s',
- '&:hover': { borderColor: 'primary.light' },
+ cursor: "pointer",
+ bgcolor: channels[item.key] ? "primary.50" : "transparent",
+ transition: "all 0.2s",
+ "&:hover": { borderColor: "primary.light" },
}}
>
{item.label}
- {item.desc}
+
+ {item.desc}
+
@@ -549,7 +637,7 @@ const SetupNotificationsStep: React.FC = () => {
))}
- )
-}
+ );
+};
-export default OnboardingWizard
+export default OnboardingWizard;
diff --git a/dashboard/frontend/src/components/achievements/AchievementBadge.tsx b/dashboard/frontend/src/components/achievements/AchievementBadge.tsx
index 3e10487..6ac7e15 100644
--- a/dashboard/frontend/src/components/achievements/AchievementBadge.tsx
+++ b/dashboard/frontend/src/components/achievements/AchievementBadge.tsx
@@ -1,19 +1,19 @@
-import { Box, Typography, Paper, Tooltip, LinearProgress } from '@mui/material';
-import { Achievement } from '../../types/achievements';
-import { useAchievementsStore } from '../../stores/achievementsStore';
+import { Box, Typography, Paper, Tooltip, LinearProgress } from "@mui/material";
+import { Achievement } from "../../types/achievements";
+import { useAchievementsStore } from "../../stores/achievementsStore";
interface AchievementBadgeProps {
achievement: Achievement;
- size?: 'small' | 'medium' | 'large';
+ size?: "small" | "medium" | "large";
showProgress?: boolean;
}
const RARITY_COLORS = {
- common: '#9E9E9E',
- uncommon: '#4CAF50',
- rare: '#2196F3',
- epic: '#9C27B0',
- legendary: '#FF9800',
+ common: "#9E9E9E",
+ uncommon: "#4CAF50",
+ rare: "#2196F3",
+ epic: "#9C27B0",
+ legendary: "#FF9800",
};
const SIZE_CONFIG = {
@@ -24,7 +24,7 @@ const SIZE_CONFIG = {
export default function AchievementBadge({
achievement,
- size = 'medium',
+ size = "medium",
showProgress = true,
}: AchievementBadgeProps) {
const { isUnlocked, getProgress } = useAchievementsStore();
@@ -42,7 +42,7 @@ export default function AchievementBadge({
{achievement.name}
{achievement.description}
-
+
Points: {achievement.points} | Rarity: {achievement.rarity}
{!unlocked && progress > 0 && (
@@ -59,21 +59,21 @@ export default function AchievementBadge({
sx={{
width: config.width,
height: config.height,
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'center',
- justifyContent: 'center',
- position: 'relative',
- border: `3px solid ${unlocked ? rarityColor : '#E0E0E0'}`,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ position: "relative",
+ border: `3px solid ${unlocked ? rarityColor : "#E0E0E0"}`,
borderRadius: 2,
background: unlocked
? `linear-gradient(135deg, ${rarityColor}22 0%, ${rarityColor}44 100%)`
- : '#F5F5F5',
+ : "#F5F5F5",
opacity: unlocked ? 1 : 0.5,
- transition: 'all 0.3s ease',
- cursor: 'pointer',
- '&:hover': {
- transform: 'scale(1.05)',
+ transition: "all 0.3s ease",
+ cursor: "pointer",
+ "&:hover": {
+ transform: "scale(1.05)",
boxShadow: unlocked ? 6 : 2,
},
}}
@@ -82,7 +82,7 @@ export default function AchievementBadge({
@@ -91,29 +91,32 @@ export default function AchievementBadge({
{/* Name */}
{achievement.name}
{/* Progress Bar */}
{showProgress && !unlocked && progress > 0 && (
-
+
✓
diff --git a/dashboard/frontend/src/components/achievements/AchievementsList.tsx b/dashboard/frontend/src/components/achievements/AchievementsList.tsx
index 37ddfab..36c777b 100644
--- a/dashboard/frontend/src/components/achievements/AchievementsList.tsx
+++ b/dashboard/frontend/src/components/achievements/AchievementsList.tsx
@@ -1,27 +1,27 @@
-import { Box, Typography, Grid, Tabs, Tab, Chip } from '@mui/material';
-import { useState } from 'react';
-import AchievementBadge from './AchievementBadge';
-import { ACHIEVEMENTS } from '../../data/achievements';
-import { useAchievementsStore } from '../../stores/achievementsStore';
-import { AchievementCategory } from '../../types/achievements';
+import { Box, Typography, Grid, Tabs, Tab, Chip } from "@mui/material";
+import { useState } from "react";
+import AchievementBadge from "./AchievementBadge";
+import { ACHIEVEMENTS } from "../../data/achievements";
+import { useAchievementsStore } from "../../stores/achievementsStore";
+import { AchievementCategory } from "../../types/achievements";
-const CATEGORIES: { value: AchievementCategory | 'all'; label: string }[] = [
- { value: 'all', label: 'All' },
- { value: 'testing', label: 'Testing' },
- { value: 'automation', label: 'Automation' },
- { value: 'quality', label: 'Quality' },
- { value: 'speed', label: 'Speed' },
- { value: 'dedication', label: 'Dedication' },
- { value: 'special', label: 'Special' },
+const CATEGORIES: { value: AchievementCategory | "all"; label: string }[] = [
+ { value: "all", label: "All" },
+ { value: "testing", label: "Testing" },
+ { value: "automation", label: "Automation" },
+ { value: "quality", label: "Quality" },
+ { value: "speed", label: "Speed" },
+ { value: "dedication", label: "Dedication" },
+ { value: "special", label: "Special" },
];
export default function AchievementsList() {
- const [category, setCategory] = useState('all');
+ const [category, setCategory] = useState("all");
const { getStats } = useAchievementsStore();
const stats = getStats();
const filteredAchievements =
- category === 'all'
+ category === "all"
? ACHIEVEMENTS
: ACHIEVEMENTS.filter((a) => a.category === category);
@@ -32,7 +32,7 @@ export default function AchievementsList() {
Your Achievements
-
+
setCategory(newValue)}
variant="scrollable"
scrollButtons="auto"
- sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
+ sx={{ mb: 3, borderBottom: 1, borderColor: "divider" }}
>
{CATEGORIES.map((cat) => (
@@ -70,7 +70,7 @@ export default function AchievementsList() {
{/* Empty State */}
{filteredAchievements.length === 0 && (
-
+
No achievements in this category yet
diff --git a/dashboard/frontend/src/components/billing/InvoiceList.tsx b/dashboard/frontend/src/components/billing/InvoiceList.tsx
index ddf8a33..76b0d90 100644
--- a/dashboard/frontend/src/components/billing/InvoiceList.tsx
+++ b/dashboard/frontend/src/components/billing/InvoiceList.tsx
@@ -12,67 +12,77 @@ import {
IconButton,
Box,
CircularProgress,
-} from '@mui/material'
+} from "@mui/material";
import {
Download as DownloadIcon,
Receipt as ReceiptIcon,
-} from '@mui/icons-material'
-import { format } from 'date-fns'
+} from "@mui/icons-material";
+import { format } from "date-fns";
interface Invoice {
- id: string
- number: string
- amount: number
- currency: string
- status: 'paid' | 'open' | 'void' | 'uncollectible'
- created_at: string
- due_date?: string
- invoice_url?: string
+ id: string;
+ number: string;
+ amount: number;
+ currency: string;
+ status: "paid" | "open" | "void" | "uncollectible";
+ created_at: string;
+ due_date?: string;
+ invoice_url?: string;
}
interface InvoiceListProps {
- invoices: Invoice[]
- isLoading?: boolean
+ invoices: Invoice[];
+ isLoading?: boolean;
}
-const statusColors: Record = {
- paid: 'success',
- open: 'warning',
- void: 'error',
- uncollectible: 'error',
-}
+const statusColors: Record<
+ string,
+ "success" | "warning" | "error" | "default"
+> = {
+ paid: "success",
+ open: "warning",
+ void: "error",
+ uncollectible: "error",
+};
export default function InvoiceList({ invoices, isLoading }: InvoiceListProps) {
const formatAmount = (amount: number, currency: string) => {
- return new Intl.NumberFormat('en-US', {
- style: 'currency',
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
currency: currency.toUpperCase(),
- }).format(amount / 100)
- }
+ }).format(amount / 100);
+ };
if (isLoading) {
return (
-
+
- )
+ );
}
if (invoices.length === 0) {
return (
-
-
+
+
No invoices yet
- )
+ );
}
return (
@@ -112,7 +122,7 @@ export default function InvoiceList({ invoices, isLoading }: InvoiceListProps) {
/>
- {format(new Date(invoice.created_at), 'MMM d, yyyy')}
+ {format(new Date(invoice.created_at), "MMM d, yyyy")}
{invoice.invoice_url && (
@@ -133,5 +143,5 @@ export default function InvoiceList({ invoices, isLoading }: InvoiceListProps) {
- )
+ );
}
diff --git a/dashboard/frontend/src/components/billing/PaymentMethodForm.tsx b/dashboard/frontend/src/components/billing/PaymentMethodForm.tsx
index 6b1ec99..64e67ea 100644
--- a/dashboard/frontend/src/components/billing/PaymentMethodForm.tsx
+++ b/dashboard/frontend/src/components/billing/PaymentMethodForm.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useState } from "react";
import {
Dialog,
DialogTitle,
@@ -11,69 +11,73 @@ import {
CircularProgress,
TextField,
Grid,
-} from '@mui/material'
+} from "@mui/material";
import {
CreditCard as CreditCardIcon,
Add as AddIcon,
-} from '@mui/icons-material'
+} from "@mui/icons-material";
interface PaymentMethodFormProps {
- open: boolean
- onClose: () => void
- onSubmit: (paymentMethodId: string) => Promise
+ open: boolean;
+ onClose: () => void;
+ onSubmit: (paymentMethodId: string) => Promise;
}
// Note: In production, you would use Stripe Elements for secure card input
// This is a simplified version for demonstration
-export default function PaymentMethodForm({ open, onClose, onSubmit }: PaymentMethodFormProps) {
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [cardNumber, setCardNumber] = useState('')
- const [expiry, setExpiry] = useState('')
- const [cvc, setCvc] = useState('')
+export default function PaymentMethodForm({
+ open,
+ onClose,
+ onSubmit,
+}: PaymentMethodFormProps) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [cardNumber, setCardNumber] = useState("");
+ const [expiry, setExpiry] = useState("");
+ const [cvc, setCvc] = useState("");
const handleSubmit = async () => {
- setLoading(true)
- setError(null)
+ setLoading(true);
+ setError(null);
try {
// In production, you would:
// 1. Use Stripe.js to create a payment method
// 2. Send only the payment method ID to your server
// This is a placeholder implementation
- const mockPaymentMethodId = `pm_${Date.now()}`
- await onSubmit(mockPaymentMethodId)
- onClose()
+ const mockPaymentMethodId = `pm_${Date.now()}`;
+ await onSubmit(mockPaymentMethodId);
+ onClose();
} catch (err: any) {
- setError(err.response?.data?.detail || 'Failed to add payment method')
+ setError(err.response?.data?.detail || "Failed to add payment method");
} finally {
- setLoading(false)
+ setLoading(false);
}
- }
+ };
const formatCardNumber = (value: string) => {
- const v = value.replace(/\s+/g, '').replace(/[^0-9]/gi, '')
- const matches = v.match(/\d{4,16}/g)
- const match = (matches && matches[0]) || ''
- const parts = []
+ const v = value.replace(/\s+/g, "").replace(/[^0-9]/gi, "");
+ const matches = v.match(/\d{4,16}/g);
+ const match = (matches && matches[0]) || "";
+ const parts = [];
for (let i = 0, len = match.length; i < len; i += 4) {
- parts.push(match.substring(i, i + 4))
+ parts.push(match.substring(i, i + 4));
}
- return parts.length ? parts.join(' ') : value
- }
+ return parts.length ? parts.join(" ") : value;
+ };
const formatExpiry = (value: string) => {
- const v = value.replace(/\s+/g, '').replace(/[^0-9]/gi, '')
+ const v = value.replace(/\s+/g, "").replace(/[^0-9]/gi, "");
if (v.length >= 2) {
- return v.slice(0, 2) + '/' + v.slice(2, 4)
+ return v.slice(0, 2) + "/" + v.slice(2, 4);
}
- return v
- }
+ return v;
+ };
return (
- )
+ );
}
diff --git a/dashboard/frontend/src/components/billing/PlanCard.tsx b/dashboard/frontend/src/components/billing/PlanCard.tsx
index 24ac382..9fad906 100644
--- a/dashboard/frontend/src/components/billing/PlanCard.tsx
+++ b/dashboard/frontend/src/components/billing/PlanCard.tsx
@@ -9,52 +9,52 @@ import {
ListItem,
ListItemIcon,
ListItemText,
-} from '@mui/material'
+} from "@mui/material";
import {
Check as CheckIcon,
Close as CloseIcon,
Star as StarIcon,
-} from '@mui/icons-material'
+} from "@mui/icons-material";
interface PlanFeature {
- name: string
- included: boolean
+ name: string;
+ included: boolean;
}
interface Plan {
- id: string
- name: string
- price: number
- interval: 'month' | 'year'
- features: PlanFeature[]
- popular?: boolean
- current?: boolean
+ id: string;
+ name: string;
+ price: number;
+ interval: "month" | "year";
+ features: PlanFeature[];
+ popular?: boolean;
+ current?: boolean;
}
interface PlanCardProps {
- plan: Plan
- onSelect: (planId: string) => void
- isLoading?: boolean
+ plan: Plan;
+ onSelect: (planId: string) => void;
+ isLoading?: boolean;
}
export default function PlanCard({ plan, onSelect, isLoading }: PlanCardProps) {
const formatPrice = (price: number) => {
- if (price === 0) return 'Free'
- return `$${price}/${plan.interval}`
- }
+ if (price === 0) return "Free";
+ return `$${price}/${plan.interval}`;
+ };
return (
))}
@@ -111,15 +113,19 @@ export default function PlanCard({ plan, onSelect, isLoading }: PlanCardProps) {
onSelect(plan.id)}
sx={{ mt: 1 }}
>
- {plan.current ? 'Current Plan' : plan.price === 0 ? 'Downgrade' : 'Select Plan'}
+ {plan.current
+ ? "Current Plan"
+ : plan.price === 0
+ ? "Downgrade"
+ : "Select Plan"}
- )
+ );
}
diff --git a/dashboard/frontend/src/components/billing/SubscriptionStatus.tsx b/dashboard/frontend/src/components/billing/SubscriptionStatus.tsx
index d0118d8..9f7c1ba 100644
--- a/dashboard/frontend/src/components/billing/SubscriptionStatus.tsx
+++ b/dashboard/frontend/src/components/billing/SubscriptionStatus.tsx
@@ -8,49 +8,52 @@ import {
Grid,
Divider,
LinearProgress,
-} from '@mui/material'
+} from "@mui/material";
import {
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Error as ErrorIcon,
Cancel as CancelIcon,
-} from '@mui/icons-material'
-import { format } from 'date-fns'
+} from "@mui/icons-material";
+import { format } from "date-fns";
interface Subscription {
- id: string
- plan_id: string
- plan_name: string
- status: 'active' | 'past_due' | 'canceled' | 'incomplete' | 'trialing'
- current_period_start: string
- current_period_end: string
- cancel_at_period_end: boolean
+ id: string;
+ plan_id: string;
+ plan_name: string;
+ status: "active" | "past_due" | "canceled" | "incomplete" | "trialing";
+ current_period_start: string;
+ current_period_end: string;
+ cancel_at_period_end: boolean;
features: {
- max_suites: number
- max_cases: number
- ai_healing: boolean
- priority_support: boolean
- }
+ max_suites: number;
+ max_cases: number;
+ ai_healing: boolean;
+ priority_support: boolean;
+ };
usage?: {
- suites_used: number
- cases_used: number
- }
+ suites_used: number;
+ cases_used: number;
+ };
}
interface SubscriptionStatusProps {
- subscription: Subscription | null
- onCancel: () => void
- onUpgrade: () => void
- isLoading?: boolean
+ subscription: Subscription | null;
+ onCancel: () => void;
+ onUpgrade: () => void;
+ isLoading?: boolean;
}
-const statusConfig: Record = {
- active: { icon: CheckCircleIcon, color: 'success', label: 'Active' },
- past_due: { icon: WarningIcon, color: 'warning', label: 'Past Due' },
- canceled: { icon: CancelIcon, color: 'error', label: 'Canceled' },
- incomplete: { icon: ErrorIcon, color: 'error', label: 'Incomplete' },
- trialing: { icon: CheckCircleIcon, color: 'info', label: 'Trialing' },
-}
+const statusConfig: Record<
+ string,
+ { icon: typeof CheckCircleIcon; color: string; label: string }
+> = {
+ active: { icon: CheckCircleIcon, color: "success", label: "Active" },
+ past_due: { icon: WarningIcon, color: "warning", label: "Past Due" },
+ canceled: { icon: CancelIcon, color: "error", label: "Canceled" },
+ incomplete: { icon: ErrorIcon, color: "error", label: "Incomplete" },
+ trialing: { icon: CheckCircleIcon, color: "info", label: "Trialing" },
+};
export default function SubscriptionStatus({
subscription,
@@ -62,40 +65,51 @@ export default function SubscriptionStatus({
return (
-
+
No Active Subscription
You are currently on the Free plan
-
+
Upgrade Plan
- )
+ );
}
- const StatusIcon = statusConfig[subscription.status]?.icon || ErrorIcon
- const statusColor = statusConfig[subscription.status]?.color || 'error'
- const statusLabel = statusConfig[subscription.status]?.label || 'Unknown'
+ const StatusIcon = statusConfig[subscription.status]?.icon || ErrorIcon;
+ const statusColor = statusConfig[subscription.status]?.color || "error";
+ const statusLabel = statusConfig[subscription.status]?.label || "Unknown";
const periodProgress = () => {
- if (!subscription.current_period_start || !subscription.current_period_end) return 0
- const start = new Date(subscription.current_period_start).getTime()
- const end = new Date(subscription.current_period_end).getTime()
- const now = Date.now()
- return Math.min(100, Math.max(0, ((now - start) / (end - start)) * 100))
- }
+ if (!subscription.current_period_start || !subscription.current_period_end)
+ return 0;
+ const start = new Date(subscription.current_period_start).getTime();
+ const end = new Date(subscription.current_period_end).getTime();
+ const now = Date.now();
+ return Math.min(100, Math.max(0, ((now - start) / (end - start)) * 100));
+ };
return (
-
-
+
+
{subscription.plan_name}
@@ -112,8 +126,12 @@ export default function SubscriptionStatus({
Current billing period
- {format(new Date(subscription.current_period_start), 'MMM d, yyyy')} -{' '}
- {format(new Date(subscription.current_period_end), 'MMM d, yyyy')}
+ {format(
+ new Date(subscription.current_period_start),
+ "MMM d, yyyy",
+ )}{" "}
+ -{" "}
+ {format(new Date(subscription.current_period_end), "MMM d, yyyy")}
@@ -129,45 +147,68 @@ export default function SubscriptionStatus({
sx={{
mt: 2,
p: 2,
- bgcolor: 'warning.lighter',
+ bgcolor: "warning.lighter",
borderRadius: 1,
}}
>
- Your subscription will be canceled at the end of the current billing period
+ Your subscription will be canceled at the end of the current
+ billing period
)}
-
+
Usage This Period
{subscription.usage && (
-
+
Test Suites
- {subscription.usage.suites_used}/{subscription.features.max_suites}
+ {subscription.usage.suites_used}/
+ {subscription.features.max_suites}
-
+
Test Cases
- {subscription.usage.cases_used}/{subscription.features.max_cases}
+ {subscription.usage.cases_used}/
+ {subscription.features.max_cases}
@@ -175,7 +216,7 @@ export default function SubscriptionStatus({
-
+
- )
+ );
}
diff --git a/dashboard/frontend/src/components/common/EmptyState.tsx b/dashboard/frontend/src/components/common/EmptyState.tsx
index 325b54c..668701e 100644
--- a/dashboard/frontend/src/components/common/EmptyState.tsx
+++ b/dashboard/frontend/src/components/common/EmptyState.tsx
@@ -1,6 +1,6 @@
-import { Box, Typography, Button, keyframes } from '@mui/material';
-import { Add as AddIcon } from '@mui/icons-material';
-import { ReactNode } from 'react';
+import { Box, Typography, Button, keyframes } from "@mui/material";
+import { Add as AddIcon } from "@mui/icons-material";
+import { ReactNode } from "react";
// Animation keyframes
const fadeIn = keyframes`
@@ -43,7 +43,7 @@ interface EmptyStateProps {
actionLabel?: string;
onAction?: () => void;
customIcon?: ReactNode;
- variant?: 'default' | 'compact';
+ variant?: "default" | "compact";
}
export default function EmptyState({
@@ -53,19 +53,19 @@ export default function EmptyState({
actionLabel,
onAction,
customIcon,
- variant = 'default',
+ variant = "default",
}: EmptyStateProps) {
- const isCompact = variant === 'compact';
+ const isCompact = variant === "compact";
return (
{
// Fallback if image doesn't load
- e.currentTarget.style.display = 'none';
+ e.currentTarget.style.display = "none";
}}
/>
@@ -99,9 +99,9 @@ export default function EmptyState({
{title}
@@ -126,7 +129,7 @@ export default function EmptyState({
variant="body1"
color="textSecondary"
sx={{
- maxWidth: '500px',
+ maxWidth: "500px",
mb: isCompact ? 2 : 3,
animation: `${slideUp} 0.5s ease-out 0.2s both`,
}}
@@ -146,17 +149,17 @@ export default function EmptyState({
mt: 2,
px: 4,
py: 1.5,
- fontSize: '1rem',
- fontWeight: 'bold',
+ fontSize: "1rem",
+ fontWeight: "bold",
boxShadow: 3,
animation: `${slideUp} 0.5s ease-out 0.3s both`,
- transition: 'all 0.3s ease',
- '&:hover': {
- transform: 'translateY(-2px)',
+ transition: "all 0.3s ease",
+ "&:hover": {
+ transform: "translateY(-2px)",
boxShadow: 6,
},
- '&:active': {
- transform: 'translateY(0)',
+ "&:active": {
+ transform: "translateY(0)",
},
}}
>
diff --git a/dashboard/frontend/src/components/common/KeyboardShortcutsDialog.tsx b/dashboard/frontend/src/components/common/KeyboardShortcutsDialog.tsx
index aa94a3f..0162ee1 100644
--- a/dashboard/frontend/src/components/common/KeyboardShortcutsDialog.tsx
+++ b/dashboard/frontend/src/components/common/KeyboardShortcutsDialog.tsx
@@ -6,9 +6,9 @@ import {
Box,
Chip,
IconButton,
-} from '@mui/material';
-import { Close as CloseIcon } from '@mui/icons-material';
-import { DEFAULT_SHORTCUTS } from '../../hooks/useKeyboardShortcuts';
+} from "@mui/material";
+import { Close as CloseIcon } from "@mui/icons-material";
+import { DEFAULT_SHORTCUTS } from "../../hooks/useKeyboardShortcuts";
interface KeyboardShortcutsDialogProps {
open: boolean;
@@ -20,18 +20,27 @@ export default function KeyboardShortcutsDialog({
onClose,
}: KeyboardShortcutsDialogProps) {
// Group shortcuts by category
- const groupedShortcuts = DEFAULT_SHORTCUTS.reduce((acc, shortcut) => {
- if (!acc[shortcut.category]) {
- acc[shortcut.category] = [];
- }
- acc[shortcut.category].push(shortcut);
- return acc;
- }, {} as Record);
+ const groupedShortcuts = DEFAULT_SHORTCUTS.reduce(
+ (acc, shortcut) => {
+ if (!acc[shortcut.category]) {
+ acc[shortcut.category] = [];
+ }
+ acc[shortcut.category].push(shortcut);
+ return acc;
+ },
+ {} as Record,
+ );
return (