From b1866a2ebe5b7e8b10719c6f0e3d8260cf181b96 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:30:46 -0400 Subject: [PATCH 1/2] fix(release-tracks): show summary counts on release track cards --- .../release-track-card.component.html | 6 +- .../release-management.component.ts | 87 +++---------------- 2 files changed, 14 insertions(+), 79 deletions(-) diff --git a/src/app/components/release-track-card/release-track-card.component.html b/src/app/components/release-track-card/release-track-card.component.html index e3bbfe28a..c9dac33c5 100644 --- a/src/app/components/release-track-card/release-track-card.component.html +++ b/src/app/components/release-track-card/release-track-card.component.html @@ -25,19 +25,19 @@
CANDIDATES
- {{ track.candidates?.length || 'TODO' }} + {{ track.stats?.candidates ?? 0 }}
STAGED
- {{ track.staged?.length || 'TODO' }} + {{ track.stats?.staged ?? 0 }}
MEMBERS
- {{ track.members?.length || 'TODO' }} + {{ track.stats?.members ?? 0 }}
diff --git a/src/app/views/dashboard-page/release-management/release-management.component.ts b/src/app/views/dashboard-page/release-management/release-management.component.ts index 98385320c..c77e576dc 100644 --- a/src/app/views/dashboard-page/release-management/release-management.component.ts +++ b/src/app/views/dashboard-page/release-management/release-management.component.ts @@ -57,11 +57,7 @@ export class ReleaseManagementComponent implements OnInit, OnDestroy { private loadTracks(): void { this.subscription = this.connector.listReleaseTracks().subscribe(result => { - if (!result || !result.data) { - this.allTracks = []; - return; - } - this.allTracks = this.tracksWithComputedData(result.data); + this.allTracks = this.tracksWithComputedData(result?.data ?? []); }); } @@ -74,80 +70,19 @@ export class ReleaseManagementComponent implements OnInit, OnDestroy { } private tracksWithComputedData(data: any[]): any[] { - return data?.map((track: any) => { - // If snapshots are available, compute stats from them. Otherwise use - // server-provided summary fields like latest_snapshot_modified. - const snapshots: any[] = Array.isArray(track.snapshots) - ? track.snapshots - : []; - - let latestVersion: any = 'No tagged releases'; - let latestModified: any = - track.latest_snapshot_modified || - track.created_at || - track.updated_at || - null; - let stats: any = { - candidates: 0, - staged: 0, - members: 0, - quarantined: 0, - }; - - if (snapshots.length) { - const sortedByModified = [...snapshots].sort((a, b) => { - const ma = a?.modified || ''; - const mb = b?.modified || ''; - return String(mb).localeCompare(String(ma)); - }); - - const latestSnapshot = sortedByModified[0] || null; - const taggedSnapshots = sortedByModified.filter( - (s: any) => s && s.version - ); - const latestTaggedSnapshot = - taggedSnapshots.length > 0 ? taggedSnapshots[0] : null; - - latestVersion = latestTaggedSnapshot - ? latestTaggedSnapshot.version - : 'No tagged releases'; - latestModified = - latestSnapshot?.modified || latestSnapshot?.created || latestModified; - - stats = { - candidates: - latestSnapshot?.candidates?.length || - latestSnapshot?.contents?.candidates?.length || - 0, - staged: - latestSnapshot?.staged?.length || - latestSnapshot?.contents?.staged?.length || - 0, - members: - latestTaggedSnapshot?.members?.length || - latestTaggedSnapshot?.contents?.members?.length || - 0, - quarantined: - latestSnapshot?.quarantine?.length || - latestSnapshot?.contents?.quarantine?.length || - 0, - }; - } else { - latestVersion = track.latest_tagged_version || 'No tagged releases'; - latestModified = track.latest_snapshot_modified || latestModified; - stats = { - candidates: track.snapshot_count || 0, - staged: 0, - members: track.tagged_release_count || 0, - quarantined: 0, - }; - } + return data.map((track: any) => { + const summary = track.summary ?? {}; return { ...track, - latestVersion, - latestModified, - stats, + latestVersion: track.latest_tagged_version ?? 'No tagged releases', + latestModified: track.latest_snapshot_modified, + stats: { + candidates: summary.candidates_count ?? 0, + staged: summary.staged_count ?? 0, + members: summary.members_count ?? 0, + quarantined: 0, + }, }; }); } From 2bf030f55b1886110100e024d45b4b955a4cc9f3 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:57:40 -0400 Subject: [PATCH 2/2] feat(release-tracks): show object details on release track object cards --- .../classes/release-tracks/snapshot.spec.ts | 69 +++++++++++++++ src/app/classes/release-tracks/snapshot.ts | 2 + src/app/classes/release-tracks/tiers.ts | 18 +++- .../release-track-object-card.component.html | 4 +- .../release-track-object-card.component.scss | 21 ++++- ...elease-track-object-card.component.spec.ts | 85 +++++++++++++++++++ .../release-track-object-card.component.ts | 42 +++++++-- .../user-avatar/user-avatar.component.spec.ts | 38 +++++---- 8 files changed, 247 insertions(+), 32 deletions(-) create mode 100644 src/app/classes/release-tracks/snapshot.spec.ts create mode 100644 src/app/components/release-track-object-card/release-track-object-card.component.spec.ts diff --git a/src/app/classes/release-tracks/snapshot.spec.ts b/src/app/classes/release-tracks/snapshot.spec.ts new file mode 100644 index 000000000..242003702 --- /dev/null +++ b/src/app/classes/release-tracks/snapshot.spec.ts @@ -0,0 +1,69 @@ +import { ReleaseTrackSnapshot } from './snapshot'; + +describe('ReleaseTrackSnapshot', () => { + it('should preserve candidate and staged display fields', () => { + const snapshot = new ReleaseTrackSnapshot({ + candidates: [ + { + object_ref: 'attack-pattern--candidate', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: 'work-in-progress', + attack_id: 'T1234', + name: 'Candidate Technique', + description: 'Candidate description', + modified_by_user: { + id: 'user-account--candidate', + username: 'candidateuser', + name: 'Candidate Reviewer', + }, + }, + ], + staged: [ + { + object_ref: 'attack-pattern--staged', + object_modified: '2026-01-02T00:00:00.000Z', + object_status: 'reviewed', + attack_id: 'T5678', + name: 'Staged Technique', + description: 'Staged description', + modified_by_user: { + id: 'user-account--staged', + username: 'stageduser', + name: 'Staged Reviewer', + }, + }, + ], + members: [ + { + object_ref: 'attack-pattern--member', + object_modified: '2026-01-03T00:00:00.000Z', + attack_id: 'T9999', + name: 'Member Technique', + }, + ], + }); + + expect(snapshot.candidates?.[0]).toEqual( + expect.objectContaining({ + attack_id: 'T1234', + name: 'Candidate Technique', + description: 'Candidate description', + modified_by_user: expect.objectContaining({ + name: 'Candidate Reviewer', + }), + }) + ); + expect(snapshot.staged?.[0]).toEqual( + expect.objectContaining({ + attack_id: 'T5678', + name: 'Staged Technique', + description: 'Staged description', + modified_by_user: expect.objectContaining({ + name: 'Staged Reviewer', + }), + }) + ); + expect(snapshot.members[0]).not.toHaveProperty('attack_id'); + expect(snapshot.members[0]).not.toHaveProperty('name'); + }); +}); diff --git a/src/app/classes/release-tracks/snapshot.ts b/src/app/classes/release-tracks/snapshot.ts index 93e663156..e45b42d52 100644 --- a/src/app/classes/release-tracks/snapshot.ts +++ b/src/app/classes/release-tracks/snapshot.ts @@ -112,6 +112,7 @@ export class ReleaseTrackSnapshot { if ('staged' in raw && Array.isArray(raw.staged)) { this.staged = raw.staged.map((s: any) => ({ + ...s, object_ref: s.object_ref, object_modified: new Date(s.object_modified), object_status: s.object_status, @@ -124,6 +125,7 @@ export class ReleaseTrackSnapshot { if ('candidates' in raw && Array.isArray(raw.candidates)) { this.candidates = raw.candidates.map((c: any) => ({ + ...c, object_ref: c.object_ref, object_modified: new Date(c.object_modified), object_status: c.object_status, diff --git a/src/app/classes/release-tracks/tiers.ts b/src/app/classes/release-tracks/tiers.ts index 3625b7d28..1f80343d1 100644 --- a/src/app/classes/release-tracks/tiers.ts +++ b/src/app/classes/release-tracks/tiers.ts @@ -1,11 +1,25 @@ import { WorkflowStatusType } from 'src/app/utils/types'; +export interface TierEntryModifiedByUser { + id?: string; + username?: string; + displayName?: string; + name?: string; +} + +export interface TierEntryDisplayFields { + attack_id?: string; + name?: string; + description?: string; + modified_by_user?: TierEntryModifiedByUser; +} + export interface MemberEntry { object_ref: string; object_modified: Date; } -export interface StagedEntry { +export interface StagedEntry extends TierEntryDisplayFields { object_ref: string; object_modified: Date; object_status: WorkflowStatusType; @@ -13,7 +27,7 @@ export interface StagedEntry { object_staged_by: string; } -export interface CandidateEntry { +export interface CandidateEntry extends TierEntryDisplayFields { object_ref: string; object_modified: Date; object_status: WorkflowStatusType; diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.html b/src/app/components/release-track-object-card/release-track-object-card.component.html index 24dece347..50cd9631b 100644 --- a/src/app/components/release-track-object-card/release-track-object-card.component.html +++ b/src/app/components/release-track-object-card/release-track-object-card.component.html @@ -18,9 +18,7 @@ -

- {{ description }} -

+
diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.scss b/src/app/components/release-track-object-card/release-track-object-card.component.scss index bfffb216a..30d81ea4d 100644 --- a/src/app/components/release-track-object-card/release-track-object-card.component.scss +++ b/src/app/components/release-track-object-card/release-track-object-card.component.scss @@ -115,14 +115,29 @@ } .object-description { - display: -webkit-box; + display: block; min-height: 30px; margin: 0; font-size: 14px; font-weight: 500; line-height: 22px; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; + + ::ng-deep p { + display: -webkit-box; + overflow: hidden; + margin: 0; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } + + ::ng-deep :last-child { + margin-bottom: 0; + } + + ::ng-deep a { + font-weight: 700; + } .light & { color: colors.on-color(light); diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.spec.ts b/src/app/components/release-track-object-card/release-track-object-card.component.spec.ts new file mode 100644 index 000000000..bca280f35 --- /dev/null +++ b/src/app/components/release-track-object-card/release-track-object-card.component.spec.ts @@ -0,0 +1,85 @@ +import { ReleaseTrackObjectCardComponent } from './release-track-object-card.component'; + +describe('ReleaseTrackObjectCardComponent', () => { + let component: ReleaseTrackObjectCardComponent; + + beforeEach(() => { + component = new ReleaseTrackObjectCardComponent(); + }); + + it('should show tier entry display fields when available', () => { + component.item = { + object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc', + object_modified: '2026-01-01T00:00:00.000Z', + attack_id: 'T1234', + name: 'Technique Name', + description: 'Technique description\n\nAdditional details.', + modified_by_user: { + id: 'user-account--1234', + username: 'reviewer1', + displayName: 'Review User', + name: 'Review User', + }, + }; + + expect(component.title).toBe('Technique Name'); + expect(component.subtitle).toBe('T1234'); + expect(component.description).toBe('Technique description'); + expect(component.modifiedByName).toBe('Review User'); + }); + + it('should use username when modified by user has no display name', () => { + component.item = { + object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc', + modified_by_user: { + username: 'reviewer1', + }, + }; + + expect(component.modifiedByName).toBe('reviewer1'); + }); + + it('should preserve markdown syntax in descriptions for rendering', () => { + component.item = { + object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc', + description: '**Markdown** [description](https://example.com)', + }; + + expect(component.description).toBe( + '**Markdown** [description](https://example.com)' + ); + }); + + it('should prefer display name over username and name', () => { + component.item = { + object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc', + modified_by_user: { + username: 'releaseuser', + displayName: 'Release Reviewer', + name: 'Fallback Name', + }, + }; + + expect(component.modifiedByName).toBe('Release Reviewer'); + }); + + it('should prefer modified by user info over staged identity ids', () => { + component.item = { + object_ref: 'attack-pattern--990e4d99-5c6f-4f34-b6f0-7221c55f39cb', + object_modified: '2026-04-22T17:57:26.218Z', + object_status: 'reviewed', + object_staged_at: '2026-06-24T19:50:19.859Z', + object_staged_by: 'identity--00000000-0000-4000-8000-000000000001', + attack_id: 'T1640.001', + name: '0 New Sub', + modified_by_user: { + id: 'identity--00000000-0000-4000-8000-000000000001', + username: 'releaseuser', + displayName: 'Release Reviewer', + name: 'Release Reviewer', + }, + }; + + expect(component.modifiedByName).toBe('Release Reviewer'); + }); +}); diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.ts b/src/app/components/release-track-object-card/release-track-object-card.component.ts index 279d14116..45517e40c 100644 --- a/src/app/components/release-track-object-card/release-track-object-card.component.ts +++ b/src/app/components/release-track-object-card/release-track-object-card.component.ts @@ -5,9 +5,11 @@ import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import moment from 'moment'; +import { MarkdownModule } from 'ngx-markdown'; import { StixTypeToAttackType } from 'src/app/utils/type-mappings'; import { StixType, WorkflowStatusType } from 'src/app/utils/types'; import { UserAvatarComponent } from '../user-avatar/user-avatar.component'; +import type { TierEntryModifiedByUser } from 'src/app/classes/release-tracks'; export interface ReleaseTrackObjectItem { object_ref: string; @@ -17,6 +19,10 @@ export interface ReleaseTrackObjectItem { object_added_by?: string; object_staged_at?: Date | string; object_staged_by?: string; + attack_id?: string; + name?: string; + description?: string; + modified_by_user?: TierEntryModifiedByUser; [key: string]: any; } @@ -29,6 +35,7 @@ export interface ReleaseTrackObjectItem { MatCardModule, MatIconModule, MatTooltipModule, + MarkdownModule, UserAvatarComponent, ], templateUrl: './release-track-object-card.component.html', @@ -46,15 +53,20 @@ export class ReleaseTrackObjectCardComponent { @Output() diffObject = new EventEmitter(); public get title(): string { - return this.item?.name || this.fallbackObjectLabel; + return ( + this.item?.name || this.item?.object_name || this.fallbackObjectLabel + ); } public get subtitle(): string { - return this.item?.attackId || '<>'; + return this.item?.attack_id || this.item?.attackId || '<>'; } public get description(): string { - const description = this.item?.description || 'No description available.'; + const description = + this.item?.description || + this.item?.object_description || + 'No description available.'; return this.firstParagraph(description); } @@ -79,11 +91,13 @@ export class ReleaseTrackObjectCardComponent { public get modifiedByName(): string { return ( - this.item?.modified_by_user || - this.item?.object_modified_by || - this.item?.object_added_by || - this.item?.object_staged_by || - this.item?.modified_by_ref || + this.userDisplayName(this.item?.modified_by_user, false) || + this.userDisplayName( + this.item?.object_modified_by || + this.item?.object_added_by || + this.item?.object_staged_by || + this.item?.modified_by_ref + ) || 'Unknown User' ); } @@ -125,4 +139,16 @@ export class ReleaseTrackObjectCardComponent { .filter(Boolean); return paragraph || 'No description available.'; } + + private userDisplayName(value: any, includeId = true): string | null { + if (!value) return null; + if (typeof value === 'string') return value; + + return ( + value.displayName || + value.username || + value.name || + (includeId ? value.id : null) + ); + } } diff --git a/src/app/components/user-avatar/user-avatar.component.spec.ts b/src/app/components/user-avatar/user-avatar.component.spec.ts index 5394c2905..5d5daad95 100644 --- a/src/app/components/user-avatar/user-avatar.component.spec.ts +++ b/src/app/components/user-avatar/user-avatar.component.spec.ts @@ -22,52 +22,58 @@ describe('UserAvatarComponent', () => { }); it('should show first and last initials for a display name with middle names', () => { - component.name = 'Cassandra Lauren Smith'; + component.name = 'Example Middle User'; - expect(component.initials).toBe('CS'); + expect(component.initials).toBe('EU'); }); it('should show first and last initials for a two-word display name', () => { - component.name = 'Laurent Hacks'; + component.name = 'Release Analyst'; - expect(component.initials).toBe('LH'); + expect(component.initials).toBe('RA'); + }); + + it('should show first and last initials for the modified by user display name', () => { + component.name = 'Review User'; + + expect(component.initials).toBe('RU'); }); it('should show the first two letters for usernames', () => { - component.name = 'clsmith'; + component.name = 'exampleuser'; - expect(component.initials).toBe('CL'); + expect(component.initials).toBe('EX'); }); it('should show the first two letters for usernames that match display initials', () => { - component.name = 'lhacks'; + component.name = 'releaseuser'; - expect(component.initials).toBe('LH'); + expect(component.initials).toBe('RE'); }); it('should ignore prefixes and suffixes when building display initials', () => { - component.name = 'Dr. Cassandra Lauren Smith Jr.'; + component.name = 'Dr. Example Middle User Jr.'; - expect(component.initials).toBe('CS'); + expect(component.initials).toBe('EU'); }); it('should ignore professional suffixes when building display initials', () => { - component.name = 'Laurent Hacks, PhD'; + component.name = 'Release Analyst, PhD'; - expect(component.initials).toBe('LH'); + expect(component.initials).toBe('RA'); }); it('should build initials from the remaining name when only a prefix is ignored', () => { - component.name = 'Dr. Cassandra'; + component.name = 'Dr. Example'; - expect(component.initials).toBe('CA'); + expect(component.initials).toBe('EX'); }); it('should assign the same background to the same name', () => { - component.name = 'Mary Jackson'; + component.name = 'Stable User'; const background = component.background; - component.name = 'Mary Jackson'; + component.name = 'Stable User'; expect(component.background).toBe(background); });