From c52aa230baa5fc7e8d239f56d829d20f10420eda Mon Sep 17 00:00:00 2001 From: nikitita Date: Mon, 19 Oct 2026 23:02:11 +0200 Subject: [PATCH 1/2] feat: add reusable feedback cards for query states --- .../src/components/feedback/FeedbackCard.css | 57 ++++++++++ .../src/components/feedback/FeedbackCard.tsx | 39 +++++++ .../ThreatActors/ThreatActorDetailed.tsx | 103 +++++++++++++----- .../src/pages/ThreatActors/ThreatActors.tsx | 66 ++++++++--- 4 files changed, 221 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/feedback/FeedbackCard.css create mode 100644 frontend/src/components/feedback/FeedbackCard.tsx diff --git a/frontend/src/components/feedback/FeedbackCard.css b/frontend/src/components/feedback/FeedbackCard.css new file mode 100644 index 0000000..0c4332c --- /dev/null +++ b/frontend/src/components/feedback/FeedbackCard.css @@ -0,0 +1,57 @@ + +.feedback-card { + min-height: 220px; + padding: 32px 20px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + + text-align: center; + + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; +} + +.feedback-card > svg { + color: var(--accent); +} + +.feedback-card--error { + border-color: #78424c; +} + +.feedback-card--error > svg { + color: #fb7185; +} + +.feedback-card h2 { + margin: 0; + color: var(--text-heading); + font-size: 20px; +} + +.feedback-card p { + margin: 0; + color: var(--text); + font-size: 14px; +} + +.feedback-spinner { + animation: feedback-spin 1s linear infinite; +} + +@keyframes feedback-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .feedback-spinner { + animation: none; + } +} diff --git a/frontend/src/components/feedback/FeedbackCard.tsx b/frontend/src/components/feedback/FeedbackCard.tsx new file mode 100644 index 0000000..098f9f7 --- /dev/null +++ b/frontend/src/components/feedback/FeedbackCard.tsx @@ -0,0 +1,39 @@ + +import { AlertCircle, LoaderCircle, SearchX } from "lucide-react"; +import "./FeedbackCard.css"; + +type FeedbackCardProps = { + variant: "loading" | "error" | "empty"; + title: string; + message?: string; +}; + +function FeedbackCard({ variant, title, message }: FeedbackCardProps) { + + const icons = { + loading: LoaderCircle, + error: AlertCircle, + empty: SearchX, + }; + + const Icon = icons[variant]; + + return ( +
+
+ ); +} + +export default FeedbackCard; diff --git a/frontend/src/pages/ThreatActors/ThreatActorDetailed.tsx b/frontend/src/pages/ThreatActors/ThreatActorDetailed.tsx index e561fdc..3553dda 100644 --- a/frontend/src/pages/ThreatActors/ThreatActorDetailed.tsx +++ b/frontend/src/pages/ThreatActors/ThreatActorDetailed.tsx @@ -1,7 +1,7 @@ import ThreatActorDetailedHeader from "../../components/threatactor/ThreatActorDetailed/ThreatActorDetailedHeader/ThreatActorDetailedHeader"; import ThreatActorOverview from "../../components/threatactor/ThreatActorDetailed/ThreatActorDetailedOverview/ThreatActorOverview"; import ThreatActorDetailedInfoCards from "../../components/threatactor/ThreatActorDetailed/ThreatActorDetailedInfoCards/ThreatActorDetailedInfoCards"; - +import FeedbackCard from "../../components/feedback/FeedbackCard"; import "./ThreatActorDetailed.css"; import ThreatActorDetailedDescription from "../../components/threatactor/ThreatActorDetailed/ThreatActorDetailedDescription/ThreatActorDetailedDescription"; import { useParams } from "react-router-dom"; @@ -30,7 +30,7 @@ function ThreatActorDetailed() { ); //Henter ut Assosierte Techniques - const {data: techniquesData} = useQuery(GET_THREAT_ACTOR_TECHNIQUES, { + const {data: techniquesData, loading: techniquesLoading, error: techniquesError} = useQuery(GET_THREAT_ACTOR_TECHNIQUES, { variables: { mitreId: mitreId ?? "", limit: PAGE_SIZE, @@ -40,7 +40,7 @@ function ThreatActorDetailed() { }); //Henter ut Assosierte Softwares - const {data: softwaresData} = useQuery(GET_THREAT_ACTOR_SOFTWARES, { + const {data: softwaresData, loading: softwaresLoading, error: softwaresError} = useQuery(GET_THREAT_ACTOR_SOFTWARES, { variables: { mitreId: mitreId ?? "", limit: PAGE_SIZE, @@ -48,26 +48,39 @@ function ThreatActorDetailed() { }, skip: !mitreId, }); - + if (!mitreId) { - return

