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
39 changes: 39 additions & 0 deletions src/app/services/ServicesDashboardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
type ServicesDashboardSkeletonProps = {
rows?: number;
};

/**
* Mirrors the service-row footprint so loading-to-content swaps keep the
* dashboard geometry stable while the status region announces progress.
*/
export function ServicesDashboardSkeleton({
rows = 5,
}: ServicesDashboardSkeletonProps) {
const rowCount = Math.max(1, Math.floor(rows));

return (
<div
role="status"
aria-live="polite"
aria-busy="true"
className="space-y-3"
>
<span className="sr-only">Loading services</span>
<ul
aria-hidden="true"
className="divide-y divide-zinc-200 dark:divide-zinc-800"
>
{Array.from({ length: rowCount }, (_, index) => (
<li
key={index}
data-testid="service-row-skeleton"
className="-mx-4 flex min-h-12 items-center justify-between px-4 py-3"
>
<div className="h-4 w-40 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" />
<div className="h-4 w-32 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" />
</li>
))}
</ul>
</div>
);
}
100 changes: 100 additions & 0 deletions src/app/services/ServicesSectionBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"use client";

import {
Component,
type ErrorInfo,
type ReactNode,
} from "react";

export const SERVICES_SECTION_ERROR_CODES = {
serviceList: "SERVICES_LIST_RENDER_FAILED",
pagination: "SERVICES_PAGINATION_RENDER_FAILED",
} as const;

export type ServicesSectionErrorCode =
(typeof SERVICES_SECTION_ERROR_CODES)[keyof typeof SERVICES_SECTION_ERROR_CODES];

export type ServicesSectionFailure = {
code: ServicesSectionErrorCode;
section: string;
error: Error;
componentStack: string | null;
};

type ServicesSectionBoundaryProps = {
sectionName: string;
errorCode: ServicesSectionErrorCode;
children: ReactNode;
};

type ServicesSectionBoundaryState = {
hasError: boolean;
};

/**
* Keeps one services-dashboard subtree from taking down its siblings.
* Render failures are logged for observability while the UI intentionally
* avoids exposing exception details to users.
*/
export class ServicesSectionBoundary extends Component<
ServicesSectionBoundaryProps,
ServicesSectionBoundaryState
> {
state: ServicesSectionBoundaryState = {
hasError: false,
};

static getDerivedStateFromError(): ServicesSectionBoundaryState {
return { hasError: true };
}

componentDidCatch(error: Error, info: ErrorInfo) {
const failure: ServicesSectionFailure = {
code: this.props.errorCode,
section: this.props.sectionName,
error,
componentStack: info.componentStack,
};

console.error("Services dashboard section failed", failure);
}

private retry = () => {
this.setState({ hasError: false });
};

render() {
if (!this.state.hasError) {
return this.props.children;
}

return (
<section
role="alert"
aria-live="assertive"
className="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm dark:border-rose-900 dark:bg-rose-950"
>
<p className="font-medium text-rose-800 dark:text-rose-300">
{this.props.sectionName} unavailable.
</p>
<p className="mt-1 text-rose-700 dark:text-rose-400">
This section hit an unexpected error. The rest of the dashboard is
still available.
</p>
<p
data-testid="services-section-error-code"
className="mt-2 font-mono text-xs text-rose-700 dark:text-rose-400"
>
Reference: {this.props.errorCode}
</p>
<button
type="button"
onClick={this.retry}
className="mt-3 rounded-md border border-rose-300 px-3 py-1.5 text-xs font-medium text-rose-800 hover:bg-rose-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-500 dark:border-rose-700 dark:text-rose-300 dark:hover:bg-rose-900"
>
Retry section
</button>
</section>
);
}
}
16 changes: 14 additions & 2 deletions src/app/services/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { PageSkeleton } from "@/components/PageSkeleton";
import { PageShell } from "@/components/PageShell";
import { ServicesDashboardSkeleton } from "./ServicesDashboardSkeleton";

