feat(ui): add nested skeleton handoffs

- Reveal loaded content for nested shadcn skeleton states
- Cover finding drawers, modals, and inline resource tables
- Extend handoffs to alert, mutelist, compliance, and overview surfaces
This commit is contained in:
Alan Buscaglia committed 2026-06-11 16:48:14 +02:00
1 parent b736ac320f
commit eff97e7404
8 files changed
+349 -314

No files matched your search

@@ -2,6 +2,7 @@ import { Skeleton } from "@heroui/skeleton";
import { Suspense } from "react";
import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table";
import { SkeletonBoundary } from "@/components/shadcn";
import { SearchParamsProps } from "@/types";
import { GraphsTabsClient } from "./_components/graphs-tabs-client";
@@ -44,10 +45,21 @@ export const GraphsTabsWrapper = async ({
GRAPH_TABS.map((tab) => {
const Component = GRAPH_COMPONENTS[tab.id];
const fallback = TAB_FALLBACKS[tab.id] ?? <LoadingFallback />;
const content = <Component searchParams={searchParams} />;
if (tab.id === "findings") {
return [
tab.id,
<SkeletonBoundary key={tab.id} fallback={fallback}>
{content}
</SkeletonBoundary>,
];
}
return [
tab.id,
<Suspense key={tab.id} fallback={fallback}>
<Component searchParams={searchParams} />
{content}
</Suspense>,
];
}),
@@ -45,6 +45,7 @@ import {
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
@@ -175,17 +176,19 @@ const PreviewSummarySkeleton = () => (
const PreviewSummary = ({ preview }: { preview: PreviewState }) => {
if (preview.status === "error") {
return (
<Card variant="danger" padding="sm">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<Badge variant="tag">Error</Badge>
</div>
<p className="text-text-error-primary text-sm">{preview.error}</p>
</CardContent>
</Card>
<SkeletonContentReveal>
<Card variant="danger" padding="sm">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<Badge variant="tag">Error</Badge>
</div>
<p className="text-text-error-primary text-sm">{preview.error}</p>
</CardContent>
</Card>
</SkeletonContentReveal>
);
}
@@ -193,16 +196,18 @@ const PreviewSummary = ({ preview }: { preview: PreviewState }) => {
if (!data) return null;
return (
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<p className="text-text-neutral-secondary text-sm">
{getPreviewMessage(data)}
</p>
</CardContent>
</Card>
<SkeletonContentReveal>
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<p className="text-text-neutral-secondary text-sm">
{getPreviewMessage(data)}
</p>
</CardContent>
</Card>
</SkeletonContentReveal>
);
};
@@ -12,6 +12,7 @@ import {
} from "@/actions/processors";
import { Button, Card, Skeleton } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { fontMono } from "@/config/fonts";
@@ -187,111 +188,114 @@ export function AdvancedMutelistForm() {
</div>
</Modal>
<Card variant="base" className="p-6">
<form action={formAction} className="flex flex-col gap-4">
{config && <input type="hidden" name="id" value={config.id} />}
<SkeletonContentReveal>
<Card variant="base" className="p-6">
<form action={formAction} className="flex flex-col gap-4">
{config && <input type="hidden" name="id" value={config.id} />}
<div className="flex flex-col gap-4">
<div>
<h3 className="text-default-700 mb-2 text-lg font-semibold">
Advanced Mutelist Configuration
</h3>
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
<li>
<strong>
This Mutelist configuration will take effect on the next
scan.
</strong>
</li>
<li>
Use this for pattern-based muting with wildcards, regions, and
tags.
</li>
<li>
Learn more about configuring the Mutelist{" "}
<CustomLink
size="sm"
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-mute-findings"
>
here
</CustomLink>
.
</li>
<li>
A default Mutelist is used to exclude certain predefined
resources if no Mutelist is provided.
</li>
</ul>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="configuration"
className="text-default-700 text-sm font-medium"
>
Mutelist Configuration (YAML)
</label>
<div className="flex flex-col gap-4">
<div>
<Textarea
id="configuration"
name="configuration"
placeholder={defaultMutedFindingsConfig}
variant="bordered"
value={configText}
onChange={(e) => handleConfigChange(e.target.value)}
minRows={20}
maxRows={20}
isInvalid={
(!hasUserStartedTyping && !!state?.errors?.configuration) ||
!yamlValidation.isValid
}
errorMessage={
(!hasUserStartedTyping && state?.errors?.configuration) ||
(!yamlValidation.isValid ? yamlValidation.error : "")
}
classNames={{
input: fontMono.className + " text-sm",
base: "min-h-[400px]",
errorMessage: "whitespace-pre-wrap",
}}
/>
{yamlValidation.isValid &&
configText &&
hasUserStartedTyping && (
<div className="text-tiny text-success my-1 flex items-center px-1">
<span>Valid YAML format</span>
</div>
)}
<h3 className="text-default-700 mb-2 text-lg font-semibold">
Advanced Mutelist Configuration
</h3>
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
<li>
<strong>
This Mutelist configuration will take effect on the next
scan.
</strong>
</li>
<li>
Use this for pattern-based muting with wildcards, regions,
and tags.
</li>
<li>
Learn more about configuring the Mutelist{" "}
<CustomLink
size="sm"
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-mute-findings"
>
here
</CustomLink>
.
</li>
<li>
A default Mutelist is used to exclude certain predefined
resources if no Mutelist is provided.
</li>
</ul>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="configuration"
className="text-default-700 text-sm font-medium"
>
Mutelist Configuration (YAML)
</label>
<div>
<Textarea
id="configuration"
name="configuration"
placeholder={defaultMutedFindingsConfig}
variant="bordered"
value={configText}
onChange={(e) => handleConfigChange(e.target.value)}
minRows={20}
maxRows={20}
isInvalid={
(!hasUserStartedTyping &&
!!state?.errors?.configuration) ||
!yamlValidation.isValid
}
errorMessage={
(!hasUserStartedTyping && state?.errors?.configuration) ||
(!yamlValidation.isValid ? yamlValidation.error : "")
}
classNames={{
input: fontMono.className + " text-sm",
base: "min-h-[400px]",
errorMessage: "whitespace-pre-wrap",
}}
/>
{yamlValidation.isValid &&
configText &&
hasUserStartedTyping && (
<div className="text-tiny text-success my-1 flex items-center px-1">
<span>Valid YAML format</span>
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex w-full justify-end gap-4">
{config && (
<div className="flex w-full justify-end gap-4">
{config && (
<Button
type="button"
aria-label="Delete Configuration"
variant="outline"
size="lg"
onClick={() => setShowDeleteConfirmation(true)}
disabled={isPending || isDeleting}
>
<Trash2 className="size-4" />
Delete
</Button>
)}
<Button
type="button"
aria-label="Delete Configuration"
variant="outline"
type="submit"
size="lg"
onClick={() => setShowDeleteConfirmation(true)}
disabled={isPending || isDeleting}
disabled={
isPending || !yamlValidation.isValid || !configText.trim()
}
>
<Trash2 className="size-4" />
Delete
{isPending ? "Saving..." : config ? "Update" : "Save"}
</Button>
)}
<Button
type="submit"
size="lg"
disabled={
isPending || !yamlValidation.isValid || !configText.trim()
}
>
{isPending ? "Saving..." : config ? "Update" : "Save"}
</Button>
</div>
</form>
</Card>
</div>
</form>
</Card>
</SkeletonContentReveal>
</>
);
}
@@ -8,6 +8,7 @@ import {
getStandaloneFindingColumns,
SkeletonTableFindings,
} from "@/components/findings/table";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { Accordion } from "@/components/ui/accordion/Accordion";
import { DataTable } from "@/components/ui/table";
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
@@ -177,7 +178,7 @@ export const ClientAccordionContent = ({
if (findings?.data?.length && findings.data.length > 0) {
return (
<>
<SkeletonContentReveal>
<h4 className="mb-2 text-sm font-medium">Findings</h4>
<DataTable
@@ -186,14 +187,14 @@ export const ClientAccordionContent = ({
metadata={findings?.meta}
disableScroll={true}
/>
</>
</SkeletonContentReveal>
);
}
return (
<div className="mt-3 mb-1 text-sm font-medium text-gray-800 dark:text-gray-200">
<SkeletonContentReveal className="mt-3 mb-1 text-sm font-medium text-gray-800 dark:text-gray-200">
⚠️ There are no findings for these regions
</div>
</SkeletonContentReveal>
);
};
@@ -7,6 +7,7 @@ import { MuteRuleActionState } from "@/actions/mute-rules/types";
import { Button, Input, Textarea } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { FormButtons } from "@/components/ui/form";
import { Label } from "@/components/ui/form/Label";
import { useMuteRuleAction } from "@/hooks/use-mute-rule-action";
@@ -170,7 +171,7 @@ export function MuteFindingsModal({
</div>
</>
) : preparationError ? (
<>
<SkeletonContentReveal>
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
<p className="text-text-neutral-primary text-sm font-medium">
We couldn&apos;t prepare this mute action.
@@ -190,9 +191,9 @@ export function MuteFindingsModal({
Close
</Button>
</div>
</>
</SkeletonContentReveal>
) : (
<>
<SkeletonContentReveal>
<div className="space-y-4">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
@@ -315,7 +316,7 @@ export function MuteFindingsModal({
submitText="Mute Findings"
isDisabled={isPending}
/>
</>
</SkeletonContentReveal>
)}
</form>
</Modal>
+143 -141
View File
@@ -15,6 +15,7 @@ import {
import { Modal } from "@/components/shadcn/modal";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { useToast } from "@/components/ui";
import { CustomBanner } from "@/components/ui/custom/custom-banner";
import { Form, FormField, FormMessage } from "@/components/ui/form";
@@ -270,151 +271,152 @@ export const SendToJiraModal = ({
</div>
)}
{/* Integration Selection */}
{!isFetchingIntegrations && integrations.length > 1 && (
<FormField
control={form.control}
name="integration"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-integration-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Jira Integration
</label>
<EnhancedMultiSelect
id="jira-integration-select"
options={integrationOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset dependent fields
form.setValue("project", "");
form.setValue("issueType", "");
setFetchedIssueTypes({});
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira integration"
searchable={true}
emptyIndicator="No integrations found."
disabled={isFetchingIntegrations}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
{!isFetchingIntegrations && (
<SkeletonContentReveal className="flex flex-col gap-4">
{/* Integration Selection */}
{integrations.length > 1 && (
<FormField
control={form.control}
name="integration"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-integration-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Jira Integration
</label>
<EnhancedMultiSelect
id="jira-integration-select"
options={integrationOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset dependent fields
form.setValue("project", "");
form.setValue("issueType", "");
setFetchedIssueTypes({});
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira integration"
searchable={true}
emptyIndicator="No integrations found."
disabled={isFetchingIntegrations}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
/>
)}
{/* Project Selection */}
{!isFetchingIntegrations &&
selectedIntegration &&
projectEntries.length > 0 && (
<FormField
control={form.control}
name="project"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-project-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Project
</label>
<EnhancedMultiSelect
id="jira-project-select"
options={projectOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset issue type when project changes
form.setValue("issueType", "");
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira project"
searchable={true}
emptyIndicator="No projects found."
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
{/* Issue Type Selection */}
{selectedProject && (
<FormField
control={form.control}
name="issueType"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-issue-type-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Issue Type
</label>
<EnhancedMultiSelect
id="jira-issue-type-select"
options={issueTypeOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
}}
defaultValue={field.value ? [field.value] : []}
placeholder={
isFetchingIssueTypes
? "Loading issue types..."
: "Select an issue type"
}
searchable={true}
emptyIndicator="No issue types found."
disabled={isFetchingIssueTypes}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
{/* Project Selection */}
{selectedIntegration && projectEntries.length > 0 && (
<FormField
control={form.control}
name="project"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-project-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Project
</label>
<EnhancedMultiSelect
id="jira-project-select"
options={projectOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset issue type when project changes
form.setValue("issueType", "");
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira project"
searchable={true}
emptyIndicator="No projects found."
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
/>
)}
{/* No integrations or none connected message */}
{!isFetchingIntegrations &&
(integrations.length === 0 || !hasConnectedIntegration) ? (
<CustomBanner
title="Jira integration is not available"
message="Please add or connect an integration first"
buttonLabel="Configure"
buttonLink="/integrations/jira"
/>
) : (
<FormButtons
setIsOpen={setOpenForFormButtons}
onCancel={() => onOpenChange(false)}
submitText="Send to Jira"
cancelText="Cancel"
loadingText="Sending..."
isDisabled={
!form.formState.isValid ||
form.formState.isSubmitting ||
isFetchingIntegrations ||
isFetchingIssueTypes ||
integrations.length === 0 ||
!hasConnectedIntegration
}
rightIcon={<Send size={20} />}
/>
{/* Issue Type Selection */}
{selectedProject && (
<FormField
control={form.control}
name="issueType"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-issue-type-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Issue Type
</label>
<EnhancedMultiSelect
id="jira-issue-type-select"
options={issueTypeOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
}}
defaultValue={field.value ? [field.value] : []}
placeholder={
isFetchingIssueTypes
? "Loading issue types..."
: "Select an issue type"
}
searchable={true}
emptyIndicator="No issue types found."
disabled={isFetchingIssueTypes}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
{/* No integrations or none connected message */}
{integrations.length === 0 || !hasConnectedIntegration ? (
<CustomBanner
title="Jira integration is not available"
message="Please add or connect an integration first"
buttonLabel="Configure"
buttonLink="/integrations/jira"
/>
) : (
<FormButtons
setIsOpen={setOpenForFormButtons}
onCancel={() => onOpenChange(false)}
submitText="Send to Jira"
cancelText="Cancel"
loadingText="Sending..."
isDisabled={
!form.formState.isValid ||
form.formState.isSubmitting ||
isFetchingIntegrations ||
isFetchingIssueTypes ||
integrations.length === 0 ||
!hasConnectedIntegration
}
rightIcon={<Send size={20} />}
/>
)}
</SkeletonContentReveal>
)}
</form>
</Form>
@@ -10,6 +10,7 @@ import { ChevronsDown } from "lucide-react";
import { useImperativeHandle, useRef } from "react";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { TableCell, TableRow } from "@/components/ui/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
@@ -233,56 +234,64 @@ export function InlineResourceContainer({
className="max-h-[440px] overflow-y-auto pl-6"
>
{/* Resource rows or skeleton placeholder */}
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<tbody>
{isLoading && rows.length === 0 ? (
Array.from({ length: skeletonRowCount }).map((_, i) => (
{isLoading && rows.length === 0 ? (
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<tbody>
{Array.from({ length: skeletonRowCount }).map((_, i) => (
<ResourceSkeletonRow
key={i}
isEmptyStateSized={filteredResourceCount === 0}
/>
))
) : rows.length > 0 ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={(e) => {
// Don't open drawer if clicking interactive elements
// (links, buttons, checkboxes, dropdown items)
const target = e.target as HTMLElement;
if (
target.closest(
"a, button, input, [role=menuitem]",
)
)
return;
drawer.openDrawer(row.index);
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
))}
</tbody>
</table>
) : (
<SkeletonContentReveal>
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<tbody>
{rows.length > 0 ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={(e) => {
// Don't open drawer if clicking interactive elements
// (links, buttons, checkboxes, dropdown items)
const target = e.target as HTMLElement;
if (
target.closest(
"a, button, input, [role=menuitem]",
)
)
return;
drawer.openDrawer(row.index);
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{getFindingGroupEmptyStateMessage(group, filters)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{getFindingGroupEmptyStateMessage(group, filters)}
</TableCell>
</TableRow>
)}
</tbody>
</table>
</TableRow>
)}
</tbody>
</table>
</SkeletonContentReveal>
)}
{/* Loading state for infinite scroll (subsequent pages only) */}
{isLoading && rows.length > 0 && (
@@ -37,6 +37,7 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import {
Tooltip,
@@ -494,7 +495,7 @@ export function ResourceDetailDrawerContent({
};
return (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<SkeletonContentReveal className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
{/* Mute modal — rendered outside drawer content to avoid overlay conflicts */}
{f && !f.isMuted && (
<MuteFindingsModal
@@ -1339,7 +1340,7 @@ export function ResourceDetailDrawerContent({
Analyze This Finding With Lighthouse AI
</a>
)}
</div>
</SkeletonContentReveal>
);
}