130 lines
6.4 KiB
TypeScript
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)
|
|
);
|
|
|