refactor(ui): improve resource detail and tab UX (#11325)

This commit is contained in:
Alejandro Bailo
2026-05-22 12:03:03 +02:00
committed by GitHub
parent 6f01041178
commit 3a096b1750
13 changed files with 385 additions and 140 deletions
+4
View File
@@ -9,6 +9,10 @@ All notable changes to the **Prowler UI** are documented in this file.
- `okta` provider support with OAuth 2.0 private-key JWT credentials form (client ID + PEM private key) [(#11213)](https://github.com/prowler-cloud/prowler/pull/11213)
- "Resource Metadata / Evidence" tab in the finding detail drawer [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187)
### 🐞 Fixed
- Resource detail panels: metadata editor now scrolls internally with the minimal scrollbar across the finding drawer and `/resources/:id`, tab labels truncate with tooltips on narrow widths, and "View in AWS Console" moved from the resource UID row to the resource actions menu [(#11325)](https://github.com/prowler-cloud/prowler/pull/11325)
---
## [1.27.0] (Prowler v5.27.0)
@@ -91,12 +91,14 @@ vi.mock("@/components/shadcn", () => {
InfoField: ({
children,
label,
className,
}: {
children: ReactNode;
label: string;
variant?: string;
className?: string;
}) => (
<div>
<div className={className}>
<span>{label}</span>
{children}
</div>
@@ -280,12 +282,6 @@ vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
),
}));
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ children, href }: { children: ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/ui/entities/date-with-time", () => ({
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
}));
@@ -784,12 +780,19 @@ describe("ResourceDetailDrawerContent — CVE recommendation button", () => {
);
expect(screen.getByText(statusExtendedWithFixVersions)).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "View in Prowler Hub" }),
).toHaveAttribute(
const hubLink = screen.getByRole("link", { name: "View in Prowler Hub" });
expect(hubLink).toHaveAttribute(
"href",
"https://hub.prowler.com/check/image_vulnerability",
);
expect(hubLink).toHaveAttribute("target", "_blank");
expect(hubLink).toHaveAttribute("rel", "noopener noreferrer");
const headingRow = screen.getByTestId("remediation-heading-row");
expect(within(headingRow).getByText("Remediation:")).toBeInTheDocument();
expect(hubLink).toHaveClass("shrink-0", "whitespace-nowrap");
expect(
within(headingRow).queryByText("Open the check in Hub"),
).not.toBeInTheDocument();
});
it("should render the official CVE reference", () => {
@@ -836,10 +839,12 @@ describe("ResourceDetailDrawerContent — CVE recommendation button", () => {
"href",
externalCveUrl,
);
expect(screen.getByRole("link", { name: externalCveUrl })).toHaveAttribute(
"href",
externalCveUrl,
);
const referenceLink = screen.getByRole("link", { name: externalCveUrl });
expect(referenceLink).toHaveAttribute("href", externalCveUrl);
expect(referenceLink).toHaveAttribute("target", "_blank");
expect(referenceLink).toHaveAttribute("rel", "noopener noreferrer");
expect(referenceLink).toHaveClass("break-all", "text-left");
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
it("should render View Advisory when the recommendation URL points to GitHub Security Advisories", () => {
@@ -1345,6 +1350,64 @@ describe("ResourceDetailDrawerContent — synthetic resource empty state", () =>
});
describe("ResourceDetailDrawerContent — current resource row display", () => {
it("should place service and region in the primary metadata row after provider and resource", () => {
// Given/When
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// Then
const primaryMetadataRow = screen.getByTestId(
"resource-detail-primary-metadata-row",
);
expect(primaryMetadataRow).toHaveClass("grid-cols-2");
expect(primaryMetadataRow).toHaveClass(
"@md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)]",
);
expect(
within(primaryMetadataRow).getByText("Provider"),
).toBeInTheDocument();
expect(
within(primaryMetadataRow).getByText("Resource"),
).toBeInTheDocument();
expect(within(primaryMetadataRow).getByText("Service")).toBeInTheDocument();
expect(within(primaryMetadataRow).getByText("Region")).toBeInTheDocument();
expect(within(primaryMetadataRow).getByText("s3")).toHaveClass(
"truncate",
"whitespace-nowrap",
);
expect(within(primaryMetadataRow).getByText("us-east-1")).toHaveClass(
"truncate",
);
const secondaryMetadataRow = screen.getByTestId(
"resource-detail-secondary-metadata-row",
);
expect(secondaryMetadataRow).toHaveClass("grid-cols-2");
expect(secondaryMetadataRow).toHaveClass("@md:grid-cols-3");
expect(
within(secondaryMetadataRow).queryByText("Service"),
).not.toBeInTheDocument();
expect(
within(secondaryMetadataRow).queryByText("Region"),
).not.toBeInTheDocument();
expect(within(secondaryMetadataRow).getByText("2 days")).toHaveClass(
"truncate",
"whitespace-nowrap",
);
});
it("should render resource card fields from the current resource row instead of the fetched finding", () => {
// Given
const currentResource: FindingResourceRow = {
@@ -1481,10 +1544,10 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
expect(screen.getByText("ec2")).toBeInTheDocument();
expect(screen.getByText("eu-west-1")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Finding Overview" }),
screen.getByRole("button", { name: "Overview" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Findings for this resource" }),
screen.getByRole("button", { name: "Other findings" }),
).toBeInTheDocument();
expect(screen.queryByText("uid-1")).not.toBeInTheDocument();
expect(screen.queryByText("Status extended")).not.toBeInTheDocument();
@@ -1594,10 +1657,10 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
expect(screen.queryByText("Description:")).not.toBeInTheDocument();
expect(screen.queryByText("Remediation:")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Finding Overview" }),
screen.getByRole("button", { name: "Overview" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Findings for this resource" }),
screen.getByRole("button", { name: "Other findings" }),
).toBeInTheDocument();
});
@@ -1796,7 +1859,7 @@ describe("ResourceDetailDrawerContent — Metadata tab", () => {
// Then
expect(
screen.getByRole("button", { name: "Resource Metadata / Evidence" }),
screen.getByRole("button", { name: "Evidence" }),
).toBeInTheDocument();
});
@@ -44,10 +44,7 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import {
ExternalResourceLink,
resolveExternalTarget,
} from "@/components/shared/external-resource-link";
import { resolveExternalTarget } from "@/components/shared/external-resource-link";
import {
QUERY_EDITOR_LANGUAGE,
QueryCodeEditor,
@@ -55,7 +52,6 @@ import {
} from "@/components/shared/query-code-editor";
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { EntityInfo } from "@/components/ui/entities/entity-info";
import {
@@ -442,7 +438,6 @@ export function ResourceDetailDrawerContent({
findingUid: f?.uid,
region: resourceRegion,
});
const hasIdAction = Boolean(externalResourceTarget);
const findingRecommendationUrl = f?.remediation.recommendation.url;
const checkRecommendationUrl = checkMeta.remediation.recommendation.url;
const recommendationUrl = isNonEmptyString(findingRecommendationUrl)
@@ -690,9 +685,12 @@ export function ResourceDetailDrawerContent({
<div className="flex items-start gap-4">
{/* Resource info grid — 4 data columns */}
<div className="@container flex min-w-0 flex-1 flex-col gap-4">
{/* Row 1: Provider (cols 1-2), Resource (cols 3-5) */}
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
<div className="flex min-w-0 flex-col gap-1 @md:col-span-2">
{/* Row 1: Provider, Resource, Service, Region */}
<div
className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)] @md:gap-x-8"
data-testid="resource-detail-primary-metadata-row"
>
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
Provider
</span>
@@ -703,7 +701,7 @@ export function ResourceDetailDrawerContent({
entityId={providerUid}
/>
</div>
<div className="flex min-w-0 flex-col gap-1 @md:col-span-3">
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
Resource
</span>
@@ -737,44 +735,59 @@ export function ResourceDetailDrawerContent({
</Tooltip>
) : undefined
}
idAction={
hasIdAction ? (
<ExternalResourceLink
providerType={providerType}
resourceUid={resourceUid}
providerUid={providerUid}
resourceName={resourceName}
findingUid={f?.uid}
region={resourceRegion}
/>
) : undefined
}
/>
</div>
</div>
{/* Row 2: Last detected, First seen, Failing for, Service, Region */}
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
<InfoField label="Last detected" variant="compact">
<DateWithTime inline dateTime={lastSeenAt || "-"} />
<InfoField
label="Service"
variant="compact"
className="min-w-0"
>
<span className="block truncate whitespace-nowrap">
{resourceService}
</span>
</InfoField>
<InfoField label="First seen" variant="compact">
<DateWithTime inline dateTime={firstSeenAt || "-"} />
</InfoField>
<InfoField label="Failing for" variant="compact">
{getFailingForLabel(firstSeenAt) || "-"}
</InfoField>
<InfoField label="Service" variant="compact">
{resourceService}
</InfoField>
<InfoField label="Region" variant="compact">
<span className="flex items-center gap-1.5">
<InfoField
label="Region"
variant="compact"
className="min-w-0"
>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
{getRegionFlag(resourceRegionLabel) && (
<span className="translate-y-px text-base leading-none">
<span className="shrink-0 translate-y-px text-base leading-none">
{getRegionFlag(resourceRegionLabel)}
</span>
)}
{resourceRegionLabel}
<span className="truncate">{resourceRegionLabel}</span>
</span>
</InfoField>
</div>
{/* Row 2: Last detected, First seen, Failing for */}
<div
className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-3 @md:gap-x-8"
data-testid="resource-detail-secondary-metadata-row"
>
<InfoField
label="Last detected"
variant="compact"
className="min-w-0"
>
<DateWithTime inline dateTime={lastSeenAt || "-"} />
</InfoField>
<InfoField
label="First seen"
variant="compact"
className="min-w-0"
>
<DateWithTime inline dateTime={firstSeenAt || "-"} />
</InfoField>
<InfoField
label="Failing for"
variant="compact"
className="min-w-0"
>
<span className="block truncate whitespace-nowrap">
{getFailingForLabel(firstSeenAt) || "-"}
</span>
</InfoField>
</div>
@@ -804,6 +817,19 @@ export function ResourceDetailDrawerContent({
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
/>
{externalResourceTarget && (
<ActionDropdownItem
icon={<ExternalLink className="size-5" />}
label={externalResourceTarget.label}
onSelect={() =>
window.open(
externalResourceTarget.url,
"_blank",
"noopener,noreferrer",
)
}
/>
)}
</ActionDropdown>
) : (
<Skeleton className="size-8 rounded-md" />
@@ -844,20 +870,31 @@ export function ResourceDetailDrawerContent({
>
<div className="mb-4 flex items-center justify-between">
<TabsList>
<TabsTrigger value="overview">Finding Overview</TabsTrigger>
<TabsTrigger value="remediation">Remediation</TabsTrigger>
<TabsTrigger value="metadata">
Resource Metadata / Evidence
<TabsTrigger value="overview" tooltip="Overview">
Overview
</TabsTrigger>
<TabsTrigger value="other-findings">
Findings for this resource
<TabsTrigger value="remediation" tooltip="Remediation">
Remediation
</TabsTrigger>
<TabsTrigger value="metadata" tooltip="Resource Metadata">
Evidence
</TabsTrigger>
<TabsTrigger
value="other-findings"
tooltip="Other Findings for this resource"
>
Other findings
</TabsTrigger>
<TabsTrigger value="scans" tooltip="Scans">
Scans
</TabsTrigger>
<TabsTrigger value="events" tooltip="Events">
Events
</TabsTrigger>
<TabsTrigger value="scans">Scans</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
</div>
{/* Finding Overview — check-level data from checkMeta (always stable) */}
{/* Overview — check-level data from checkMeta (always stable) */}
<TabsContent
value="overview"
className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto"
@@ -892,19 +929,26 @@ export function ResourceDetailDrawerContent({
<span className="text-text-neutral-secondary text-xs">
References:
</span>
<ul className="list-inside list-disc space-y-1">
{checkMeta.additionalUrls.map((link, idx) => (
<li key={idx}>
<CustomLink
<div className="flex flex-col items-start gap-1">
{checkMeta.additionalUrls.map((link) => (
<Button
key={link}
variant="link"
size="link-xs"
className="h-auto justify-start p-0 text-left break-all whitespace-normal!"
asChild
>
<Link
href={link}
size="sm"
className="break-all whitespace-normal!"
target="_blank"
rel="noopener noreferrer"
prefetch={false}
>
{link}
</CustomLink>
</li>
</Link>
</Button>
))}
</ul>
</div>
</div>
</Card>
)}
@@ -986,26 +1030,39 @@ export function ResourceDetailDrawerContent({
{(checkMeta.remediation.recommendation.text ||
recommendationLink) && (
<div className="flex flex-col gap-1 px-1">
<span className="text-text-neutral-primary text-sm font-semibold">
Remediation:
</span>
<div className="flex items-start gap-3">
<div
className="flex min-w-0 items-center justify-between gap-3"
data-testid="remediation-heading-row"
>
<span className="text-text-neutral-primary text-sm font-semibold">
Remediation:
</span>
{recommendationLink && (
<Button
variant="link"
size="link-xs"
className="shrink-0 whitespace-nowrap"
asChild
>
<Link
href={recommendationLink.href}
target="_blank"
rel="noopener noreferrer"
prefetch={false}
>
{recommendationLink.label}
</Link>
</Button>
)}
</div>
<div>
{checkMeta.remediation.recommendation.text && (
<div className="text-text-neutral-primary flex-1 text-sm">
<div className="text-text-neutral-primary text-sm">
<MarkdownContainer>
{checkMeta.remediation.recommendation.text}
</MarkdownContainer>
</div>
)}
{recommendationLink && (
<CustomLink
href={recommendationLink.href}
size="sm"
className="shrink-0"
>
{recommendationLink.label}
</CustomLink>
)}
</div>
</div>
)}
@@ -1091,7 +1148,7 @@ export function ResourceDetailDrawerContent({
)}
</TabsContent>
{/* Findings for this resource */}
{/* Other findings — findings affecting this same resource */}
<TabsContent
value="other-findings"
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
@@ -9,19 +9,23 @@ export function ResourceDetailSkeleton() {
return (
<div className="flex items-start gap-4">
<div className="@container flex min-w-0 flex-1 flex-col gap-4">
{/* Row 1: Provider, Resource */}
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] @md:gap-x-8">
<EntityInfoSkeleton hasIcon labelWidth="w-12" />
<EntityInfoSkeleton labelWidth="w-14" />
{/* Row 1: Provider, Resource, Service, Region */}
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)] @md:gap-x-8">
<div className="col-span-2 @md:col-span-1">
<EntityInfoSkeleton hasIcon labelWidth="w-12" />
</div>
<div className="col-span-2 @md:col-span-1">
<EntityInfoSkeleton labelWidth="w-14" />
</div>
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
</div>
{/* Row 2: Last detected, First seen, Failing for, Service, Region */}
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
{/* Row 2: Last detected, First seen, Failing for */}
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-3 @md:gap-x-8">
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
</div>
</div>
@@ -26,4 +26,23 @@ describe("resource detail content", () => {
expect(source).not.toContain("useEffect");
expect(source).not.toContain("useEffect(");
});
it("renders the external resource link below the resource title row", () => {
expect(source).toContain(`</div>
<ExternalResourceLink`);
expect(source).toContain('className="self-start justify-start"');
});
it("keeps resource date fields together on the third details row", () => {
expect(source).toContain(
'className="grid min-w-0 grid-cols-2 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4"',
);
expect(source).toContain('className="col-span-2 md:col-span-1"');
expect(source).toContain(`label="Created At"
variant="compact"
className="col-start-1 min-w-0"`);
expect(source).toContain(`label="Last Updated"
variant="compact"
className="col-start-2 min-w-0"`);
});
});
@@ -228,19 +228,20 @@ export const ResourceDetailContent = ({
</TooltipTrigger>
<TooltipContent>Copy resource link to clipboard</TooltipContent>
</Tooltip>
<ExternalResourceLink
providerType={providerData.provider}
resourceUid={attributes.uid}
providerUid={providerData.uid}
resourceName={attributes.name}
region={attributes.region}
/>
</div>
<ExternalResourceLink
providerType={providerData.provider}
resourceUid={attributes.uid}
providerUid={providerData.uid}
resourceName={attributes.name}
region={attributes.region}
className="justify-start self-start"
/>
</div>
</div>
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 overflow-hidden rounded-lg border p-4">
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
<div className="grid min-w-0 grid-cols-2 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
{providerOrg ? (
<div className="col-span-2 flex flex-col gap-1">
<EntityInfo
@@ -258,13 +259,21 @@ export const ResourceDetailContent = ({
</div>
</div>
) : (
<EntityInfo
cloudProvider={providerData.provider as ProviderType}
entityAlias={providerData.alias ?? undefined}
entityId={providerData.uid}
/>
<div className="col-span-2 md:col-span-1">
<EntityInfo
cloudProvider={providerData.provider as ProviderType}
entityAlias={providerData.alias ?? undefined}
entityId={providerData.uid}
/>
</div>
)}
<div className={providerOrg ? "self-end" : undefined}>
<div
className={
providerOrg
? "col-span-2 self-end md:col-span-1"
: "col-span-2 md:col-span-1"
}
>
<EntityInfo
nameIcon={<Container className="size-4" />}
entityAlias={resourceName}
@@ -299,10 +308,18 @@ export const ResourceDetailContent = ({
{renderValue(attributes.partition)}
</InfoField>
<InfoField label="Created At" variant="compact">
<InfoField
label="Created At"
variant="compact"
className="col-start-1 min-w-0"
>
<DateWithTime inline dateTime={attributes.inserted_at || "-"} />
</InfoField>
<InfoField label="Last Updated" variant="compact">
<InfoField
label="Last Updated"
variant="compact"
className="col-start-2 min-w-0"
>
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
</InfoField>
</div>
@@ -320,9 +337,15 @@ export const ResourceDetailContent = ({
<InfoTooltip content="This table also includes muted findings" />
</span>
</TabsTrigger>
<TabsTrigger value="metadata">Metadata</TabsTrigger>
<TabsTrigger value="tags">Tags</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
<TabsTrigger value="metadata" tooltip="Resource Metadata">
Evidence
</TabsTrigger>
<TabsTrigger value="tags" tooltip="Tags">
Tags
</TabsTrigger>
<TabsTrigger value="events" tooltip="Events">
Events
</TabsTrigger>
</TabsList>
</div>
@@ -372,7 +395,10 @@ export const ResourceDetailContent = ({
)}
</TabsContent>
<TabsContent value="metadata" className="flex flex-col gap-4">
<TabsContent
value="metadata"
className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden"
>
<ResourceMetadataPanel
metadata={attributes.metadata}
details={attributes.details}
@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
describe("shadcn Button", () => {
it("supports extra-small link buttons", () => {
render(
<Button variant="link" size="link-xs">
Open link
</Button>,
);
expect(screen.getByRole("button", { name: "Open link" })).toHaveClass(
"text-xs",
);
});
});
+1
View File
@@ -37,6 +37,7 @@ const buttonVariants = cva(
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
"link-xs": "text-xs",
"link-sm": "text-sm",
},
},
+38 -6
View File
@@ -1,15 +1,20 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type { ComponentProps } from "react";
import type { ComponentProps, ReactNode } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
/**
* Trigger component style parts using semantic class names
*/
const TRIGGER_STYLES = {
base: "relative inline-flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4",
base: "relative inline-flex min-w-0 items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4",
border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]",
text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white",
active:
@@ -46,7 +51,10 @@ function buildTriggerClassName(): string {
* Build list className
*/
function buildListClassName(): string {
return "inline-flex w-full items-center border-[#E9E9F0] dark:border-[#171D30]";
// `flex` + `min-w-0` lets the triggers shrink proportionally when the
// container is narrow, so each trigger truncates with ellipsis instead
// of forcing a horizontal scrollbar.
return "flex w-full min-w-0 items-center border-[#E9E9F0] dark:border-[#171D30]";
}
function Tabs({
@@ -75,16 +83,40 @@ function TabsList({
);
}
interface TabsTriggerProps
extends ComponentProps<typeof TabsPrimitive.Trigger> {
/**
* When set, the trigger is wrapped in a shadcn Tooltip rendered below
* the bar. Useful for showing the full name when the label is truncated
* to ellipsis on narrow containers.
*/
tooltip?: ReactNode;
}
function TabsTrigger({
className,
tooltip,
children,
...props
}: ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
}: TabsTriggerProps) {
const trigger = (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(buildTriggerClassName(), className)}
{...props}
/>
>
{/* Wrapper provides the block-level box needed for `truncate` to
* actually render an ellipsis. Padding and gap on the trigger stay
* constant; only this span shrinks below its content width. */}
<span className="block min-w-0 truncate">{children}</span>
</TabsPrimitive.Trigger>
);
if (!tooltip) return trigger;
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="bottom">{tooltip}</TooltipContent>
</Tooltip>
);
}
@@ -18,6 +18,7 @@ describe("ExternalResourceLink", () => {
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(link).toHaveTextContent("View in AWS Console");
expect(link).toHaveClass("text-xs");
});
it("renders a repository link for IaC resources", () => {
@@ -73,7 +73,7 @@ export const ExternalResourceLink = (props: ExternalResourceLinkProps) => {
<TooltipTrigger asChild>
<Button
variant="link"
size="link-sm"
size="link-xs"
asChild
className={props.className}
>
@@ -20,6 +20,11 @@ interface ResourceMetadataPanelProps {
* neither is available. Reused by the resource detail view and the finding
* detail drawer (compliance requirement findings view) to keep the UX
* consistent across surfaces.
*
* Layout contract: the parent must be a bounded flex column (e.g.
* `flex min-h-0 flex-1 flex-col overflow-hidden`). The embedded editor
* fills that height and scrolls internally, so JSON-heavy resources do
* not push the surrounding chrome (drawer, page) into a double scroll.
*/
export function ResourceMetadataPanel({
metadata,
@@ -57,7 +62,6 @@ export function ResourceMetadataPanel({
value={formattedMetadata}
copyValue={formattedMetadata}
editable={false}
minHeight={220}
fill
showCopyButton
onChange={() => {}}
+27 -11
View File
@@ -332,49 +332,65 @@
display: none; /* Chrome, Safari, Opera */
}
/* Minimal scrollbar styles */
.minimal-scrollbar {
/* Minimal scrollbar styles
*
* The descendant selectors target `.cm-scroller` so that CodeMirror
* editors which receive `.minimal-scrollbar` on their `.cm-editor`
* wrapper also style their inner scroller (the element that actually
* overflows when the editor fills a bounded container).
*/
.minimal-scrollbar,
.minimal-scrollbar .cm-scroller {
scrollbar-width: thin; /* Firefox */
scrollbar-color: rgb(203 213 225 / 0.5) transparent; /* thumb and track for Firefox */
}
.minimal-scrollbar:hover {
.minimal-scrollbar:hover,
.minimal-scrollbar .cm-scroller:hover {
scrollbar-color: rgb(148 163 184 / 0.7) transparent; /* darker thumb on hover */
}
/* Webkit browsers (Chrome, Safari, Edge) */
.minimal-scrollbar::-webkit-scrollbar {
.minimal-scrollbar::-webkit-scrollbar,
.minimal-scrollbar .cm-scroller::-webkit-scrollbar {
width: 6px;
}
.minimal-scrollbar::-webkit-scrollbar-track {
.minimal-scrollbar::-webkit-scrollbar-track,
.minimal-scrollbar .cm-scroller::-webkit-scrollbar-track {
background: transparent;
}
.minimal-scrollbar::-webkit-scrollbar-thumb {
.minimal-scrollbar::-webkit-scrollbar-thumb,
.minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb {
background-color: rgb(203 213 225 / 0.5);
border-radius: 3px;
transition: background-color 0.2s ease;
}
.minimal-scrollbar::-webkit-scrollbar-thumb:hover {
.minimal-scrollbar::-webkit-scrollbar-thumb:hover,
.minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb:hover {
background-color: rgb(148 163 184 / 0.7);
}
/* Dark mode */
.dark .minimal-scrollbar {
.dark .minimal-scrollbar,
.dark .minimal-scrollbar .cm-scroller {
scrollbar-color: rgb(71 85 105 / 0.5) transparent;
}
.dark .minimal-scrollbar:hover {
.dark .minimal-scrollbar:hover,
.dark .minimal-scrollbar .cm-scroller:hover {
scrollbar-color: rgb(100 116 139 / 0.7) transparent;
}
.dark .minimal-scrollbar::-webkit-scrollbar-thumb {
.dark .minimal-scrollbar::-webkit-scrollbar-thumb,
.dark .minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb {
background-color: rgb(71 85 105 / 0.5);
}
.dark .minimal-scrollbar::-webkit-scrollbar-thumb:hover {
.dark .minimal-scrollbar::-webkit-scrollbar-thumb:hover,
.dark .minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb:hover {
background-color: rgb(100 116 139 / 0.7);
}