Missing Threat Actor ID.

; + return ; } if (detailedLoading) { - return

Loading Threat Actor...

; + return ; } if (detailedError) { - return

Error loading Threat Actor: {detailedError.message}

; + return ; } const actor = detailedData?.getThreatActorByMitreId; - if(!actor){ - return

Threat Actor not found.

; + if (!actor) { + return ; } - return (
@@ -106,25 +119,57 @@ function ThreatActorDetailed() { xMitreDeprecated={actor.xMitreDeprecated} /> -
- - - -
+ +
+ + {/* Associated Techniques */} + {techniquesLoading ? ( + + ) : techniquesError ? ( + + ) : ( + + )} + + {/* Associated Software */} + {softwaresLoading ? ( + + ) : softwaresError ? ( + + ) : ( + + )} + +
+
diff --git a/frontend/src/pages/ThreatActors/ThreatActors.tsx b/frontend/src/pages/ThreatActors/ThreatActors.tsx index 20de402..6b43380 100644 --- a/frontend/src/pages/ThreatActors/ThreatActors.tsx +++ b/frontend/src/pages/ThreatActors/ThreatActors.tsx @@ -7,6 +7,7 @@ import { useQuery } from "@apollo/client/react"; import { GET_ALL_THREAT_ACTOR_GRID, GET_THREAT_ACTOR_FILTER_OPTIONS } from "../../graphql/queries/threatActorQueries"; import { useState } from "react"; import ThreatActorActiveFilters from "../../components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters"; +import FeedbackCard from "../../components/feedback/FeedbackCard"; @@ -81,7 +82,7 @@ function ThreatActor(){ const { sortBy, ...filters } = activeFilters; - const {data} = useQuery(GET_ALL_THREAT_ACTOR_GRID, { + const {data, loading, error} = useQuery(GET_ALL_THREAT_ACTOR_GRID, { variables: { limit: pageSize, offset: (page - 1) * pageSize, @@ -97,7 +98,7 @@ function ThreatActor(){ const actorsCount = data?.allThreatActorsGrid.totalCount ?? 0; //Henter ut data for filter - const {data: filterData} = useQuery(GET_THREAT_ACTOR_FILTER_OPTIONS, {fetchPolicy: "cache-first"}) + const {data: filterData, loading: filtersLoading, error: filtersError} = useQuery(GET_THREAT_ACTOR_FILTER_OPTIONS, {fetchPolicy: "cache-first"}) const filterOptions = filterData?.threatActorFilterOptions; return ( @@ -109,12 +110,27 @@ function ThreatActor(){ onSearch={handleSearch} /> - + {filtersError ? ( + + ) : filtersLoading && !filterOptions ? ( + + ) : ( + + )} + - + {loading ? ( + + ) : error ? ( + + ) : actors.length === 0 ? ( + + ) : ( + + )} ); } From 56521dbbde340a24b70b4b182f13cd093965bebc Mon Sep 17 00:00:00 2001 From: nikitita Date: Tue, 20 Oct 2026 12:31:06 +0200 Subject: [PATCH 2/2] fix: display empty feedback for missing associations --- .../ThreatActorAssociatedList.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/components/threatactor/ThreatActorDetailed/ThreatActorAssociated/ThreatActorAssociatedList.tsx b/frontend/src/components/threatactor/ThreatActorDetailed/ThreatActorAssociated/ThreatActorAssociatedList.tsx index 177f0e6..b8e97c5 100644 --- a/frontend/src/components/threatactor/ThreatActorDetailed/ThreatActorAssociated/ThreatActorAssociatedList.tsx +++ b/frontend/src/components/threatactor/ThreatActorDetailed/ThreatActorAssociated/ThreatActorAssociatedList.tsx @@ -6,6 +6,7 @@ import { } from "lucide-react"; import type { AssociatedItem } from "../../../../types/ThreatActorTypes"; import "./ThreatActorAssociatedList.css"; +import FeedbackCard from "../../../feedback/FeedbackCard"; type ThreatActorAssociatedListProps = { type: "techniques" | "software"; @@ -30,6 +31,18 @@ function ThreatActorAssociatedList({type,items,totalCount,page,pageSize,onPageCh + {/* Vis empty state dersom ingen tilknyttede entiteter finnes */} + {totalCount === 0 && ( + + )}
{items.map((item) => (