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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@
"/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 @@ -229,6 +228,15 @@
{ 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();

Check failure on line 233 in src/App.tsx

View workflow job for this annotation

GitHub Actions / verify

Cannot find name '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 />;

Check failure on line 237 in src/App.tsx

View workflow job for this annotation

GitHub Actions / verify

Cannot find name 'Navigate'. Did you mean 'Navigator'?
}

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

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

Check failure on line 1185 in src/App.tsx

View workflow job for this annotation

GitHub Actions / verify

Property 'legacyRateLimit' does not exist on type '{ readonly landing: "/"; readonly dashboard: "/dashboard"; readonly marketplace: "/marketplace"; readonly publish: "/publish"; readonly myApis: "/apis/my-apis"; readonly planBadge: "/apis/plan-badge"; ... 12 more ...; readonly endpointSummary: "/endpoints"; }'.

<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 @@
sparklineValues?: number[];
pricingOnRequest?: boolean;
baseUrl?: string;
rateLimits?: Array<{
plan: string;
requestsPerMinute: number;
burstLimit?: number;
concurrentConnections?: number;
}>;
};


Expand All @@ -44,7 +50,7 @@
status: "operational" | "degraded" | "maintenance"; description: string;
price: number; latency: number; uptime: number; rating: number;
tags: string[]; category: string; calls: number; features: string[]; useCases: string[];
sla?: APIItem["sla"];

Check failure on line 53 in src/data/mockApis.ts

View workflow job for this annotation

GitHub Actions / verify

Property 'sla' does not exist on type 'APIItem'.
endpoints: NonNullable<APIItem["endpoints"]>;
}> = [
{ id: "geocode-01", name: "GeoLocate", provider: "Acme Labs", version: "1.8.0", status: "operational",
Expand Down Expand Up @@ -115,9 +121,27 @@
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 @@ -197,6 +221,10 @@
},
{
id: "pay-qr",
rateLimits: [
{ plan: "Standard", requestsPerMinute: 300 },
{ plan: "Premium", requestsPerMinute: 3000 },
],
name: "QuickPay",
provider: { name: "PayFast", url: "#" },
status: "degraded",
Expand Down Expand Up @@ -373,8 +401,9 @@
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],
sla: x.sla,

Check failure on line 406 in src/data/mockApis.ts

View workflow job for this annotation

GitHub Actions / verify

Object literal may only specify known properties, and 'sla' does not exist in type 'APIItem'.
} satisfies APIItem;
}),
];
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
1 change: 1 addition & 0 deletions 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
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