mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat(ui/overview): add click navigation for charts and threat score improvements (#9281)
This commit is contained in:
@@ -48,23 +48,21 @@ if [ "$CODE_REVIEW_ENABLED" = "true" ]; then
|
||||
echo -e "${YELLOW}🔍 Running Claude Code standards validation...${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}📋 Files to validate:${NC}"
|
||||
echo "$STAGED_FILES" | sed 's/^/ - /'
|
||||
echo "$STAGED_FILES" | while IFS= read -r file; do echo " - $file"; done
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}📤 Sending to Claude Code for validation...${NC}"
|
||||
echo ""
|
||||
|
||||
# Build prompt with git diff of changes AND full context
|
||||
# Build prompt with full file contents
|
||||
VALIDATION_PROMPT=$(
|
||||
cat <<'PROMPT_EOF'
|
||||
You are a code reviewer for the Prowler UI project. Analyze the code changes (git diff with full context) below and validate they comply with AGENTS.md standards.
|
||||
|
||||
**CRITICAL: You MUST check BOTH the changed lines AND the surrounding context for violations.**
|
||||
You are a code reviewer for the Prowler UI project. Analyze the full file contents of changed files below and validate they comply with AGENTS.md standards.
|
||||
|
||||
**RULES TO CHECK:**
|
||||
1. React Imports: NO `import * as React` or `import React, {` → Use `import { useState }`
|
||||
2. TypeScript: NO union types like `type X = "a" | "b"` → Use const-based: `const X = {...} as const`
|
||||
3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes (e.g., `bg-bg-neutral-tertiary`, `border-border-neutral-primary`)
|
||||
3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes.
|
||||
4. cn(): Use for merging multiple classes or for conditionals (handles Tailwind conflicts with twMerge) → `cn(BUTTON_STYLES.base, BUTTON_STYLES.active, isLoading && "opacity-50")`
|
||||
5. React 19: NO `useMemo`/`useCallback` without reason
|
||||
6. Zod v4: Use `.min(1)` not `.nonempty()`, `z.email()` not `z.string().email()`. All inputs must be validated with Zod.
|
||||
@@ -76,24 +74,28 @@ You are a code reviewer for the Prowler UI project. Analyze the code changes (gi
|
||||
12. Use the components inside components/shadcn if possible
|
||||
13. Check Accessibility best practices (like alt tags in images, semantic HTML, Aria labels, etc.)
|
||||
|
||||
=== GIT DIFF WITH CONTEXT ===
|
||||
=== FILES TO REVIEW ===
|
||||
PROMPT_EOF
|
||||
)
|
||||
|
||||
# Add git diff to prompt with more context (U5 = 5 lines before/after)
|
||||
VALIDATION_PROMPT="$VALIDATION_PROMPT
|
||||
$(git diff --cached -U5)"
|
||||
# Add full file contents for each staged file
|
||||
for file in $STAGED_FILES; do
|
||||
VALIDATION_PROMPT="$VALIDATION_PROMPT
|
||||
|
||||
=== FILE: $file ===
|
||||
$(cat "$file" 2>/dev/null || echo "Error reading file")"
|
||||
done
|
||||
|
||||
VALIDATION_PROMPT="$VALIDATION_PROMPT
|
||||
|
||||
=== END DIFF ===
|
||||
=== END FILES ===
|
||||
|
||||
**IMPORTANT: Your response MUST start with exactly one of these lines:**
|
||||
STATUS: PASSED
|
||||
STATUS: FAILED
|
||||
|
||||
**If FAILED:** List each violation with File, Line Number, Rule Number, and Issue.
|
||||
**If PASSED:** Confirm all visible code (including context) complies with AGENTS.md standards.
|
||||
**If PASSED:** Confirm all files comply with AGENTS.md standards.
|
||||
|
||||
**Start your response now with STATUS:**"
|
||||
|
||||
@@ -149,3 +151,19 @@ else
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run build
|
||||
echo -e "${BLUE}🔨 Running build...${NC}"
|
||||
echo ""
|
||||
|
||||
if npm run build; then
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ Build passed${NC}"
|
||||
echo ""
|
||||
else
|
||||
echo ""
|
||||
echo -e "${RED}❌ Build failed${NC}"
|
||||
echo -e "${RED}Fix build errors before committing${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -19,6 +19,8 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
- Resource ID moved up in the findings detail page [(#9141)](https://github.com/prowler-cloud/prowler/pull/9141)
|
||||
- C5 compliance logo [(#9224)](https://github.com/prowler-cloud/prowler/pull/9224)
|
||||
- Overview charts now support click navigation to Findings page with filters and keyboard accessibility [(#9281)](https://github.com/prowler-cloud/prowler/pull/9281)
|
||||
- Threat score now displays 2 decimal places with note that it doesn't include muted findings [(#9281)](https://github.com/prowler-cloud/prowler/pull/9281)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ export const getFindingsBySeverity = async ({
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (query) url.searchParams.append("filter[search]", query);
|
||||
if (sort) url.searchParams.append("sort", sort);
|
||||
|
||||
// Handle multiple filters, but exclude unsupported filters
|
||||
// The overviews/findings_severity endpoint does not support status or muted filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getFindingsByStatus } from "@/actions/overview/overview";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../lib/filter-params";
|
||||
@@ -11,7 +12,10 @@ export const CheckFindingsSSR = async ({
|
||||
}) => {
|
||||
const filters = pickFilterParams(searchParams);
|
||||
|
||||
const findingsByStatus = await getFindingsByStatus({ filters });
|
||||
const [findingsByStatus, providersData] = await Promise.all([
|
||||
getFindingsByStatus({ filters }),
|
||||
getProviders({ page: 1, pageSize: 200 }),
|
||||
]);
|
||||
|
||||
if (!findingsByStatus) {
|
||||
return (
|
||||
@@ -21,29 +25,21 @@ export const CheckFindingsSSR = async ({
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
fail = 0,
|
||||
pass = 0,
|
||||
muted_new = 0,
|
||||
muted_changed = 0,
|
||||
fail_new = 0,
|
||||
pass_new = 0,
|
||||
} = findingsByStatus?.data?.attributes || {};
|
||||
const attributes = findingsByStatus?.data?.attributes || {};
|
||||
|
||||
const mutedTotal = muted_new + muted_changed;
|
||||
const { fail = 0, pass = 0, fail_new = 0, pass_new = 0 } = attributes;
|
||||
|
||||
return (
|
||||
<StatusChart
|
||||
failFindingsData={{
|
||||
total: fail,
|
||||
new: fail_new,
|
||||
muted: mutedTotal,
|
||||
}}
|
||||
passFindingsData={{
|
||||
total: pass,
|
||||
new: pass_new,
|
||||
muted: mutedTotal,
|
||||
}}
|
||||
providers={providersData?.data}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getFindingsBySeverity } from "@/actions/overview/overview";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../lib/filter-params";
|
||||
@@ -11,7 +12,10 @@ export const RiskSeverityChartDetailSSR = async ({
|
||||
}) => {
|
||||
const filters = pickFilterParams(searchParams);
|
||||
|
||||
const findingsBySeverity = await getFindingsBySeverity({ filters });
|
||||
const [findingsBySeverity, providersData] = await Promise.all([
|
||||
getFindingsBySeverity({ filters }),
|
||||
getProviders({ page: 1, pageSize: 200 }),
|
||||
]);
|
||||
|
||||
if (!findingsBySeverity) {
|
||||
return (
|
||||
@@ -36,6 +40,7 @@ export const RiskSeverityChartDetailSSR = async ({
|
||||
medium={medium}
|
||||
low={low}
|
||||
informational={informational}
|
||||
providers={providersData?.data}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
||||
import { BarDataPoint } from "@/components/graphs/types";
|
||||
import {
|
||||
@@ -11,12 +13,23 @@ import {
|
||||
} from "@/components/shadcn";
|
||||
import { calculatePercentage } from "@/lib/utils";
|
||||
|
||||
interface ProviderAttributes {
|
||||
uid: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
attributes: ProviderAttributes;
|
||||
}
|
||||
|
||||
interface RiskSeverityChartProps {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
informational: number;
|
||||
providers?: Provider[];
|
||||
}
|
||||
|
||||
export const RiskSeverityChart = ({
|
||||
@@ -25,7 +38,55 @@ export const RiskSeverityChart = ({
|
||||
medium,
|
||||
low,
|
||||
informational,
|
||||
providers = [],
|
||||
}: RiskSeverityChartProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleBarClick = (dataPoint: BarDataPoint) => {
|
||||
// Build the URL with current filters plus severity and muted
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
// Convert filter[provider_id__in] to filter[provider_uid__in] for findings page
|
||||
const providerIds = params.get("filter[provider_id__in]");
|
||||
if (providerIds) {
|
||||
params.delete("filter[provider_id__in]");
|
||||
// Remove provider_type__in since provider_id__in is more specific
|
||||
params.delete("filter[provider_type__in]");
|
||||
|
||||
const ids = providerIds.split(",");
|
||||
const uids = ids
|
||||
.map((id) => {
|
||||
const provider = providers.find((p) => p.id === id);
|
||||
return provider?.attributes.uid;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (uids.length > 0) {
|
||||
params.set("filter[provider_uid__in]", uids.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
// Map severity name to lowercase for the filter
|
||||
const severityMap: Record<string, string> = {
|
||||
Critical: "critical",
|
||||
High: "high",
|
||||
Medium: "medium",
|
||||
Low: "low",
|
||||
Info: "informational",
|
||||
};
|
||||
|
||||
const severity = severityMap[dataPoint.name];
|
||||
if (severity) {
|
||||
params.set("filter[severity__in]", severity);
|
||||
}
|
||||
|
||||
// Add exclude muted findings filter
|
||||
params.set("filter[muted]", "false");
|
||||
|
||||
// Navigate to findings page
|
||||
router.push(`/findings?${params.toString()}`);
|
||||
};
|
||||
// Calculate total findings
|
||||
const totalFindings = critical + high + medium + low + informational;
|
||||
|
||||
@@ -68,7 +129,7 @@ export const RiskSeverityChart = ({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 items-center justify-start px-6">
|
||||
<HorizontalBarChart data={chartData} />
|
||||
<HorizontalBarChart data={chartData} onBarClick={handleBarClick} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, BellOff, ShieldCheck, TriangleAlert } from "lucide-react";
|
||||
import { Bell, ShieldCheck, TriangleAlert } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { DonutChart } from "@/components/graphs/donut-chart";
|
||||
import { DonutDataPoint } from "@/components/graphs/types";
|
||||
@@ -15,26 +16,76 @@ import {
|
||||
} from "@/components/shadcn";
|
||||
import { calculatePercentage } from "@/lib/utils";
|
||||
|
||||
interface FindingsData {
|
||||
total: number;
|
||||
new: number;
|
||||
}
|
||||
|
||||
interface ProviderAttributes {
|
||||
uid: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
attributes: ProviderAttributes;
|
||||
}
|
||||
|
||||
interface StatusChartProps {
|
||||
failFindingsData: {
|
||||
total: number;
|
||||
new: number;
|
||||
muted: number;
|
||||
};
|
||||
passFindingsData: {
|
||||
total: number;
|
||||
new: number;
|
||||
muted: number;
|
||||
};
|
||||
failFindingsData: FindingsData;
|
||||
passFindingsData: FindingsData;
|
||||
providers?: Provider[];
|
||||
}
|
||||
|
||||
export const StatusChart = ({
|
||||
failFindingsData,
|
||||
passFindingsData,
|
||||
providers = [],
|
||||
}: StatusChartProps) => {
|
||||
// Calculate total findings
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Calculate total from displayed findings (fail + pass)
|
||||
const totalFindings = failFindingsData.total + passFindingsData.total;
|
||||
|
||||
const handleSegmentClick = (dataPoint: DonutDataPoint) => {
|
||||
// Build the URL with current filters plus status and muted
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
// Convert filter[provider_id__in] to filter[provider_uid__in] for findings page
|
||||
const providerIds = params.get("filter[provider_id__in]");
|
||||
if (providerIds) {
|
||||
params.delete("filter[provider_id__in]");
|
||||
// Remove provider_type__in since provider_id__in is more specific
|
||||
params.delete("filter[provider_type__in]");
|
||||
|
||||
const ids = providerIds.split(",");
|
||||
const uids = ids
|
||||
.map((id) => {
|
||||
const provider = providers.find((p) => p.id === id);
|
||||
return provider?.attributes.uid;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (uids.length > 0) {
|
||||
params.set("filter[provider_uid__in]", uids.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
// Add status filter based on which segment was clicked
|
||||
if (dataPoint.name === "Fail Findings") {
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
} else if (dataPoint.name === "Pass Findings") {
|
||||
params.set("filter[status__in]", "PASS");
|
||||
}
|
||||
|
||||
// Add exclude muted findings filter
|
||||
params.set("filter[muted]", "false");
|
||||
|
||||
// Navigate to findings page
|
||||
router.push(`/findings?${params.toString()}`);
|
||||
};
|
||||
|
||||
// Calculate percentages
|
||||
const failPercentage = calculatePercentage(
|
||||
failFindingsData.total,
|
||||
@@ -93,6 +144,7 @@ export const StatusChart = ({
|
||||
value: totalFindings.toLocaleString(),
|
||||
label: "Total Findings",
|
||||
}}
|
||||
onSegmentClick={handleSegmentClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -109,10 +161,7 @@ export const StatusChart = ({
|
||||
variant: CardVariant.fail,
|
||||
}}
|
||||
label="Fail Findings"
|
||||
stats={[
|
||||
{ icon: Bell, label: `${failFindingsData.new} New` },
|
||||
{ icon: BellOff, label: `${failFindingsData.muted} Muted` },
|
||||
]}
|
||||
stats={[{ icon: Bell, label: `${failFindingsData.new} New` }]}
|
||||
emptyState={
|
||||
failFindingsData.total === 0
|
||||
? { message: "No failed findings to display" }
|
||||
@@ -133,10 +182,7 @@ export const StatusChart = ({
|
||||
variant: CardVariant.pass,
|
||||
}}
|
||||
label="Pass Findings"
|
||||
stats={[
|
||||
{ icon: Bell, label: `${passFindingsData.new} New` },
|
||||
{ icon: BellOff, label: `${passFindingsData.muted} Muted` },
|
||||
]}
|
||||
stats={[{ icon: Bell, label: `${passFindingsData.new} New` }]}
|
||||
emptyState={
|
||||
passFindingsData.total === 0
|
||||
? { message: "No passed findings to display" }
|
||||
|
||||
@@ -15,25 +15,31 @@ import {
|
||||
Skeleton,
|
||||
} from "@/components/shadcn";
|
||||
|
||||
// CSS variables are required here as they're passed to RadialChart component
|
||||
// which uses Recharts library that needs actual color values, not Tailwind classes
|
||||
const THREAT_COLORS = {
|
||||
DANGER: "var(--bg-fail-primary)",
|
||||
WARNING: "var(--bg-warning-primary)",
|
||||
SUCCESS: "var(--bg-pass-primary)",
|
||||
NEUTRAL: "var(--bg-neutral-tertiary)",
|
||||
} as const;
|
||||
|
||||
const THREAT_LEVEL_CONFIG = {
|
||||
DANGER: {
|
||||
label: "Critical Risk",
|
||||
color: "var(--bg-fail-primary)",
|
||||
chartColor: "var(--bg-fail-primary)",
|
||||
color: THREAT_COLORS.DANGER,
|
||||
minScore: 0,
|
||||
maxScore: 30,
|
||||
},
|
||||
WARNING: {
|
||||
label: "Moderate Risk",
|
||||
color: "var(--bg-warning-primary)",
|
||||
chartColor: "var(--bg-warning-primary)",
|
||||
color: THREAT_COLORS.WARNING,
|
||||
minScore: 31,
|
||||
maxScore: 60,
|
||||
},
|
||||
SUCCESS: {
|
||||
label: "Secure",
|
||||
color: "var(--bg-pass-primary)",
|
||||
chartColor: "var(--bg-pass-primary)",
|
||||
color: THREAT_COLORS.SUCCESS,
|
||||
minScore: 61,
|
||||
maxScore: 100,
|
||||
},
|
||||
@@ -68,7 +74,7 @@ function convertSectionScoresToTooltipData(
|
||||
return Object.entries(sectionScores).map(([name, value]) => {
|
||||
// Determine color based on the same ranges as THREAT_LEVEL_CONFIG
|
||||
const threatLevel = getThreatLevel(value);
|
||||
const color = THREAT_LEVEL_CONFIG[threatLevel].chartColor;
|
||||
const color = THREAT_LEVEL_CONFIG[threatLevel].color;
|
||||
|
||||
return { name, value, color };
|
||||
});
|
||||
@@ -126,8 +132,8 @@ export function ThreatScore({
|
||||
<RadialChart
|
||||
percentage={displayScore}
|
||||
label="Score"
|
||||
color={config.chartColor}
|
||||
backgroundColor="var(--bg-neutral-tertiary)"
|
||||
color={config.color}
|
||||
backgroundColor={THREAT_COLORS.NEUTRAL}
|
||||
height={206}
|
||||
innerRadius={90}
|
||||
outerRadius={115}
|
||||
@@ -162,8 +168,7 @@ export function ThreatScore({
|
||||
<div className="flex items-start gap-1.5">
|
||||
<ThumbsUp
|
||||
size={16}
|
||||
className="mt-0.5 shrink-0"
|
||||
style={{ minWidth: "16px", minHeight: "16px" }}
|
||||
className="mt-0.5 min-h-4 min-w-4 shrink-0"
|
||||
/>
|
||||
<p>
|
||||
Threat score has{" "}
|
||||
@@ -178,8 +183,7 @@ export function ThreatScore({
|
||||
<div className="flex items-start gap-1.5">
|
||||
<MessageCircleWarning
|
||||
size={16}
|
||||
className="mt-0.5 shrink-0"
|
||||
style={{ minWidth: "16px", minHeight: "16px" }}
|
||||
className="mt-0.5 min-h-4 min-w-4 shrink-0"
|
||||
/>
|
||||
<p>
|
||||
Major gaps include {gaps.slice(0, 2).join(", ")}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface DonutChartProps {
|
||||
value: string | number;
|
||||
label: string;
|
||||
};
|
||||
onSegmentClick?: (dataPoint: DonutDataPoint, index: number) => void;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
@@ -72,6 +73,7 @@ export function DonutChart({
|
||||
outerRadius = 86,
|
||||
showLegend = true,
|
||||
centerLabel,
|
||||
onSegmentClick,
|
||||
}: DonutChartProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
@@ -137,14 +139,23 @@ export function DonutChart({
|
||||
{(isEmpty ? emptyData : chartData).map((entry, index) => {
|
||||
const opacity =
|
||||
hoveredIndex === null ? 1 : hoveredIndex === index ? 1 : 0.5;
|
||||
const isClickable = !isEmpty && onSegmentClick;
|
||||
return (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.fill}
|
||||
opacity={opacity}
|
||||
style={{ transition: "opacity 0.2s" }}
|
||||
style={{
|
||||
transition: "opacity 0.2s",
|
||||
cursor: isClickable ? "pointer" : "default",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
onSegmentClick(data[index], index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -12,12 +12,14 @@ interface HorizontalBarChartProps {
|
||||
height?: number;
|
||||
title?: string;
|
||||
labelWidth?: string;
|
||||
onBarClick?: (dataPoint: BarDataPoint, index: number) => void;
|
||||
}
|
||||
|
||||
export function HorizontalBarChart({
|
||||
data,
|
||||
title,
|
||||
labelWidth = "w-20",
|
||||
onBarClick,
|
||||
}: HorizontalBarChartProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
@@ -58,12 +60,33 @@ export function HorizontalBarChart({
|
||||
getSeverityColorByName(item.name) ||
|
||||
"var(--bg-neutral-tertiary)";
|
||||
|
||||
const isClickable = !isEmpty && onBarClick;
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex items-center gap-10"
|
||||
style={{ cursor: isClickable ? "pointer" : "default" }}
|
||||
role={isClickable ? "button" : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onMouseEnter={() => !isEmpty && setHoveredIndex(index)}
|
||||
onMouseLeave={() => !isEmpty && setHoveredIndex(null)}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
const originalIndex = data.findIndex(
|
||||
(d) => d.name === item.name,
|
||||
);
|
||||
onBarClick(data[originalIndex], originalIndex);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isClickable && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
const originalIndex = data.findIndex(
|
||||
(d) => d.name === item.name,
|
||||
);
|
||||
onBarClick(data[originalIndex], originalIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Label */}
|
||||
<div className={`w-20 md:${labelWidth} shrink-0`}>
|
||||
|
||||
Reference in New Issue
Block a user