Assessor_2.0/src/components/TileCard/TileCard.tsx

130 lines
6.4 KiB
TypeScript

import { memo, type CSSProperties, type KeyboardEvent } from 'react';
import type { FindingRecord } from '../../domain/finding';
import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { resultStatusFieldFor } from '../../features/linked-results/result-message';
import { highlightText } from '../HighlightText/highlight-text';
import { BookmarkIcon } from '../BookmarkIcon';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
export interface TileBadge {
label: string;
value: string;
text: string;
severity?: string;
}
export function tileBadges(record: FindingRecord, fallbackToolId?: string): TileBadge[] {
const requested = [
{ label: 'Status', pattern: /^(result|status|evaluation)$/i },
{ label: 'Category', pattern: /^category$/i },
{ label: 'Severity', pattern: /^(severity|criticality|priority)$/i },
{ label: 'Weight', pattern: /^weight$/i },
{ label: 'Score', pattern: /^score$/i }
];
const badges = requested.flatMap<TileBadge>(({ label, pattern }) => {
const field = label === 'Status' ? resultStatusFieldFor(record, fallbackToolId) : record.fields.find(candidate => {
if (!candidate.value) return false;
return pattern.test(candidate.label.trim());
});
if (!field) return [];
const text = /^(Weight|Score)$/.test(label) ? `${label}: ${field.value}` : field.value;
return [{ label, value: field.value, text, ...(label === 'Severity' ? { severity: normalizeSeverity(field.value) } : {}) }];
});
const cisBadges = cisBenchmarkCategories(record, fallbackToolId).map(value => ({ label: 'CIS Category', value, text: value }));
const orderedBadges = [
...badges.filter(badge => badge.label === 'Status' || badge.label === 'Category'),
...cisBadges,
...badges.filter(badge => badge.label !== 'Status' && badge.label !== 'Category')
];
return orderedBadges.filter((badge, index) => !orderedBadges.slice(0, index).some(previous => previous.value.trim().toLocaleLowerCase('en-US') === badge.value.trim().toLocaleLowerCase('en-US')));
}
export function compactTileBadges(badges: TileBadge[], limit?: number): TileBadge[] {
if (!limit || badges.length <= limit) return badges;
const cisBadges = badges.filter(badge => badge.label === 'CIS Category');
const preferred = cisBadges.length
? [badges.find(badge => badge.label === 'Status'), badges.find(badge => badge.label === 'Weight'), cisBadges.at(-1)]
: [badges.find(badge => badge.label === 'Status'), badges.find(badge => badge.label === 'Category'), badges.find(badge => badge.label === 'Severity')];
const selected = preferred.filter((badge, index, candidates): badge is TileBadge => Boolean(badge) && candidates.indexOf(badge) === index);
for (const badge of badges) {
if (selected.length >= limit - 1) break;
if (!selected.includes(badge)) selected.push(badge);
}
const hidden = badges.length - selected.length;
return [...selected, { label: 'More', value: String(hidden), text: `+${hidden}` }];
}
interface Props {
record: FindingRecord;
title: string;
bookmarked: boolean;
resultColor?: string;
/** Werkzeug-ID des Datasets; entscheidet ueber die werkzeuggebundene Statusnormalisierung. */
toolId?: string;
search?: string;
badgeLimit?: number;
onOpen(): void;
onBookmark(): void;
onResults?(): void;
}
function TileCardComponent({ record, title, bookmarked, resultColor, toolId, search, badgeLimit, onOpen, onBookmark, onResults }: Props) {
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
const severity = normalizeSeverity(severityField?.value);
const recordToolId = toolIdForRecord(record, toolId);
const badges = tileBadges(record, recordToolId);
const visibleBadges = compactTileBadges(badges, badgeLimit);
const style = resultColor ? ({ '--result-color': resultColor } as CSSProperties) : undefined;
const activate = (event: KeyboardEvent<HTMLElement>) => {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
onOpen();
};
return (
<article
className={`data-tile severity-${severity}${bookmarked ? ' is-bookmarked' : ''}`}
tabIndex={0}
role="button"
onClick={onOpen}
onKeyDown={activate}
>
<span className="data-tile-hex" aria-hidden="true" />
<div className="data-tile-content">
<h3>{highlightText(title, search)}</h3>
<div className="data-tile-badges">
{visibleBadges.map(badge => badge.label === 'Status'
/* Der Ergebnisstatus ist ein Outcome, keine Meta-Plakette: Textfarbe
plus Glyph statt gefuellter Flaeche. Sonst sehen Passed, Failed
und Investigate in der Kachelansicht identisch aus. */
? <span className="outcome-mark" data-badge-role="status" data-outcome={outcomeForStatusValue(recordToolId, badge.value)} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>
: <span className={`reader-severity${badge.severity ? ` severity-${badge.severity}` : ''}${badge.label === 'More' ? ' tile-badge-overflow' : ''}`} data-badge-role={badge.label === 'CIS Category' ? 'category' : badge.label.toLocaleLowerCase('en-US')} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>)}
</div>
</div>
<div className="data-tile-actions">
<button className="reader-bookmark-btn tile-bookmark-btn" type="button" aria-pressed={bookmarked} title={bookmarked ? 'Remove bookmark' : 'Mark as bookmark'} onClick={event => {
event.stopPropagation();
onBookmark();
}}><BookmarkIcon filled={bookmarked} /></button>
{onResults && <button className={`tile-results-btn${resultColor ? ' is-open' : ''}`} style={style} type="button" title="Show results" onClick={event => {
event.stopPropagation();
onResults();
}}>R</button>}
</div>
</article>
);
}
export const TileCard = memo(TileCardComponent, (previous, next) =>
previous.record === next.record &&
previous.title === next.title &&
previous.bookmarked === next.bookmarked &&
previous.resultColor === next.resultColor &&
previous.toolId === next.toolId &&
previous.search === next.search &&
previous.badgeLimit === next.badgeLimit &&
Boolean(previous.onResults) === Boolean(next.onResults)
);