diff --git a/src/App.tsx b/src/App.tsx index 13ec01c..0fc2f9d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, useCallback, lazy, Suspense } from "react"; -import { Routes, Route, NavLink, useNavigate, useLocation } from "react-router-dom"; +import { Routes, Route, NavLink, Navigate, useNavigate, useLocation } from "react-router-dom"; +import { useToast } from "./components/Toast"; import { ThemeToggle } from "./ThemeToggle"; import ServerError from "./components/ServerError"; import useDocumentTitle from "./hooks/useDocumentTitle"; @@ -49,7 +50,6 @@ const routePrefetchers: Record Promise> = { "/theme-playground": () => import("./pages/ThemePlayground"), "/design-system/docs": () => import("./pages/DesignSystemDocs"), "/a11y-audit": () => import("./pages/A11yAudit"), - "/rate-limit": () => import("./pages/RateLimitCard"), "/webhooks/deliveries": () => import("./pages/WebhookDeliveries"), "/endpoints": () => import("./pages/EndpointSummary"), }; @@ -210,7 +210,8 @@ const APP_ROUTES = { themePlayground: "/theme-playground", designSystem: "/design-system/docs", serverError: "/500", - rateLimitCard: "/rate-limit", + rateLimitCard: "/marketplace/:apiId/rate-limits", + legacyRateLimit: "/rate-limit", slaCard: "/marketplace/grantfox-wave-compute/sla", webhookDeliveries: "/webhooks/deliveries", onboarding: "/onboarding", @@ -226,6 +227,15 @@ const PRIMARY_NAV: Array<{ to: string; label: string }> = [ { to: APP_ROUTES.documentation, label: "Docs" }, ]; +/** `/rate-limit` no longer exists; limits are per-API. Send users to the Marketplace. */ +function LegacyRateLimitRedirect() { + const { showToast } = useToast(); + useEffect(() => { + showToast("Rate limits are now shown per API. Pick an API to see its limits."); + }, [showToast]); + return ; +} + function buildExplorerLink(hash: string) { return `${EXPLORER_BASE_URL}${hash}`; } @@ -1049,6 +1059,7 @@ function App() { } /> } /> + } /> } /> diff --git a/src/config/routeCatalogue.ts b/src/config/routeCatalogue.ts index 4fc9901..dabac52 100644 --- a/src/config/routeCatalogue.ts +++ b/src/config/routeCatalogue.ts @@ -75,11 +75,6 @@ export const ROUTE_CATALOGUE: RouteCatalogueEntry[] = [ path: "/design-system/docs", keywords: ["design system", "design", "components", "ui kit"], }, - { - label: "Rate Limit", - path: "/rate-limit", - keywords: ["rate limit", "throttle", "limits"], - }, { label: "Webhook Deliveries", path: "/webhooks/deliveries", diff --git a/src/data/mockApis.ts b/src/data/mockApis.ts index 987fb10..0ff1669 100644 --- a/src/data/mockApis.ts +++ b/src/data/mockApis.ts @@ -36,6 +36,12 @@ export type APIItem = { sparklineValues?: number[]; pricingOnRequest?: boolean; baseUrl?: string; + rateLimits?: Array<{ + plan: string; + requestsPerMinute: number; + burstLimit?: number; + concurrentConnections?: number; + }>; }; @@ -98,9 +104,27 @@ const SAMPLE_APIS: Array<{ endpoints: [{ id: "render-pdf", title: "Render PDF", url: "/v1/render", method: "POST", group: "Rendering", params: [{ name: "html", type: "string", required: true }, { name: "page_size", type: "string", required: false }], response: '{ "pdf_url": "https://files.pdfbuilder.dev/r/91c.pdf", "pages": 3 }' }] }, ]; +/** Published rate limits for sample APIs; APIs absent here publish none. */ +const SAMPLE_RATE_LIMITS: Record = { + "geocode-01": [ + { plan: "Free", requestsPerMinute: 30, burstLimit: 60 }, + { plan: "Pro", requestsPerMinute: 1200, burstLimit: 2400 }, + ], + "fx-rates-01": [ + { plan: "Free", requestsPerMinute: 60, concurrentConnections: 2 }, + { plan: "Developer", requestsPerMinute: 600, concurrentConnections: 10 }, + { plan: "Enterprise", requestsPerMinute: 12000, concurrentConnections: 200 }, + ], +}; + export const MOCK_APIS: APIItem[] = [ { id: "weather-001", + rateLimits: [ + { plan: "Free", requestsPerMinute: 10, burstLimit: 20, concurrentConnections: 2 }, + { plan: "Developer", requestsPerMinute: 120, burstLimit: 200, concurrentConnections: 10 }, + { plan: "Pro", requestsPerMinute: 600, burstLimit: 1000, concurrentConnections: 50 }, + ], name: "WeatherSim API", provider: { name: "Acme Labs", url: "#" }, version: "2.3.1", @@ -180,6 +204,10 @@ export const MOCK_APIS: APIItem[] = [ }, { id: "pay-qr", + rateLimits: [ + { plan: "Standard", requestsPerMinute: 300 }, + { plan: "Premium", requestsPerMinute: 3000 }, + ], name: "QuickPay", provider: { name: "PayFast", url: "#" }, status: "degraded", @@ -356,6 +384,7 @@ export const MOCK_APIS: APIItem[] = [ endpoints: x.endpoints, stats: { totalCalls: x.calls, avgResponseMs: x.latency, uptimePct: x.uptime }, hourlyHealth: health(-1), + rateLimits: SAMPLE_RATE_LIMITS[x.id], sparklineValues: [10 + i, 12 + i, 11 + i, 14 + i, 16 + i, 15 + i, 18 + i, 19 + i, 17 + i, 21 + i, 22 + i, 24 + i], } satisfies APIItem; }), diff --git a/src/pages/ApiDetailPage.tsx b/src/pages/ApiDetailPage.tsx index 156afc7..8964d37 100644 --- a/src/pages/ApiDetailPage.tsx +++ b/src/pages/ApiDetailPage.tsx @@ -1308,6 +1308,7 @@ export default function ApiDetailPage({ onBack }: Props) { Status + View rate limits diff --git a/src/pages/RateLimitCard.test.tsx b/src/pages/RateLimitCard.test.tsx index 07af88e..a987481 100644 --- a/src/pages/RateLimitCard.test.tsx +++ b/src/pages/RateLimitCard.test.tsx @@ -19,13 +19,7 @@ import RateLimitCard, { type RateLimitCardProps } from "./RateLimitCard"; * 7. Breadcrumb presence and middle-ellipsis truncation */ -/** Expected pattern class for each plan. */ -const PLAN_PATTERN_CLASSES: Record = { - Free: "free", - Developer: "developer", - Pro: "pro", - Enterprise: "enterprise", -}; +import RateLimitCard from "./RateLimitCard"; /** * Forces `window.matchMedia("(max-width: 639px)")` to report a match so the @@ -57,92 +51,36 @@ function renderPage(props?: RateLimitCardProps) { } describe("RateLimitCard", () => { - afterEach(() => { - cleanup(); - }); - - // ── Page structure ──────────────────────────────────────────────────────── + beforeEach(() => vi.clearAllMocks()); + afterEach(() => cleanup()); - it("renders the page heading", () => { - renderPage(); - expect( - screen.getByRole("heading", { name: /rate limit configuration/i }), - ).toBeTruthy(); + it("shows a loading state first", () => { + renderPage("three-tier"); + expect(screen.getByRole("status").textContent).toMatch(/loading/i); }); - it("renders the API subtitle", () => { - renderPage(); - expect( - screen.getByText(/GrantFox Wave Compute API/), - ).toBeTruthy(); + it("renders three rows and a caption naming the API", async () => { + renderPage("three-tier"); + const table = await screen.findByRole("table", { name: /Three Tier API/ }); + expect(within(table).getByText(/Rate limit tiers for Three Tier API/)).toBeTruthy(); + expect(within(table).getAllByRole("row").length).toBe(4); // header + 3 + expect(within(table).getByText(new Intl.NumberFormat().format(120000))).toBeTruthy(); + expect(within(table).getByText("Burst limit")).toBeTruthy(); + expect(within(table).getByText("Concurrent connections")).toBeTruthy(); }); - // ── Breadcrumb ──────────────────────────────────────────────────────────── - - it("renders an accessible breadcrumb navigation", () => { - renderPage(); - const nav = screen.getByRole("navigation", { name: /breadcrumb/i }); - expect(nav).toBeTruthy(); + it("omits Burst and Concurrent columns when no tier has them", async () => { + renderPage("no-burst"); + const table = await screen.findByRole("table", { name: /No Burst API/ }); + expect(within(table).queryByText("Burst limit")).toBeNull(); + expect(within(table).queryByText("Concurrent connections")).toBeNull(); + expect(within(table).getByText("Requests / min")).toBeTruthy(); }); - it("shows Marketplace as the first breadcrumb link", () => { - renderPage(); - const marketplaceLink = screen.getByRole("link", { - name: "Marketplace", - }); - expect(marketplaceLink).toBeTruthy(); - expect(marketplaceLink.getAttribute("href")).toBe("/marketplace"); - }); - - it("marks the last crumb as the current page", () => { - renderPage(); - const current = document.querySelector( - '[aria-current="page"]', - ); - expect(current).not.toBeNull(); - // Visual text may be truncated but aria-current must be present - expect(current?.getAttribute("aria-current")).toBe("page"); - }); - - it("applies middle-ellipsis truncation to long middle crumb labels", () => { - const { container } = renderPage(); - - // The two middle crumbs are rendered inside `.breadcrumb-middle` list items - // as visible links on desktop, and also inside the popover on mobile. - // On desktop they are in .breadcrumb-middle items. - const middleItems = Array.from( - container.querySelectorAll( - ".breadcrumb-middle .breadcrumb-link", - ), - ); - - // Both middle labels are long and should be truncated - expect(middleItems.length).toBe(2); - for (const item of middleItems) { - expect(item.textContent).toContain("\u2026"); - } - }); - - it("preserves full labels as accessible names on truncated crumbs", () => { - const { container } = renderPage(); - - const middleLinks = Array.from( - container.querySelectorAll( - ".breadcrumb-middle .breadcrumb-link", - ), - ); - - const fullLabels = [ - "GrantFox Wave Compute API – Stellar Edition", - "Rate Limits & Throttling Policies", - ]; - - middleLinks.forEach((link, i) => { - // title is always set - expect(link.getAttribute("title")).toBe(fullLabels[i]); - // aria-label is set when truncated (both labels exceed 28 chars) - expect(link.getAttribute("aria-label")).toBe(fullLabels[i]); - }); + it("shows the not-published state when the API has no rateLimits", async () => { + renderPage("unpublished"); + expect(await screen.findByText("This provider has not published rate limits.")).toBeTruthy(); + expect(screen.queryByRole("table")).toBeNull(); }); // ── Rate-limit table (Desktop) ──────────────────────────────────────────── @@ -166,15 +104,15 @@ describe("RateLimitCard", () => { for (const plan of ["Free", "Developer", "Pro", "Enterprise"]) { expect(screen.getByText(plan)).toBeTruthy(); } + expect(nav.textContent).toContain("Three Tier API"); }); - it("shows the correct requests-per-minute value for the Free tier", () => { - renderPage(); - // "10" appears in both the Free row (req/min) and Developer row (concurrent), - // so we confirm it is present at least once in the table. - const cells = screen.getAllByText("10"); - expect(cells.length).toBeGreaterThanOrEqual(1); + it("contains no GrantFox literals", async () => { + const { container } = renderPage("three-tier"); + await screen.findByRole("table"); + expect(container.textContent).not.toMatch(/GrantFox/i); }); +}); it("renders the throttle note below the table", () => { renderPage(); diff --git a/src/pages/RateLimitCard.tsx b/src/pages/RateLimitCard.tsx index ed3e4b3..75f105d 100644 --- a/src/pages/RateLimitCard.tsx +++ b/src/pages/RateLimitCard.tsx @@ -4,19 +4,11 @@ import useDocumentTitle from "../hooks/useDocumentTitle"; import "./RateLimitCard.css"; /** - * RateLimitCard – GrantFox FWC26 campaign "Stellar Wave" (issue #567) + * RateLimitCard – per-API rate limits at `/marketplace/:apiId/rate-limits`. * - * Displays rate-limit configuration for a deeply nested API endpoint. - * The breadcrumb path intentionally contains long segment names to exercise - * the middle-ellipsis truncation feature (`maxLabelLength` prop) added to - * the Breadcrumb component. - * - * The breadcrumb trail is: - * Marketplace → GrantFox Wave Compute API (long) → Rate Limits → Current Page - * - * With `maxLabelLength={28}` the two middle segments are each capped at - * 28 characters in the visible label while the full strings remain available - * via `title` / `aria-label` for screen readers and on hover. + * The API is resolved from the catalogue (`fetchCatalog`) and the table is + * built from `api.rateLimits`. Optional columns (burst, concurrent + * connections) render only when at least one tier publishes that field. */ /** Rate-limit tier shape used in the table below. */ @@ -81,11 +73,7 @@ const BREADCRUMB_ITEMS = [ }, ] as const; -/** - * Maximum characters before a crumb label is shortened with a middle-ellipsis. - * Chosen so the two long middle segments fit comfortably in a single breadcrumb - * row even on narrow viewports. - */ +/** Maximum characters before a crumb label is shortened with a middle-ellipsis. */ const BREADCRUMB_MAX_LABEL = 28; /** Pattern class and description for each plan tier (color-blind safe). */ @@ -184,9 +172,7 @@ export default function RateLimitCard({

Rate Limit Configuration

-

- GrantFox Wave Compute API – Stellar Edition  ·  FWC26 -

+ {api &&

{api.name}

}
{isMobile ? ( diff --git a/src/renderRoute.tsx b/src/renderRoute.tsx index 799be42..f152707 100644 --- a/src/renderRoute.tsx +++ b/src/renderRoute.tsx @@ -22,6 +22,7 @@ import MarketplacePageSkeleton from "./pages/MarketplacePage.skeleton"; import ApiDetailPageSkeleton from "./pages/ApiDetailPage.skeleton"; import LatencyChart from "./pages/LatencyChart"; import StandaloneShell from "./components/StandaloneShell"; +import { isMarketplaceSubpage } from "./routeMatchers"; /** * The slice of a React 18 root that routing needs. Keeping it structural lets @@ -66,7 +67,7 @@ export async function renderRoute( return; } - if (pathname.startsWith("/marketplace") && !pathname.endsWith("/sla")) { + if (pathname.startsWith("/marketplace") && !isMarketplaceSubpage(pathname)) { startRouteLoading(); root.render(wrap()); const mod = await import("./pages/MarketplacePage"); diff --git a/src/routeMatchers.test.ts b/src/routeMatchers.test.ts new file mode 100644 index 0000000..28c4fe7 --- /dev/null +++ b/src/routeMatchers.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { isMarketplaceSubpage } from "./routeMatchers"; + +describe("isMarketplaceSubpage", () => { + it.each([ + ["/marketplace", false], + ["/marketplace?x", false], + ["/marketplace/a/sla", true], + ["/marketplace/a/rate-limits", true], + ["/marketplace/a/rate-limits/", true], + ["/marketplace/a/other", false], + ["/marketplace/a/rate-limits/config", false], + ])("%s -> %s", (path, expected) => { + expect(isMarketplaceSubpage(path)).toBe(expected); + }); +}); diff --git a/src/routeMatchers.ts b/src/routeMatchers.ts new file mode 100644 index 0000000..48e2e80 --- /dev/null +++ b/src/routeMatchers.ts @@ -0,0 +1,16 @@ +/** + * Pure routing predicates shared by the custom router (`renderRoute.tsx`). + */ + +const MARKETPLACE_SUBPAGE = /^\/marketplace\/[^/]+\/(sla|rate-limits)\/?$/; + +/** + * True when `pathname` is a per-API marketplace subpage that is rendered by + * `App` (e.g. `/marketplace/:apiId/sla`, `/marketplace/:apiId/rate-limits`) + * rather than by the Marketplace listing page. Query strings and hashes are + * ignored. + */ +export function isMarketplaceSubpage(pathname: string): boolean { + const path = pathname.split(/[?#]/)[0]; + return MARKETPLACE_SUBPAGE.test(path); +}