mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-20 10:24:15 +00:00
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:
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'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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+3
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user