refactor(ui): improve layouts and styles (#9807)

This commit is contained in:
Alejandro Bailo
2026-01-16 12:00:01 +01:00
committed by GitHub
parent 76a8610121
commit ec4eb70539
14 changed files with 151 additions and 269 deletions
@@ -1,7 +1,5 @@
"use server";
import { Spacer } from "@heroui/spacer";
import { getLatestFindings } from "@/actions/findings/findings";
import { LighthouseBanner } from "@/components/lighthouse/banner";
import { LinkToFindings } from "@/components/overview";
@@ -59,22 +57,19 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
};
return (
<div className="flex w-full flex-col">
<div className="flex w-full flex-col gap-6">
<LighthouseBanner />
<div className="relative flex w-full">
<div className="flex w-full items-center gap-2">
<h3 className="text-sm font-bold uppercase">
<div className="relative w-full flex-col justify-between md:flex-row">
<div className="flex w-full flex-col items-start gap-2 md:flex-row md:items-center">
<h3 className="text-sm font-bold text-nowrap whitespace-nowrap uppercase">
Latest new failing findings
</h3>
<p className="text-text-neutral-tertiary text-xs">
<p className="text-text-neutral-tertiary text-xs whitespace-nowrap">
Showing the latest 10 new failing findings by severity.
</p>
</div>
<div className="absolute -top-6 right-0">
<LinkToFindings />
</div>
</div>
<Spacer y={4} />
<DataTable
key={`dashboard-findings-${Date.now()}`}
+15 -15
View File
@@ -1,6 +1,5 @@
import { Spacer } from "@heroui/spacer";
import Link from "next/link";
import React, { Suspense } from "react";
import { Suspense } from "react";
import { getInvitations } from "@/actions/invitations/invitation";
import { getRoles } from "@/actions/roles";
@@ -28,21 +27,22 @@ export default async function Invitations({
<ContentLayout title="Invitations" icon="lucide:mail">
<FilterControls search />
<div className="flex flex-row items-center justify-between">
<DataTableFilterCustom filters={filterInvitations || []} />
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-between">
<DataTableFilterCustom filters={filterInvitations || []} />
<Button asChild>
<Link href="/invitations/new">
Send Invitation
<AddIcon size={20} />
</Link>
</Button>
<Button asChild>
<Link href="/invitations/new">
Send Invitation
<AddIcon size={20} />
</Link>
</Button>
</div>
<Suspense key={searchParamsKey} fallback={<SkeletonTableInvitation />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</div>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableInvitation />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</ContentLayout>
);
}
+8 -9
View File
@@ -1,4 +1,3 @@
import { Spacer } from "@heroui/spacer";
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
@@ -26,20 +25,20 @@ export default async function Providers({
return (
<ContentLayout title="Cloud Providers" icon="lucide:cloud-cog">
<FilterControls search customFilters={filterProviders || []} />
<Spacer y={8} />
<ProvidersActions />
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<ProvidersTableFallback />}>
<ProvidersTable searchParams={resolvedSearchParams} />
</Suspense>
<div className="flex flex-col gap-6">
<FilterControls search customFilters={filterProviders || []} />
<ProvidersActions />
<Suspense key={searchParamsKey} fallback={<ProvidersTableFallback />}>
<ProvidersTable searchParams={resolvedSearchParams} />
</Suspense>
</div>
</ContentLayout>
);
}
const ProvidersActions = () => {
return (
<div className="flex items-center gap-4 md:justify-end">
<div className="flex flex-wrap gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton />
<AddProviderButton />
+30 -31
View File
@@ -1,4 +1,3 @@
import { Spacer } from "@heroui/spacer";
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
@@ -63,36 +62,36 @@ export default async function Resources({
return (
<ContentLayout title="Resources" icon="lucide:warehouse">
<FilterControls search date />
<Spacer y={4} />
<DataTableFilterCustom
filters={[
{
key: "provider__in",
labelCheckboxGroup: "Provider",
values: providerIds,
valueLabelMapping: providerDetails,
},
{
key: "region__in",
labelCheckboxGroup: "Region",
values: uniqueRegions,
},
{
key: "service__in",
labelCheckboxGroup: "Service",
values: uniqueServices,
},
{
key: "groups__in",
labelCheckboxGroup: "Group",
values: uniqueGroups,
},
]}
/>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableResources />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
<div className="flex flex-col gap-6">
<DataTableFilterCustom
filters={[
{
key: "provider__in",
labelCheckboxGroup: "Provider",
values: providerIds,
valueLabelMapping: providerDetails,
},
{
key: "region__in",
labelCheckboxGroup: "Region",
values: uniqueRegions,
},
{
key: "service__in",
labelCheckboxGroup: "Service",
values: uniqueServices,
},
{
key: "groups__in",
labelCheckboxGroup: "Group",
values: uniqueGroups,
},
]}
/>
<Suspense key={searchParamsKey} fallback={<SkeletonTableResources />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</div>
</ContentLayout>
);
}
+14 -16
View File
@@ -1,4 +1,3 @@
import { Spacer } from "@heroui/spacer";
import Link from "next/link";
import { Suspense } from "react";
@@ -6,8 +5,7 @@ import { getRoles } from "@/actions/roles";
import { FilterControls } from "@/components/filters";
import { filterRoles } from "@/components/filters/data-filters";
import { AddIcon } from "@/components/icons";
import { ColumnsRoles } from "@/components/roles/table";
import { SkeletonTableRoles } from "@/components/roles/table";
import { ColumnsRoles, SkeletonTableRoles } from "@/components/roles/table";
import { Button } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
@@ -25,21 +23,21 @@ export default async function Roles({
<ContentLayout title="Roles" icon="lucide:user-cog">
<FilterControls search />
<div className="flex flex-row items-center justify-between">
<DataTableFilterCustom filters={filterRoles || []} />
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-between">
<DataTableFilterCustom filters={filterRoles || []} />
<Button asChild>
<Link href="/roles/new">
Add Role
<AddIcon size={20} />
</Link>
</Button>
</div>
<Button asChild>
<Link href="/roles/new">
Add Role
<AddIcon size={20} />
</Link>
</Button>
<Suspense key={searchParamsKey} fallback={<SkeletonTableRoles />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</div>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableRoles />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</ContentLayout>
);
}
+24 -26
View File
@@ -1,4 +1,3 @@
import { Spacer } from "@heroui/spacer";
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
@@ -87,33 +86,32 @@ export default async function Scans({
<ContentLayout title="Scans" icon="lucide:timer">
<AutoRefresh hasExecutingScan={hasExecutingScan} />
<>
{!hasManageScansPermission ? (
<CustomBanner
title={"Access Denied"}
message={"You don't have permission to launch the scan."}
<>
{!hasManageScansPermission ? (
<CustomBanner
title={"Access Denied"}
message={"You don't have permission to launch the scan."}
/>
) : thereIsNoProvidersConnected ? (
<>
<NoProvidersConnected />
</>
) : (
<LaunchScanWorkflow providers={providerInfo} />
)}
</>
<div className="flex flex-col gap-6">
<ScansFilters
providerUIDs={providerUIDs}
providerDetails={providerDetails}
/>
) : thereIsNoProvidersConnected ? (
<>
<Spacer y={8} />
<NoProvidersConnected />
<Spacer y={8} />
</>
) : (
<LaunchScanWorkflow providers={providerInfo} />
)}
<ScansFilters
providerUIDs={providerUIDs}
providerDetails={providerDetails}
/>
<Spacer y={8} />
<div className="flex items-center justify-end gap-4">
<MutedFindingsConfigButton />
<div className="flex items-center justify-end">
<MutedFindingsConfigButton />
</div>
<Suspense fallback={<SkeletonTableScans />}>
<SSRDataTableScans searchParams={resolvedSearchParams} />
</Suspense>
</div>
<Spacer y={8} />
<Suspense fallback={<SkeletonTableScans />}>
<SSRDataTableScans searchParams={resolvedSearchParams} />
</Suspense>
</>
</ContentLayout>
);
+13 -16
View File
@@ -1,15 +1,13 @@
import { Spacer } from "@heroui/spacer";
import Link from "next/link";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles/roles";
import { getUsers } from "@/actions/users/users";
import { FilterControls } from "@/components/filters";
import { filterUsers } from "@/components/filters/data-filters";
import { AddIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { DataTable } from "@/components/ui/table";
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
import { Role, SearchParamsProps, UserProps } from "@/types";
@@ -25,21 +23,20 @@ export default async function Users({
<ContentLayout title="Users" icon="lucide:user">
<FilterControls search />
<div className="flex flex-row items-center justify-between">
<DataTableFilterCustom filters={filterUsers || []} />
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-end">
<Button asChild>
<Link href="/invitations/new">
Invite User
<AddIcon size={20} />
</Link>
</Button>
</div>
<Button asChild>
<Link href="/invitations/new">
Invite User
<AddIcon size={20} />
</Link>
</Button>
<Suspense key={searchParamsKey} fallback={<SkeletonTableUser />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</div>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableUser />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</ContentLayout>
);
}
@@ -73,8 +73,10 @@ export const ComplianceHeader = ({
{hasContent && (
<div className="flex w-full items-start justify-between gap-6 sm:mb-8">
<div className="flex flex-1 flex-col justify-end gap-4">
{/* Showed in the details page */}
{selectedScan && <ComplianceScanInfo scan={selectedScan} />}
{/* Showed in the compliance page */}
{showProviders && <DataCompliance scans={scans} />}
{!hideFilters && allFilters.length > 0 && (
<DataTableFilterCustom filters={allFilters} />
@@ -27,6 +27,7 @@ export const ComplianceScanInfo = ({ scan }: ComplianceScanInfoProps) => {
entityAlias={scan.providerInfo.alias}
entityId={scan.providerInfo.uid}
showCopyAction={false}
maxWidth="w-[80px]"
/>
</div>
<Divider orientation="vertical" className="h-8" />
@@ -1,116 +0,0 @@
"use client";
import { X } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Badge } from "@/components/shadcn";
import { useUrlFilters } from "@/hooks/use-url-filters";
export interface FilterBadgeConfig {
/**
* The filter key without the "filter[]" wrapper.
* Example: "scan__in", "check_id__in", "provider__in"
*/
filterKey: string;
/**
* Label to display before the value.
* Example: "Scan", "Check ID", "Provider"
*/
label: string;
/**
* Optional function to format a single value for display.
* Useful for truncating UUIDs, etc.
* Default: shows value as-is
*/
formatValue?: (value: string) => string;
/**
* Optional function to format the display when multiple values are selected.
* Default: "{count} {label}s filtered"
*/
formatMultiple?: (count: number, label: string) => string;
}
/**
* Default filter badge configurations for common use cases.
* Add new filters here to automatically show them as badges.
*/
export const DEFAULT_FILTER_BADGES: FilterBadgeConfig[] = [
{
filterKey: "check_id__in",
label: "Check ID",
formatMultiple: (count) => `${count} Check IDs filtered`,
},
];
interface ActiveFilterBadgeProps {
config: FilterBadgeConfig;
}
/**
* Single filter badge component that reads from URL and displays if active.
*/
const ActiveFilterBadge = ({ config }: ActiveFilterBadgeProps) => {
const searchParams = useSearchParams();
const { clearFilter } = useUrlFilters();
const {
filterKey,
label,
formatValue = (v) => v,
formatMultiple = (count, lbl) => `${count} ${lbl}s filtered`,
} = config;
const fullKey = filterKey.startsWith("filter[")
? filterKey
: `filter[${filterKey}]`;
const filterValue = searchParams.get(fullKey);
if (!filterValue) {
return null;
}
const values = filterValue.split(",");
const displayText =
values.length > 1
? formatMultiple(values.length, label)
: `${label}: ${formatValue(values[0])}`;
return (
<Badge
variant="outline"
className="flex cursor-pointer items-center gap-1 px-3 py-1.5"
onClick={() => clearFilter(filterKey)}
>
<span className="max-w-[200px] truncate text-sm">{displayText}</span>
<X className="size-3.5 shrink-0" />
</Badge>
);
};
interface ActiveFilterBadgesProps {
/**
* Filter configurations to render as badges.
* Defaults to DEFAULT_FILTER_BADGES if not provided.
*/
filters?: FilterBadgeConfig[];
}
/**
* Renders filter badges for all configured filters that are active in the URL.
* Only shows badges for filters that have values in the URL params.
*/
export const ActiveFilterBadges = ({
filters = DEFAULT_FILTER_BADGES,
}: ActiveFilterBadgesProps) => {
return (
<>
{filters.map((config) => (
<ActiveFilterBadge key={config.filterKey} config={config} />
))}
</>
);
};
+2 -2
View File
@@ -30,8 +30,8 @@ export const FilterControls = ({
customFilters,
}: FilterControlsProps) => {
return (
<div className="mb-4 flex flex-col">
<div className="flex flex-col items-start gap-4 md:flex-row md:items-center">
<div className="flex flex-col">
<div className="mb-4 flex flex-col items-start gap-4 md:flex-row md:items-center">
<div className="grid w-full flex-1 grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
{search && <CustomSearchInput />}
{providers && <CustomSelectProvider />}
-1
View File
@@ -1,4 +1,3 @@
export * from "./active-filter-badge";
export * from "./clear-filters-button";
export * from "./custom-account-selection";
export * from "./custom-checkbox-muted-findings";
+36 -24
View File
@@ -1,3 +1,9 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
export interface ChartLegendItem {
label: string;
color: string;
@@ -18,36 +24,42 @@ export function ChartLegend({
const isInteractive = !!onItemClick;
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex max-w-full items-center overflow-hidden rounded-full border sm:gap-2">
{items.map((item, index) => {
const dataKey = item.dataKey ?? item.label.toLowerCase();
const isSelected = selectedItem === dataKey;
const isFaded = selectedItem !== null && !isSelected;
return (
<button
key={`legend-${index}`}
type="button"
className={`flex items-center gap-2 px-4 py-3 transition-opacity duration-200 ${
isInteractive
? "cursor-pointer hover:opacity-80"
: "cursor-default"
} ${isFaded ? "opacity-30" : "opacity-100"}`}
onClick={() => onItemClick?.(dataKey)}
disabled={!isInteractive}
>
<div
className={`h-3 w-3 rounded ${isSelected ? "ring-2 ring-offset-1" : ""}`}
style={{
backgroundColor: item.color,
// @ts-expect-error ring-color is a valid Tailwind CSS variable
"--tw-ring-color": item.color,
}}
/>
<span className="text-text-neutral-secondary text-sm font-medium">
{item.label}
</span>
</button>
<Tooltip key={`legend-${index}`}>
<TooltipTrigger asChild>
<button
type="button"
className={`flex min-w-0 items-center gap-1 px-2 py-3 transition-opacity duration-200 sm:gap-2 sm:px-4 ${
isInteractive
? "cursor-pointer hover:opacity-80"
: "cursor-default"
} ${isFaded ? "opacity-30" : "opacity-100"}`}
onClick={() => onItemClick?.(dataKey)}
disabled={!isInteractive}
>
<div
className={`h-3 w-3 shrink-0 rounded ${isSelected ? "ring-2 ring-offset-1" : ""}`}
style={{
backgroundColor: item.color,
// @ts-expect-error ring-color is a valid Tailwind CSS variable
"--tw-ring-color": item.color,
}}
/>
<span className="text-text-neutral-secondary max-w-[120px] truncate text-sm font-medium sm:max-w-[200px]">
{item.label}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
@@ -3,7 +3,6 @@
import { useSearchParams } from "next/navigation";
import { ComplianceScanInfo } from "@/components/compliance/compliance-header/compliance-scan-info";
import { ActiveFilterBadges } from "@/components/filters/active-filter-badge";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import {
MultiSelect,
@@ -176,8 +175,7 @@ export const DataTableFilterCustom = ({
);
})}
{!hideClearButton && (
<div className="flex items-center justify-start gap-2">
<ActiveFilterBadges />
<div className="flex items-center justify-start">
<ClearFiltersButton />
</div>
)}