export default function Loading() {
return <PageSkeleton />;
return (
<PageShell>
<header
aria-hidden="true"
className="flex items-baseline justify-between"
>
<div className="h-9 w-32 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" />
<div className="h-9 w-28 animate-pulse rounded-full bg-zinc-200 dark:bg-zinc-800" />
</header>
<ServicesDashboardSkeleton />
</PageShell>
);
}
145 changes: 89 additions & 56 deletions src/app/services/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { ErrorMessage } from "@/components/ErrorMessage";
import { EmptyState } from "@/components/EmptyState";
import { PageShell } from "@/components/PageShell";
import { Pagination } from "@/components/Pagination";
import { Spinner } from "@/components/Spinner";
import { ServicesDashboardSkeleton } from "./ServicesDashboardSkeleton";
import {
SERVICES_SECTION_ERROR_CODES,
ServicesSectionBoundary,
} from "./ServicesSectionBoundary";
import { truncateMiddle } from "@/lib/format";
import { useToast } from "@/components/ToastProvider";
import { useClipboard } from "@/lib/useClipboard";
Expand Down Expand Up @@ -177,6 +181,64 @@ export function ServiceCopyButton({ serviceId }: { serviceId: string }) {
);
}


function ServicesResults({
services,
sortKey,
sortDir,
}: {
services: Service[];
sortKey: SortKey;
sortDir: SortDir;
}) {
const sortedServices = useSorted(services, sortKey, sortDir);

if (services.length === 0) {
return (
<EmptyState
title="No services registered yet."
description="Create the first service to start tracking request pricing."
action={
<Link
href="/services/new"
className="rounded-full bg-black px-4 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:bg-white dark:text-black"
>
New service
</Link>
}
/>
);
}

return (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{sortedServices?.map((service) => (
<li
key={service.serviceId}
className="-mx-4 flex min-h-12 items-center justify-between rounded-lg px-4 py-3 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<Link
href={`/services/${encodeURIComponent(service.serviceId)}`}
className="flex flex-1 items-center justify-between rounded-lg hover:bg-zinc-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:hover:bg-zinc-900"
>
<span
className="font-mono text-sm"
title={service.serviceId}
aria-label={service.serviceId}
>
{truncateMiddle(service.serviceId)}
</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{service.priceStroops} stroops / request
</span>
</Link>
<ServiceCopyButton serviceId={service.serviceId} />
</li>
))}
</ul>
);
}

export default function ServicesPage() {
const [services, setServices] = useState<Service[] | null>(null);
const [error, setError] = useState<string | null>(null);
Expand All @@ -188,8 +250,6 @@ export default function ServicesPage() {
getInitialSort
);

const sortedServices = useSorted(services, sortKey, sortDir);

const handleSort = (key: SortKey) => {
const next: { key: SortKey; dir: SortDir } =
key === sortKey
Expand Down Expand Up @@ -266,59 +326,32 @@ export default function ServicesPage() {
</Link>
</header>
<ErrorMessage title="Failed to load services" detail={error} />
{loading && (
<div className="flex justify-center py-10">
<Spinner label="Loading services" />
</div>
)}
{!loading && services && services.length === 0 && (
<EmptyState
title="No services registered yet."
description="Create the first service to start tracking request pricing."
action={
<Link
href="/services/new"
className="rounded-full bg-black px-4 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:bg-white dark:text-black"
>
New service
</Link>
}
/>
)}
{!loading && services && services.length > 0 && (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{sortedServices?.map((s) => (
<li
key={s.serviceId}
className="-mx-4 flex items-center justify-between rounded-lg px-4 py-3 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<Link
href={`/services/${encodeURIComponent(s.serviceId)}`}
className="flex flex-1 items-center justify-between rounded-lg hover:bg-zinc-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:hover:bg-zinc-900"
>
<span
className="font-mono text-sm"
title={s.serviceId}
aria-label={s.serviceId}
>
{truncateMiddle(s.serviceId)}
</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{s.priceStroops} stroops / request
</span>
</Link>
<ServiceCopyButton serviceId={s.serviceId} />
</li>
))}
</ul>
)}
{!loading && !error && (
<Pagination
page={page}
pageCount={pageCount}
onChange={onPageChange}
/>
)}
<ServicesSectionBoundary
sectionName="Service list"
errorCode={SERVICES_SECTION_ERROR_CODES.serviceList}
>
{loading ? (
<ServicesDashboardSkeleton />
) : services ? (
<ServicesResults
services={services}
sortKey={sortKey}
sortDir={sortDir}
/>
) : null}
</ServicesSectionBoundary>
<ServicesSectionBoundary
sectionName="Pagination"
errorCode={SERVICES_SECTION_ERROR_CODES.pagination}
>
{!loading && !error && (
<Pagination
page={page}
pageCount={pageCount}
onChange={onPageChange}
/>
)}
</ServicesSectionBoundary>
</PageShell>
);
}
Expand Down
Loading
Loading