Skip to content
Open
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
17 changes: 14 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -49,7 +50,6 @@ const routePrefetchers: Record<string, () => Promise<any>> = {
"/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"),
};
Expand Down Expand Up @@ -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",
Expand All @@ -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 <Navigate to={APP_ROUTES.marketplace} replace />;
}

function buildExplorerLink(hash: string) {
return `${EXPLORER_BASE_URL}${hash}`;
}
Expand Down Expand Up @@ -1049,6 +1059,7 @@ function App() {
<Route path="/a11y-audit" element={<A11yAudit />} />

<Route path={APP_ROUTES.rateLimitCard} element={<RateLimitCard />} />
<Route path={APP_ROUTES.legacyRateLimit} element={<LegacyRateLimitRedirect />} />

<Route path={APP_ROUTES.endpointSummary} element={<EndpointSummary />} />

Expand Down
5 changes: 0 additions & 5 deletions src/config/routeCatalogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions src/data/mockApis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export type APIItem = {
sparklineValues?: number[];
pricingOnRequest?: boolean;
baseUrl?: string;
rateLimits?: Array<{
plan: string;
requestsPerMinute: number;
burstLimit?: number;
concurrentConnections?: number;
}>;
};


Expand Down Expand Up @@ -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<string, APIItem["rateLimits"]> = {
"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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
}),
Expand Down
1 change: 1 addition & 0 deletions src/pages/ApiDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import { SHORTCUTS } from "../hooks/useGlobalShortcuts";
import LiveRegion from "../components/LiveRegion";
import { toCurl, type CurlRequest } from "../utils/toCurl";
import { fetchApiDetail, type ApiDetail } from "../api/catalogApi";

Check failure on line 33 in src/pages/ApiDetailPage.tsx

View workflow job for this annotation

GitHub Actions / verify

Module '"../api/catalogApi"' has no exported member 'ApiDetail'.

/**
* ApiDetailPage
Expand Down Expand Up @@ -643,7 +643,7 @@
const ratingDistribution = useMemo(() => {
if (rawReviews.length === 0) return undefined;
const dist: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
rawReviews.forEach((r) => {

Check failure on line 646 in src/pages/ApiDetailPage.tsx

View workflow job for this annotation

GitHub Actions / verify

Parameter 'r' implicitly has an 'any' type.
const star = Math.min(5, Math.max(1, Math.round(r.rating)));
dist[star] = (dist[star] ?? 0) + 1;
});
Expand Down Expand Up @@ -698,14 +698,14 @@
fetchApiDetail(id, controller.signal)
.then((result) => {
if (!active) return;
if (result.kind === "not-found") {

Check failure on line 701 in src/pages/ApiDetailPage.tsx

View workflow job for this annotation

GitHub Actions / verify

Property 'kind' does not exist on type 'ApiDetailResult'.
setApi(null);
setStatus("not-found");
return;
}
setApi(result.api);

Check failure on line 706 in src/pages/ApiDetailPage.tsx

View workflow job for this annotation

GitHub Actions / verify

Property 'api' does not exist on type 'ApiDetailResult'.
setStatus("ready");
setAnnouncement(`${result.api.name} detail page loaded`);

Check failure on line 708 in src/pages/ApiDetailPage.tsx

View workflow job for this annotation

GitHub Actions / verify

Property 'api' does not exist on type 'ApiDetailResult'.
})
.catch((err) => {
if (!active || controller.signal.aborted) return;
Expand Down Expand Up @@ -879,7 +879,7 @@
<div>
<h2>Key Features</h2>
<ul style={{ paddingLeft: "var(--mkt-space-2xl)", lineHeight: "var(--mkt-line-height-loose)" }}>
{(api.features || []).map((f) => (

Check failure on line 882 in src/pages/ApiDetailPage.tsx

View workflow job for this annotation

GitHub Actions / verify

Parameter 'f' implicitly has an 'any' type.
<li key={f} style={{ color: "var(--muted)" }}>
{f}
</li>
Expand Down Expand Up @@ -1308,6 +1308,7 @@
<span style={{ fontSize: 14, color: "var(--muted)" }}>Status</span>
<StatusBadge status={apiStatusToVariant(api.status)} />
</div>
<a href={`/marketplace/${encodeURIComponent(api.id)}/rate-limits`} style={{ fontSize: "var(--mkt-font-size-tag)" }}>View rate limits</a>
</div>
</div>

Expand Down
122 changes: 30 additions & 92 deletions src/pages/RateLimitCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Free: "free",
Developer: "developer",
Pro: "pro",
Enterprise: "enterprise",
};
import RateLimitCard from "./RateLimitCard";

/**
* Forces `window.matchMedia("(max-width: 639px)")` to report a match so the
Expand Down Expand Up @@ -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<HTMLElement>(
'[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<HTMLElement>(
".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<HTMLAnchorElement>(
".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) ────────────────────────────────────────────
Expand All @@ -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();
Expand Down
26 changes: 6 additions & 20 deletions src/pages/RateLimitCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -184,9 +172,7 @@ export default function RateLimitCard({
<div className="rate-limit-card">
<div className="rate-limit-card-header">
<h1 className="rate-limit-card-title">Rate Limit Configuration</h1>
<p className="rate-limit-card-subtitle">
GrantFox Wave Compute API – Stellar Edition &nbsp;·&nbsp; FWC26
</p>
{api && <p className="rate-limit-card-subtitle">{api.name}</p>}
</div>

{isMobile ? (
Expand Down
3 changes: 2 additions & 1 deletion src/renderRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(<StandaloneShell><MarketplacePageSkeleton /></StandaloneShell>));
const mod = await import("./pages/MarketplacePage");
Expand Down
16 changes: 16 additions & 0 deletions src/routeMatchers.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
16 changes: 16 additions & 0 deletions src/routeMatchers.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading