mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-26 21:34:19 +00:00
feat(ui): add skeleton loading handoffs
- Add reusable shadcn skeleton scanner and reveal primitives - Wrap page-level loading states with skeleton content handoffs - Document skeleton usage through a project skill
This commit is contained in:
29 files changed
+455
-98
No files matched your search
@@ -35,6 +35,7 @@ Use these skills for detailed patterns on-demand:
|
||||
| `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) |
|
||||
| `prowler-api` | Django + RLS + JSON:API patterns | [SKILL.md](skills/prowler-api/SKILL.md) |
|
||||
| `prowler-ui` | Next.js + shadcn conventions | [SKILL.md](skills/prowler-ui/SKILL.md) |
|
||||
| `prowler-ui-skeletons` | shadcn skeleton loading and content reveal conventions | [SKILL.md](skills/prowler-ui-skeletons/SKILL.md) |
|
||||
| `prowler-sdk-check` | Create new security checks | [SKILL.md](skills/prowler-sdk-check/SKILL.md) |
|
||||
| `prowler-mcp` | MCP server tools and models | [SKILL.md](skills/prowler-mcp/SKILL.md) |
|
||||
| `prowler-test-sdk` | SDK testing (pytest + moto) | [SKILL.md](skills/prowler-test-sdk/SKILL.md) |
|
||||
@@ -85,6 +86,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` |
|
||||
| Creating/modifying models, views, serializers | `prowler-api` |
|
||||
| Creating/updating compliance frameworks | `prowler-compliance` |
|
||||
| Debug why a GitHub Actions job is failing | `prowler-ci` |
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: prowler-ui-skeletons
|
||||
description: "Trigger: skeleton, loading state, Suspense fallback, content reveal, shimmer. Use Prowler shadcn skeletons correctly."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "1.0"
|
||||
scope: [root, ui]
|
||||
auto_invoke:
|
||||
- "Creating/modifying skeletons"
|
||||
- "Creating/modifying loading states"
|
||||
- "Adding Suspense fallbacks"
|
||||
---
|
||||
|
||||
## Activation Contract
|
||||
|
||||
Use this skill before creating or modifying any Prowler UI skeleton, loading placeholder, Suspense fallback, or loading-to-content transition.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Prefer shadcn `Skeleton` from `@/components/shadcn`; do not add new HeroUI skeletons.
|
||||
- Do not mix HeroUI and shadcn inside the same new loading surface.
|
||||
- Keep scanner/shimmer behavior centralized in shadcn `Skeleton`; never duplicate scanner CSS in feature files.
|
||||
- For Suspense data loading, wrap the boundary with `SkeletonBoundary` so fallback removal and real content reveal are paired.
|
||||
- For client-state loading (`isLoading`, drawers, modals, expanded rows), add a reveal wrapper around the resolved content, not around the skeleton.
|
||||
- Respect `motion-reduce`; every animation must degrade to no transform/transition.
|
||||
- Preserve layout stability: skeleton dimensions must match the final content as closely as practical.
|
||||
- Do not migrate legacy/HeroUI skeletons unless the task explicitly includes that migration.
|
||||
|
||||
## Decision Gates
|
||||
|
||||
| Situation | Action |
|
||||
| --- | --- |
|
||||
| Page/server data with `Suspense` fallback | Use `SkeletonBoundary` with the skeleton fallback. |
|
||||
| Nested Suspense inside tab/chart content | Use `SkeletonBoundary` unless the fallback is legacy/HeroUI. |
|
||||
| Client state swaps skeleton to content | Keep shadcn `Skeleton`; wrap resolved content with `SkeletonContentReveal` or an equivalent shared reveal. |
|
||||
| Existing HeroUI skeleton | Leave unchanged unless migration is explicitly requested. |
|
||||
| Text-only `Loading...` fallback | Replace only if the requested scope includes that surface. |
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. Identify whether the skeleton is shadcn, HeroUI legacy, or text-only fallback.
|
||||
2. If shadcn + Suspense, use `SkeletonBoundary` instead of raw `Suspense`.
|
||||
3. If shadcn + client state, keep the skeleton fallback and reveal only the loaded content.
|
||||
4. Verify reduced-motion classes remain present.
|
||||
5. Add or update focused tests when changing shared skeleton primitives or reusable boundaries.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report:
|
||||
- Which loading surfaces changed.
|
||||
- Whether each surface is Suspense-boundary or client-state loading.
|
||||
- Which legacy/HeroUI skeletons were intentionally left untouched.
|
||||
- Test/typecheck evidence when implementation changes are made.
|
||||
|
||||
## References
|
||||
|
||||
- `ui/components/shadcn/skeleton/skeleton.tsx`
|
||||
- `ui/components/shadcn/skeleton/skeleton-boundary.tsx`
|
||||
- `ui/components/shadcn/skeleton/skeleton-content-reveal.tsx`
|
||||
+31
-29
@@ -3,6 +3,7 @@
|
||||
> **Skills Reference**: For detailed patterns, use these skills:
|
||||
>
|
||||
> - [`prowler-ui`](../skills/prowler-ui/SKILL.md) - Prowler-specific UI patterns
|
||||
> - [`prowler-ui-skeletons`](../skills/prowler-ui-skeletons/SKILL.md) - shadcn skeleton loading and content reveal conventions
|
||||
> - [`prowler-test-ui`](../skills/prowler-test-ui/SKILL.md) - Playwright E2E testing (comprehensive)
|
||||
> - [`typescript`](../skills/typescript/SKILL.md) - Const types, flat interfaces
|
||||
> - [`react-19`](../skills/react-19/SKILL.md) - No useMemo/useCallback, compiler
|
||||
@@ -19,35 +20,36 @@
|
||||
|
||||
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
| Action | Skill |
|
||||
| -------------------------------------------------------------- | ------------------- |
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Modifying component | `tdd` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
| Action | Skill |
|
||||
| ------------------------------------------------------------------- | ---------------------- |
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Modifying component | `tdd` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Info } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
getComplianceOverviewMetadataInfo,
|
||||
@@ -16,6 +15,7 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
|
||||
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { SkeletonBoundary } from "@/components/shadcn/skeleton/skeleton-boundary";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
|
||||
import {
|
||||
@@ -156,7 +156,7 @@ export default async function Compliance({
|
||||
)}
|
||||
|
||||
{/* Row 3: Compliance grid with client-side search */}
|
||||
<Suspense
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={
|
||||
<ComplianceOverviewPanel>
|
||||
@@ -169,7 +169,7 @@ export default async function Compliance({
|
||||
scanId={selectedScanId}
|
||||
selectedScan={selectedScanData}
|
||||
/>
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</>
|
||||
) : (
|
||||
<NoScansAvailable />
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingGroupsResponse,
|
||||
getFindingGroups,
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
FindingsGroupTable,
|
||||
SkeletonTableFindings,
|
||||
} from "@/components/findings/table";
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import {
|
||||
@@ -111,12 +110,12 @@ export default async function Findings({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableFindings />}>
|
||||
<SkeletonBoundary fallback={<SkeletonTableFindings />}>
|
||||
<SSRDataTable
|
||||
searchParams={resolvedSearchParams}
|
||||
filters={resolvedFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</FilterTransitionWrapper>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getInvitations } from "@/actions/invitations/invitation";
|
||||
import { getRoles } from "@/actions/roles";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
ColumnsInvitation,
|
||||
SkeletonTableInvitation,
|
||||
} from "@/components/invitations/table";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Button, SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { InvitationProps, Role, SearchParamsProps } from "@/types";
|
||||
@@ -39,9 +38,12 @@ export default async function Invitations({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableInvitation />}>
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableInvitation />}
|
||||
>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
|
||||
@@ -18,9 +17,12 @@ export default async function MutelistPage({
|
||||
<ContentLayout title="Mutelist" icon="lucide:volume-x">
|
||||
<MutelistTabs
|
||||
simpleContent={
|
||||
<Suspense key={searchParamsKey} fallback={<MuteRulesTableSkeleton />}>
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={<MuteRulesTableSkeleton />}
|
||||
>
|
||||
<MuteRulesTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
}
|
||||
/>
|
||||
</ContentLayout>
|
||||
|
||||
+28
-20
@@ -1,7 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
@@ -47,55 +46,64 @@ export default async function Home({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<SkeletonBoundary
|
||||
fallback={<ThreatScoreSkeleton />}
|
||||
className="w-full lg:max-w-[312px]"
|
||||
>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<SkeletonBoundary
|
||||
fallback={<StatusChartSkeleton />}
|
||||
className="min-w-[312px] flex-1 md:min-w-[380px]"
|
||||
>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<SkeletonBoundary
|
||||
fallback={<RiskSeverityChartSkeleton />}
|
||||
className="min-w-[312px] flex-1 md:min-w-[380px]"
|
||||
>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<ResourcesInventorySkeleton />}>
|
||||
<SkeletonBoundary fallback={<ResourcesInventorySkeleton />}>
|
||||
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:flex-wrap sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<SkeletonBoundary fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<SkeletonBoundary fallback={<WatchlistCardSkeleton />}>
|
||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts column: Attack Surface on top, Findings Over Time below */}
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
<Suspense fallback={<AttackSurfaceSkeleton />}>
|
||||
<SkeletonBoundary fallback={<AttackSurfaceSkeleton />}>
|
||||
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
|
||||
</SkeletonBoundary>
|
||||
<SkeletonBoundary fallback={<FindingSeverityOverTimeSkeleton />}>
|
||||
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<RiskPipelineViewSkeleton />}>
|
||||
<SkeletonBoundary fallback={<RiskPipelineViewSkeleton />}>
|
||||
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ProvidersAccountsView } from "@/components/providers";
|
||||
import { SkeletonTableProviders } from "@/components/providers/table";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { Skeleton, SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
@@ -30,20 +28,20 @@ export default async function Providers({
|
||||
<ProviderPageTabs
|
||||
activeTab={activeTab}
|
||||
providersContent={
|
||||
<Suspense
|
||||
<SkeletonBoundary
|
||||
key={`providers-${searchParamsKey}`}
|
||||
fallback={<ProvidersTableFallback />}
|
||||
>
|
||||
<ProvidersTabContent searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
}
|
||||
providerGroupsContent={
|
||||
<Suspense
|
||||
<SkeletonBoundary
|
||||
key={`groups-${searchParamsKey}`}
|
||||
fallback={<ProviderGroupsFallback />}
|
||||
>
|
||||
<ProviderGroupsContent searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
}
|
||||
/>
|
||||
</FilterTransitionWrapper>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import {
|
||||
getLatestMetadataInfo,
|
||||
@@ -11,6 +9,7 @@ import {
|
||||
import { ResourcesFilters } from "@/components/resources/resources-filters";
|
||||
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
|
||||
import { ResourcesTableWithSelection } from "@/components/resources/table";
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import {
|
||||
@@ -86,12 +85,12 @@ export default async function Resources({
|
||||
uniqueGroups={uniqueGroups}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableResources />}>
|
||||
<SkeletonBoundary fallback={<SkeletonTableResources />}>
|
||||
<SSRDataTable
|
||||
searchParams={resolvedSearchParams}
|
||||
initialResource={processedResource}
|
||||
/>
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</FilterTransitionWrapper>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getRoles } from "@/actions/roles";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { filterRoles } from "@/components/filters/data-filters";
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import { ColumnsRoles, SkeletonTableRoles } from "@/components/roles/table";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Button, SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
@@ -34,9 +33,12 @@ export default async function Roles({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableRoles />}>
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableRoles />}
|
||||
>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { auth } from "@/auth.config";
|
||||
@@ -12,6 +10,7 @@ import { ScansPageShell } from "@/components/scans/scans-page-shell";
|
||||
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
|
||||
import { SkeletonTableScans } from "@/components/scans/table";
|
||||
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import {
|
||||
ProviderProps,
|
||||
@@ -88,7 +87,7 @@ export default async function Scans({
|
||||
hasManageScansPermission={hasManageScansPermission}
|
||||
activeScanCount={activeScanCount}
|
||||
>
|
||||
<Suspense
|
||||
<SkeletonBoundary
|
||||
fallback={
|
||||
<SkeletonTableScans
|
||||
tab={getScanJobsTab(resolvedSearchParams.tab)}
|
||||
@@ -96,7 +95,7 @@ export default async function Scans({
|
||||
}
|
||||
>
|
||||
<SSRDataTableScans searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</ScansPageShell>
|
||||
)}
|
||||
</ContentLayout>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getRoles } from "@/actions/roles/roles";
|
||||
import { getCurrentUserTenantRole, getUsers } from "@/actions/users/users";
|
||||
import { auth } from "@/auth.config";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Button, SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
|
||||
@@ -35,9 +34,12 @@ export default async function Users({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableUser />}>
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableUser />}
|
||||
>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[background-color,border-color,color,box-shadow] duration-200 ease-out motion-reduce:transition-none overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -20,6 +20,8 @@ export * from "./select/multiselect";
|
||||
export * from "./select/select";
|
||||
export * from "./separator/separator";
|
||||
export * from "./skeleton/skeleton";
|
||||
export * from "./skeleton/skeleton-boundary";
|
||||
export * from "./skeleton/skeleton-content-reveal";
|
||||
export * from "./tabs/generic-tabs";
|
||||
export * from "./tabs/tabs";
|
||||
export * from "./textarea/textarea";
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Progress } from "./progress";
|
||||
|
||||
describe("Progress", () => {
|
||||
it("animates progress value changes with a transform-only transition", () => {
|
||||
// Given
|
||||
render(<Progress aria-label="Scan progress" value={40} />);
|
||||
|
||||
// When
|
||||
const root = screen.getByRole("progressbar", { name: /scan progress/i });
|
||||
const indicator = root.querySelector("[data-slot='progress-indicator']");
|
||||
|
||||
// Then
|
||||
expect(root).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(indicator).toHaveClass(
|
||||
"transition-transform",
|
||||
"duration-300",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(indicator).toHaveStyle({ transform: "translateX(-60%)" });
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ function Progress({
|
||||
data-slot="progress"
|
||||
value={normalizedValue}
|
||||
className={cn(
|
||||
"border-border-neutral-secondary bg-bg-neutral-secondary relative h-2 w-full overflow-hidden rounded-full border",
|
||||
"border-border-neutral-secondary bg-bg-neutral-secondary relative h-2 w-full overflow-hidden rounded-full border transition-colors duration-200 ease-out motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -30,7 +30,7 @@ function Progress({
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn(
|
||||
"bg-button-primary h-full w-full flex-1 transition-all",
|
||||
"bg-button-primary h-full w-full flex-1 transition-transform duration-300 ease-out motion-reduce:transition-none",
|
||||
indicatorClassName,
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - normalizedValue}%)` }}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Skeleton } from "./skeleton";
|
||||
import { SkeletonBoundary } from "./skeleton-boundary";
|
||||
|
||||
describe("SkeletonBoundary", () => {
|
||||
it("wraps resolved content with the shared skeleton reveal", () => {
|
||||
// Given
|
||||
render(
|
||||
<SkeletonBoundary fallback={<Skeleton aria-label="Loading content" />}>
|
||||
<section aria-label="Resolved content">Ready</section>
|
||||
</SkeletonBoundary>,
|
||||
);
|
||||
|
||||
// When
|
||||
const reveal = screen.getByTestId("skeleton-content-reveal");
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Resolved content")).toBeInTheDocument();
|
||||
expect(reveal).toHaveAttribute("data-motion", "skeleton-content-handoff");
|
||||
});
|
||||
|
||||
it("forwards className to the reveal wrapper", () => {
|
||||
// Given
|
||||
render(
|
||||
<SkeletonBoundary
|
||||
fallback={<Skeleton aria-label="Loading content" />}
|
||||
className="custom-boundary"
|
||||
>
|
||||
Ready
|
||||
</SkeletonBoundary>,
|
||||
);
|
||||
|
||||
// When
|
||||
const reveal = screen.getByTestId("skeleton-content-reveal");
|
||||
|
||||
// Then
|
||||
expect(reveal).toHaveClass("custom-boundary");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ReactNode, Suspense } from "react";
|
||||
|
||||
import { SkeletonContentReveal } from "./skeleton-content-reveal";
|
||||
|
||||
interface SkeletonBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SkeletonBoundary({
|
||||
children,
|
||||
fallback,
|
||||
className,
|
||||
}: SkeletonBoundaryProps) {
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<SkeletonContentReveal className={className}>
|
||||
{children}
|
||||
</SkeletonContentReveal>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export { SkeletonBoundary };
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SkeletonContentReveal } from "./skeleton-content-reveal";
|
||||
|
||||
describe("SkeletonContentReveal", () => {
|
||||
it("reveals streamed content with insertion-time CSS motion", () => {
|
||||
// Given
|
||||
render(
|
||||
<SkeletonContentReveal>
|
||||
<section aria-label="Loaded content">Ready</section>
|
||||
</SkeletonContentReveal>,
|
||||
);
|
||||
|
||||
// When
|
||||
const wrapper = screen.getByTestId("skeleton-content-reveal");
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Loaded content")).toBeInTheDocument();
|
||||
expect(wrapper).toHaveAttribute("data-motion", "skeleton-content-handoff");
|
||||
expect(wrapper).toHaveClass(
|
||||
"transition-[opacity,transform]",
|
||||
"duration-700",
|
||||
"starting:opacity-0",
|
||||
"starting:translate-y-3",
|
||||
"opacity-100",
|
||||
"translate-y-0",
|
||||
"motion-reduce:transform-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("merges caller classes without dropping the motion contract", () => {
|
||||
// Given
|
||||
render(
|
||||
<SkeletonContentReveal className="custom-reveal">
|
||||
Ready
|
||||
</SkeletonContentReveal>,
|
||||
);
|
||||
|
||||
// When
|
||||
const wrapper = screen.getByTestId("skeleton-content-reveal");
|
||||
|
||||
// Then
|
||||
expect(wrapper).toHaveClass(
|
||||
"custom-reveal",
|
||||
"transition-[opacity,transform]",
|
||||
"starting:opacity-0",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SkeletonContentRevealProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SkeletonContentReveal({
|
||||
children,
|
||||
className,
|
||||
}: SkeletonContentRevealProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="skeleton-content-reveal"
|
||||
data-motion="skeleton-content-handoff"
|
||||
className={cn(
|
||||
"translate-y-0 opacity-100 transition-[opacity,transform] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transform-none motion-reduce:transition-none starting:translate-y-3 starting:opacity-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SkeletonContentReveal };
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Skeleton } from "./skeleton";
|
||||
|
||||
describe("Skeleton", () => {
|
||||
it("uses a subtle scanner animation that respects reduced motion", () => {
|
||||
// Given
|
||||
render(<Skeleton aria-label="Loading providers" />);
|
||||
|
||||
// When
|
||||
const skeleton = screen.getByLabelText("Loading providers");
|
||||
|
||||
// Then
|
||||
expect(skeleton).toHaveClass(
|
||||
"relative",
|
||||
"overflow-hidden",
|
||||
"bg-border-neutral-tertiary",
|
||||
"transition-colors",
|
||||
"duration-500",
|
||||
"ease-out",
|
||||
);
|
||||
|
||||
const scanner = skeleton.querySelector("[data-slot='skeleton-scanner']");
|
||||
expect(scanner).toHaveClass(
|
||||
"animate-skeleton-scan",
|
||||
"bg-gradient-to-r",
|
||||
"from-transparent",
|
||||
"via-white/10",
|
||||
"to-transparent",
|
||||
"motion-reduce:hidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,25 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function Skeleton({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn(
|
||||
"bg-border-neutral-tertiary animate-pulse rounded-md",
|
||||
"bg-border-neutral-tertiary relative overflow-hidden rounded-md transition-colors duration-500 ease-out motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
data-slot="skeleton-scanner"
|
||||
className="animate-skeleton-scan pointer-events-none absolute inset-y-0 -left-1/2 w-1/2 bg-gradient-to-r from-transparent via-white/10 to-transparent motion-reduce:hidden"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,4 +33,22 @@ describe("LoadingState", () => {
|
||||
const { container } = render(<LoadingState className="custom-wrapper" />);
|
||||
expect(container.firstChild).toHaveClass("custom-wrapper");
|
||||
});
|
||||
|
||||
it("animates the loading state entry and label color subtly", () => {
|
||||
const { container } = render(<LoadingState label="Loading findings..." />);
|
||||
expect(container.firstChild).toHaveClass(
|
||||
"animate-in",
|
||||
"fade-in-0",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(screen.getByText("Loading findings...")).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,16 @@ export function LoadingState({
|
||||
}: LoadingStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-center gap-2 py-8", className)}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 flex items-center justify-center gap-2 py-8 duration-200 ease-out motion-reduce:animate-none motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Spinner className={cn("size-6", spinnerClassName)} />
|
||||
{label && (
|
||||
<span className="text-text-neutral-tertiary text-sm">{label}</span>
|
||||
<span className="text-text-neutral-tertiary text-sm transition-colors duration-200 ease-out motion-reduce:transition-none">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,10 @@ interface SpinnerProps {
|
||||
export function Spinner({ className }: SpinnerProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("size-5 shrink-0 animate-spin", className)}
|
||||
className={cn(
|
||||
"size-5 shrink-0 animate-spin motion-reduce:animate-none",
|
||||
className,
|
||||
)}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -22,9 +22,26 @@ describe("StatusBadge", () => {
|
||||
);
|
||||
|
||||
it("renders the executing state with spinner and progress percentage", () => {
|
||||
render(<StatusBadge status="executing" loadingProgress={42} />);
|
||||
const { container } = render(
|
||||
<StatusBadge status="executing" loadingProgress={42} />,
|
||||
);
|
||||
expect(screen.getByText("executing")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toHaveClass(
|
||||
"animate-spin",
|
||||
"motion-reduce:animate-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("animates status color changes without layout motion", () => {
|
||||
const { container } = render(<StatusBadge status="completed" />);
|
||||
const badge = container.querySelector("[data-slot='badge']");
|
||||
expect(badge).toHaveClass(
|
||||
"transition-[background-color,border-color,color,box-shadow]",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits progress when loadingProgress is not provided", () => {
|
||||
|
||||
@@ -68,7 +68,10 @@ export const StatusBadge = ({
|
||||
>
|
||||
{status === "executing" ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<SpinnerIcon size={12} className="animate-spin" />
|
||||
<SpinnerIcon
|
||||
size={12}
|
||||
className="animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
{loadingProgress !== undefined && (
|
||||
<span className="text-[0.6rem]">{loadingProgress}%</span>
|
||||
)}
|
||||
|
||||
@@ -409,6 +409,21 @@
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes skeletonScan {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
55%,
|
||||
100% {
|
||||
transform: translateX(300%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-skeleton-scan {
|
||||
animation: skeletonScan 2.6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== BASE LAYER ===== */
|
||||
|
||||
Reference in new issue
Block a user