From af8d2e29947a8ae1a4ffffc9d04eaada15196904 Mon Sep 17 00:00:00 2001 From: avwchapman Date: Mon, 20 Jul 2026 10:38:55 -0400 Subject: [PATCH 1/3] feat: add release track membership view to STIX objects --- src/app/app.module.ts | 2 + .../membership-section.component.html | 174 +++++ .../membership-section.component.scss | 650 ++++++++++++++++++ .../membership-section.component.spec.ts | 198 ++++++ .../membership-section.component.ts | 568 +++++++++++++++ .../stix-page-tabs.component.html | 10 + .../stix-page-tabs.component.ts | 1 + .../membership-section-data.service.ts | 615 +++++++++++++++++ .../release-track-page.component.html | 1 + 9 files changed, 2219 insertions(+) create mode 100644 src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html create mode 100644 src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss create mode 100644 src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts create mode 100644 src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts create mode 100644 src/app/services/connectors/rest-api/membership-section-data.service.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9b3e0b325..3efcde6fc 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -76,6 +76,7 @@ import { MultipleChoiceDialogComponent } from './components/multiple-choice-dial import { NavigationComponent } from './components/navigation/navigation.component'; import { ReferenceEditDialogComponent } from './components/reference-edit-dialog/reference-edit-dialog.component'; import { HistoryTimelineComponent } from './components/stix/stix-page-tabs/history-timeline/history-timeline.component'; +import { MembershipSectionComponent } from './components/stix/stix-page-tabs/membership-section/membership-section.component'; import { ReferenceSidebarComponent } from './components/resources-drawer/reference-sidebar/reference-sidebar.component'; import { ResourcesDrawerComponent } from './components/resources-drawer/resources-drawer.component'; import { SearchComponent } from './components/resources-drawer/search/search.component'; @@ -260,6 +261,7 @@ export function initConfig(appConfigService: AppConfigService) { AddDialogComponent, DeleteDialogComponent, HistoryTimelineComponent, + MembershipSectionComponent, ReferenceSidebarComponent, ReferenceEditDialogComponent, MultipleChoiceDialogComponent, diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html new file mode 100644 index 000000000..e8723ea3d --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html @@ -0,0 +1,174 @@ +
+
+

Release Track Distribution

+ +
+ + + +
+
+ +
+ Loading release track membership... +
+ +
+
+
+
+

{{ getTrackName(membership) }}

+

{{ getTrackDescription(membership) }}

+
+ +
+ + + + {{ getTrackType(membership) }} + +
+
+ +
+
+

Current Draft Status

+ +

+ Not in current draft. +

+ +
+ + +
+ + WIP + + + + Review + + + + Staged + +
+
+
+ +
+

Production Releases

+ +

+ No production releases found. +

+ +
+ +
+
+
+
+
+
diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss new file mode 100644 index 000000000..938352795 --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss @@ -0,0 +1,650 @@ +@use 'sass:color'; +@use '../../../../../style/globals'; +@use '../../../../../style/colors' as colors; + +$membership-accent: colors.color(mitre-light-blue); +$membership-accent-dark: #62c9ff; +$membership-success: colors.color(secondary); + +// Explicit purple values for the virtual release-track badge. +$membership-virtual: #8c5bd6; +$membership-virtual-light: #6f3db8; +$membership-virtual-dark: #c3a0f4; + +:host { + display: block; + width: 100%; +} + +.membership-section { + box-sizing: border-box; + width: 100%; + padding-top: 32px; +} + +.membership-header { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + width: 100%; + gap: 24px; + margin-bottom: 22px; + + h2 { + margin: 0; + color: $membership-accent; + font-size: 18px; + font-weight: 700; + letter-spacing: 0.2px; + line-height: 24px; + text-align: left; + } +} + +.membership-header-actions { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 10px; + margin-left: auto; +} + +button.membership-action-button { + display: inline-flex; + box-sizing: border-box; + min-width: 68px; + height: 34px; + align-items: center; + justify-content: center; + padding: 0 14px; + border: 1px solid; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: 12px; + font-weight: 650; + line-height: 32px; + visibility: visible; + opacity: 1; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; + + &:disabled, + &[disabled] { + display: inline-flex; + cursor: default; + visibility: visible; + opacity: 1; + } +} + +.loading-state { + display: flex; + box-sizing: border-box; + min-height: 150px; + align-items: center; + justify-content: center; + padding: 28px; + border: 1px solid; + border-radius: 4px; + text-align: center; +} + +.release-track-list { + display: flex; + flex-direction: column; + gap: 24px; +} + +.release-track-card { + overflow: hidden; + border: 1px solid; + border-radius: 4px; + transition: + border-color 120ms ease, + box-shadow 120ms ease, + background-color 120ms ease; +} + +.release-track-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 16px; + border-bottom: 1px solid; +} + +.release-track-heading { + min-width: 0; + + h3 { + margin: 0 0 4px; + font-size: 17px; + font-weight: 700; + line-height: 23px; + text-align: left; + } + + p { + margin: 0; + font-size: 13px; + line-height: 18px; + } +} + +.release-track-actions { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 10px; +} + +.view-track-button { + display: inline-flex; + box-sizing: border-box; + min-height: 30px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + border: 1px solid; + border-radius: 4px; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 11px; + font-weight: 650; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; + + &:disabled { + cursor: default; + } +} + +.external-link-icon { + color: inherit; + font-size: 13px; + line-height: 1; +} + +.track-type-badge { + display: inline-flex; + box-sizing: border-box; + min-height: 22px; + align-items: center; + justify-content: center; + padding: 0 10px; + border: 1px solid; + border-radius: 999px; + font-size: 10px; + font-weight: 750; + line-height: 20px; + white-space: nowrap; + text-transform: uppercase; +} + +.track-type-badge--virtual { + border-color: rgba($membership-virtual, 0.9); + background: rgba($membership-virtual, 0.18); + color: $membership-virtual; +} + +.release-track-content { + display: grid; + grid-template-columns: minmax(280px, 1fr) minmax(420px, 2fr); + gap: 20px; + padding: 18px 16px; +} + +.draft-column, +.releases-column { + min-width: 0; + + h4 { + margin: 0 0 10px; + color: $membership-accent; + font-size: 13px; + font-weight: 700; + line-height: 18px; + text-align: left; + } +} + +.no-current-draft, +.no-production-releases { + margin: 0; + font-size: 13px; + font-style: italic; + line-height: 18px; +} + +.draft-card, +.production-release { + border: 1px solid; + border-radius: 4px; +} + +.draft-card { + padding: 12px; +} + +.release-selection, +.production-release { + display: flex; + align-items: flex-start; + gap: 11px; +} + +.release-selection { + cursor: pointer; +} + +.production-release-list { + display: flex; + flex-direction: column; + gap: 7px; +} + +.production-release { + box-sizing: border-box; + min-height: 52px; + padding: 10px 12px; + cursor: pointer; + transition: + background-color 120ms ease, + border-color 120ms ease; +} + +.release-information { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; + + strong { + color: $membership-success; + font-size: 14px; + font-weight: 700; + line-height: 18px; + } + + small { + overflow: hidden; + font-size: 11px; + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.draft-status { + text-transform: capitalize; +} + +.release-tag-icon { + position: relative; + width: 13px; + height: 9px; + margin-top: 4px; + border: 1.5px solid $membership-success; + border-radius: 2px 4px 4px 2px; + flex: 0 0 auto; + transform: rotate(45deg); + + &::after { + position: absolute; + top: 2px; + left: 1px; + width: 2px; + height: 2px; + border-radius: 50%; + background: $membership-success; + content: ''; + } +} + +input[type='checkbox'] { + width: 14px; + height: 14px; + margin: 3px 0 0; + accent-color: $membership-success; + cursor: pointer; + + &:disabled { + cursor: default; + } +} + +.status-progress { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-top: 16px; +} + +.status-step { + display: flex; + min-height: 22px; + align-items: center; + justify-content: center; + border: 1px solid; + border-radius: 4px; + font-size: 9px; + font-weight: 750; + line-height: 20px; + text-transform: uppercase; +} + +.status-step--active { + border-color: rgba($membership-success, 0.65) !important; + background: rgba($membership-success, 0.18) !important; + color: $membership-success !important; +} + +/* + * Light mode + * + * Mirrors the dashboard's white cards, subtle inner surfaces, + * standard borders and dark text. + */ +:host-context(.light) { + .membership-section { + color: colors.color(dark); + } + + .membership-header { + h2 { + color: colors.color(primary); + } + } + + button.membership-action-button { + border-color: colors.border-color(light); + background: #ffffff; + color: colors.color(dark); + + &:hover:not(:disabled) { + border-color: rgba(colors.color(primary), 0.45); + background: color.mix(colors.color(mitre-light-blue), #ffffff, 18%); + } + + &:disabled, + &[disabled] { + border-color: colors.border-color(light); + background: #ffffff; + color: colors.on-color-deemphasis(light); + } + } + + button.membership-action-button--primary { + border-color: rgba(colors.color(dark), 0.3); + background: rgba(colors.color(dark), 0.62); + color: colors.color(light); + + &:hover:not(:disabled) { + border-color: rgba(colors.color(dark), 0.75); + background: rgba(colors.color(dark), 0.75); + } + + &:disabled, + &[disabled] { + border-color: rgba(colors.color(dark), 0.18); + background: rgba(colors.color(dark), 0.28); + color: rgba(colors.color(light), 0.9); + } + } + + .loading-state, + .release-track-card { + border-color: colors.border-color(light); + background: #ffffff; + } + + .loading-state { + color: colors.on-color-deemphasis(light); + } + + .release-track-header { + border-bottom-color: colors.border-color(light); + } + + .release-track-heading { + h3 { + color: colors.color(dark); + } + + p { + color: colors.on-color-deemphasis(light); + } + } + + .view-track-button { + border-color: rgba(colors.color(primary), 0.45); + color: colors.color(primary); + + &:hover:not(:disabled) { + border-color: colors.color(primary); + background: color.mix(colors.color(mitre-light-blue), #ffffff, 20%); + } + + &:disabled { + border-color: colors.border-color(light); + background: transparent; + color: colors.on-color-deemphasis(light); + opacity: 0.65; + } + } + + .track-type-badge { + border-color: rgba(colors.color(primary), 0.55); + background: color.mix(colors.color(mitre-light-blue), #ffffff, 24%); + color: colors.color(primary); + } + + .track-type-badge--virtual { + border-color: rgba($membership-virtual-light, 0.8); + background: rgba($membership-virtual, 0.14); + color: $membership-virtual-light; + } + + .no-current-draft, + .no-production-releases, + .release-information small { + color: colors.on-color-deemphasis(light); + } + + .draft-card, + .production-release { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.025); + } + + .production-release { + &:hover { + border-color: rgba(colors.color(primary), 0.3); + background: color.mix(colors.color(mitre-light-blue), #ffffff, 14%); + } + } + + .status-step { + border-color: colors.border-color(light); + background: #ffffff; + color: colors.on-color-deemphasis(light); + } +} + +/* + * Dark mode + */ +:host-context(.dark) { + .membership-section { + color: colors.color(light); + } + + .membership-header { + h2 { + color: colors.color(mitre-light-blue); + } + } + + button.membership-action-button { + border-color: colors.border-color(dark); + background: transparent; + color: colors.color(light); + + &:hover:not(:disabled) { + border-color: rgba(colors.color(mitre-silver), 0.5); + background: rgba(colors.color(mitre-silver), 0.08); + } + + &:disabled, + &[disabled] { + border-color: colors.border-color(dark); + background: transparent; + color: colors.on-color-deemphasis(dark); + } + } + + button.membership-action-button--primary { + border-color: rgba(colors.color(mitre-silver), 0.28); + background: rgba(colors.color(mitre-silver), 0.2); + color: colors.color(mitre-silver); + + &:hover:not(:disabled) { + background: rgba(colors.color(mitre-silver), 0.28); + } + + &:disabled, + &[disabled] { + border-color: rgba(colors.color(mitre-silver), 0.2); + background: rgba(colors.color(mitre-silver), 0.1); + color: rgba(colors.color(mitre-silver), 0.45); + } + } + + .loading-state, + .release-track-card { + border-color: colors.border-color(dark); + background: color.mix(colors.color(dark), #ffffff, 94%); + } + + .loading-state { + color: colors.on-color-deemphasis(dark); + } + + .release-track-header { + border-bottom-color: colors.border-color(dark); + } + + .release-track-heading { + h3 { + color: colors.color(light); + } + + p { + color: colors.on-color-deemphasis(dark); + } + } + + .view-track-button { + border-color: rgba($membership-accent, 0.45); + color: $membership-accent; + + &:hover:not(:disabled) { + border-color: $membership-accent; + background: rgba($membership-accent, 0.08); + } + + &:disabled { + border-color: colors.border-color(dark); + background: transparent; + color: colors.on-color-deemphasis(dark); + opacity: 0.65; + } + } + + .track-type-badge { + border-color: rgba($membership-accent, 0.65); + background: rgba($membership-accent, 0.12); + color: $membership-accent; + } + + .track-type-badge--virtual { + border-color: rgba($membership-virtual-dark, 0.9); + background: rgba($membership-virtual, 0.26); + color: $membership-virtual-dark; + } + + .no-current-draft, + .no-production-releases, + .release-information small { + color: colors.on-color-deemphasis(dark); + } + + .draft-status { + color: $membership-accent-dark; + } + + .draft-card, + .production-release { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.06); + } + + .production-release { + &:hover { + border-color: rgba(colors.color(mitre-silver), 0.36); + background: rgba(colors.color(mitre-silver), 0.1); + } + } + + .status-step { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.04); + color: colors.on-color-deemphasis(dark); + } + + .status-step--active { + border-color: rgba($membership-accent-dark, 0.9) !important; + background: rgba($membership-accent-dark, 0.2) !important; + color: $membership-accent-dark !important; + } +} + +@media (max-width: 900px) { + .release-track-content { + grid-template-columns: 1fr; + } +} + +@media (max-width: 650px) { + .membership-section { + padding-top: 24px; + } + + .membership-header, + .release-track-header { + align-items: stretch; + flex-direction: column; + } + + .membership-header-actions, + .release-track-actions { + flex-wrap: wrap; + } + + .membership-header-actions { + margin-left: 0; + } + + .membership-action-button { + flex: 1; + } +} diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts new file mode 100644 index 000000000..b91164014 --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts @@ -0,0 +1,198 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { Router } from '@angular/router'; + +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; +import { MembershipSectionComponent } from './membership-section.component'; +import { + MembershipSectionDataService, + MembershipTrack, +} from 'src/app/services/connectors/rest-api/membership-section-data.service'; + +describe('MembershipSectionComponent', () => { + let component: MembershipSectionComponent; + let fixture: ComponentFixture; + let membershipDataService: any; + let authenticationService: any; + let router: any; + + const defaultTracks: MembershipTrack[] = [ + { + id: 'core-objects', + name: 'Core Objects', + description: 'Core objects', + type: 'STANDARD', + current_draft: null, + production_releases: [], + releases: [], + versions: [], + }, + { + id: 'enterprise-attack', + name: 'Enterprise ATT&CK', + description: 'Enterprise ATT&CK', + type: 'VIRTUAL', + current_draft: null, + production_releases: [], + releases: [], + versions: [], + }, + ]; + + beforeEach(async () => { + membershipDataService = jasmine.createSpyObj( + 'MembershipSectionDataService', + ['getDefaultTracks', 'loadMemberships'] + ); + + membershipDataService.getDefaultTracks.and.callFake(() => + defaultTracks.map(track => ({ ...track })) + ); + + membershipDataService.loadMemberships.and.returnValue( + of(defaultTracks.map(track => ({ ...track }))) + ); + + authenticationService = jasmine.createSpyObj('AuthenticationService', [ + 'isAuthorized', + ]); + authenticationService.isAuthorized.and.returnValue(true); + + router = jasmine.createSpyObj('Router', ['navigate']); + + await TestBed.configureTestingModule({ + declarations: [MembershipSectionComponent], + providers: [ + { + provide: MembershipSectionDataService, + useValue: membershipDataService, + }, + { + provide: AuthenticationService, + useValue: authenticationService, + }, + { provide: Router, useValue: router }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(MembershipSectionComponent); + + component = fixture.componentInstance; + + component.config = { + mode: 'view', + object: { + stixID: 'attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8', + name: 'Service Stop', + type: 'attack-pattern', + }, + }; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should expose the object reference', () => { + expect(component.objectRef).toBe( + 'attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8' + ); + }); + + it('should load memberships through the data service', () => { + expect(membershipDataService.loadMemberships).toHaveBeenCalledWith( + 'attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8', + component.membershipObject, + component.config + ); + }); + + it('should display the two type-based tracks', () => { + expect(component.memberships.length).toBe(2); + expect(component.memberships[0].name).toBe('Core Objects'); + expect(component.memberships[0].type).toBe('STANDARD'); + expect(component.memberships[1].name).toBe('Enterprise ATT&CK'); + expect(component.memberships[1].type).toBe('VIRTUAL'); + }); + + it('should mark Enterprise ATT&CK as virtual', () => { + const enterpriseTrack = component.memberships[1]; + + expect(component.getTrackType(enterpriseTrack)).toBe('VIRTUAL'); + + expect(component.isVirtualTrack(enterpriseTrack)).toBe(true); + }); + + it('should label a work-in-progress draft with a placeholder date', () => { + const membership = { + ...component.memberships[0], + current_draft: { + status: 'work-in-progress', + in_current_draft: true, + }, + }; + + expect(component.getDraftStatusLabel(membership)).toBe('Work in Progress'); + expect(component.getDraftDateLabel(membership)).toBe('As of TBD'); + }); + + it('should render Clear and Compare buttons', () => { + const element: HTMLElement = fixture.nativeElement; + + expect(element.textContent).toContain('Clear'); + expect(element.textContent).toContain('Compare (0/2)'); + }); + + it('should navigate authorized users to the release track dashboard', () => { + const track = component.memberships[0]; + + component.viewTrack(track); + + expect(router.navigate).toHaveBeenCalledWith([ + '/dashboard/release-management', + track.id, + ]); + }); + + it('should allow no more than two selected releases', () => { + const membership = component.memberships[0]; + + component.toggleReleaseSelection( + { id: 'release--1', version: '1.0' }, + membership, + true + ); + + component.toggleReleaseSelection( + { id: 'release--2', version: '2.0' }, + membership, + true + ); + + component.toggleReleaseSelection( + { id: 'release--3', version: '3.0' }, + membership, + true + ); + + expect(component.selectedReleaseCount).toBe(2); + }); + + it('should clear selected releases', () => { + const membership = component.memberships[0]; + + component.toggleReleaseSelection( + { id: 'release--1', version: '1.0' }, + membership, + true + ); + + component.clearSelection(); + + expect(component.selectedReleaseCount).toBe(0); + }); +}); diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts new file mode 100644 index 000000000..8cd1d9022 --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts @@ -0,0 +1,568 @@ +import { + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { finalize, takeUntil } from 'rxjs/operators'; +import { Router } from '@angular/router'; + +import { Role } from 'src/app/classes/authn/role'; +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; +import { + MembershipSectionDataService, + MembershipTrack, +} from 'src/app/services/connectors/rest-api/membership-section-data.service'; + +interface SelectedRelease { + key: string; + release: any; + membership: MembershipTrack; +} + +@Component({ + selector: 'app-membership-section', + standalone: false, + templateUrl: './membership-section.component.html', + styleUrls: ['./membership-section.component.scss'], +}) +export class MembershipSectionComponent + implements OnInit, OnChanges, OnDestroy +{ + @Input() config: any; + @Input() object: any; + + @Output() releasesCompared = new EventEmitter(); + + memberships: MembershipTrack[] = []; + + loading = false; + loadError: string | null = null; + private loadedObjectRef: string | null = null; + + private selectedReleases = new Map(); + + private readonly destroy$ = new Subject(); + + constructor( + private readonly membershipData: MembershipSectionDataService, + private readonly authenticationService: AuthenticationService, + private readonly router: Router, + private readonly elementRef: ElementRef + ) { + this.memberships = this.membershipData.getDefaultTracks(); + } + + ngOnInit(): void { + this.loadMembershipData(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!changes.config && !changes.object) { + return; + } + + const currentObjectRef = this.objectRef; + + if (currentObjectRef && currentObjectRef !== this.loadedObjectRef) { + this.loadMembershipData(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + get membershipObject(): any { + return this.object ?? this.config?.object ?? null; + } + + get objectRef(): string | null { + return ( + this.membershipObject?.stixID ?? + this.membershipObject?.id ?? + this.membershipObject?.stix?.id ?? + null + ); + } + + get selectedReleaseCount(): number { + return this.selectedReleases.size; + } + + get hasSelection(): boolean { + return this.selectedReleaseCount > 0; + } + + get canViewReleaseTrackDashboard(): boolean { + return this.authenticationService.isAuthorized([ + Role.TEAM_LEAD, + Role.ADMIN, + ]); + } + + loadMembershipData(): void { + const objectRef = this.objectRef; + + if (!objectRef) { + this.memberships = this.membershipData.getDefaultTracks(); + + this.loadError = + 'Unable to load release-track membership because the object ID is missing.'; + + return; + } + + if (this.loading && this.loadedObjectRef === objectRef) { + return; + } + + this.loadedObjectRef = objectRef; + this.loading = true; + this.loadError = null; + this.selectedReleases = new Map(); + + this.membershipData + .loadMemberships(objectRef, this.membershipObject, this.config) + .pipe( + takeUntil(this.destroy$), + finalize(() => { + this.loading = false; + }) + ) + .subscribe({ + next: tracks => { + this.memberships = tracks; + }, + error: error => { + console.error('Unable to load release-track membership', error); + + this.memberships = this.membershipData.getDefaultTracks(); + + this.loadError = 'Release-track membership could not be loaded.'; + }, + }); + } + + getTrackName(membership: MembershipTrack): string { + return ( + membership?.name ?? + membership?.title ?? + membership?.track?.name ?? + membership?.release_track?.name ?? + this.formatIdentifier(membership?.id) ?? + 'Release Track' + ); + } + + getTrackDescription(membership: MembershipTrack): string { + return ( + membership?.description ?? + membership?.track?.description ?? + membership?.release_track?.description ?? + 'Release track containing this ATT&CK object.' + ); + } + + getTrackType(membership: MembershipTrack): string { + const type = + membership?.type ?? + membership?.track_type ?? + membership?.track?.type ?? + membership?.release_track?.type ?? + 'STANDARD'; + + return String(type).replace(/[_-]+/g, ' ').toUpperCase(); + } + + isVirtualTrack(membership: MembershipTrack): boolean { + return String( + membership?.type ?? + membership?.track_type ?? + membership?.track?.type ?? + membership?.release_track?.type ?? + '' + ) + .trim() + .toLowerCase() + .includes('virtual'); + } + + viewTrack(membership: MembershipTrack, event?: Event): void { + event?.stopPropagation(); + + if (!this.canViewReleaseTrackDashboard) { + return; + } + + const trackId = this.getTrackId(membership); + + this.router.navigate(['/dashboard/release-management', trackId]); + } + + getCurrentDraft(membership: MembershipTrack): any { + return ( + membership?.current_draft ?? + membership?.currentDraft ?? + membership?.draft ?? + null + ); + } + + hasCurrentDraft(membership: MembershipTrack): boolean { + const draft = this.getCurrentDraft(membership); + + if (!draft) { + return false; + } + + return draft?.in_current_draft !== false && draft?.inCurrentDraft !== false; + } + + getDraftStatusLabel(membership: MembershipTrack): string { + const draft = this.getCurrentDraft(membership); + + const status = + draft?.status ?? + draft?.state ?? + draft?.tier ?? + membership?.status ?? + membership?.tier ?? + 'work-in-progress'; + + if ( + ['work-in-progress', 'wip', 'candidate', 'candidates', 'draft'].includes( + this.normalizeStatus(status) + ) + ) { + return 'Work in Progress'; + } + + return this.formatStatus(status); + } + + getDraftDateLabel(membership: MembershipTrack): string { + const draft = this.getCurrentDraft(membership); + + const date = + draft?.updated_at ?? + draft?.updatedAt ?? + draft?.modified ?? + draft?.date ?? + draft?.created; + + return date ? `As of ${this.formatDate(date, true)}` : 'As of TBD'; + } + + isDraftStepActive(membership: MembershipTrack, step: string): boolean { + const draft = this.getCurrentDraft(membership); + + const status = this.normalizeStatus( + draft?.status ?? + draft?.state ?? + draft?.tier ?? + membership?.status ?? + membership?.tier + ); + + const normalizedStep = this.normalizeStatus(step); + + if (normalizedStep === 'work-in-progress') { + return [ + 'work-in-progress', + 'wip', + 'candidate', + 'candidates', + 'draft', + ].includes(status); + } + + if (normalizedStep === 'review') { + return ['awaiting-review', 'review', 'reviewed'].includes(status); + } + + if (normalizedStep === 'staged') { + return status === 'staged'; + } + + return status === normalizedStep; + } + + getProductionReleases(membership: MembershipTrack): any[] { + const releases = + membership?.production_releases ?? + membership?.productionReleases ?? + membership?.releases ?? + []; + + if (!Array.isArray(releases)) { + return []; + } + + return [...releases].sort( + (first, second) => + this.getReleaseTimestamp(second) - this.getReleaseTimestamp(first) + ); + } + + getReleaseVersion(release: any): string { + const version = + this.normalizeVersionValue(release?.version) ?? + release?.name ?? + release?.title ?? + release?.release_version ?? + release?.releaseVersion ?? + release?.tag; + + if (!version) { + return 'Release'; + } + + const value = String(version); + + return value.toLowerCase().startsWith('v') ? value : `v${value}`; + } + + getReleaseDateLabel(release: any): string { + const date = + release?.released_at ?? + release?.releasedAt ?? + release?.release_date ?? + release?.releaseDate ?? + release?.modified ?? + release?.created_at ?? + release?.createdAt ?? + release?.created ?? + release?.date; + + return date ? `Released on ${this.formatDate(date)}` : 'Production release'; + } + + toggleReleaseSelection( + release: any, + membership: MembershipTrack, + checked: boolean + ): void { + if (!release) { + return; + } + + const key = this.getReleaseSelectionKey(release, membership); + + if (!checked) { + const nextSelection = new Map(this.selectedReleases); + nextSelection.delete(key); + this.selectedReleases = nextSelection; + return; + } + + if (this.selectedReleases.size >= 2) { + return; + } + + this.selectedReleases = new Map(this.selectedReleases).set(key, { + key, + release, + membership, + }); + } + + isReleaseSelected(release: any, membership: MembershipTrack): boolean { + if (!release) { + return false; + } + + return this.selectedReleases.has( + this.getReleaseSelectionKey(release, membership) + ); + } + + clearSelection(): void { + this.selectedReleases = new Map(); + + this.elementRef.nativeElement + .querySelectorAll('input[type="checkbox"]') + .forEach(checkbox => { + checkbox.checked = false; + }); + } + + compareSelectedReleases(): void { + if (this.selectedReleaseCount !== 2) { + return; + } + + this.releasesCompared.emit([...this.selectedReleases.values()]); + } + + trackByMembership( + index: number, + membership: MembershipTrack + ): string | number { + return ( + membership?.id ?? + membership?.apiId ?? + membership?.track_id ?? + membership?.name ?? + index + ); + } + + trackByRelease(index: number, release: any): string | number { + const version = + release?.version?._version ?? + release?.version ?? + release?.release_version; + + return ( + release?.id ?? + release?.snapshot_id ?? + release?.modified ?? + release?.tag ?? + release?.name ?? + (Array.isArray(version) ? version.join('.') : version) ?? + index + ); + } + + private getTrackId(membership: MembershipTrack): string { + return String( + membership?.id ?? + membership?.apiId ?? + membership?.track_id ?? + membership?.track?.id ?? + membership?.release_track?.id ?? + membership?.name ?? + '' + ); + } + + private getReleaseSelectionKey( + release: any, + membership: MembershipTrack + ): string { + const membershipId = + membership?.apiId ?? + membership?.track_id ?? + membership?.id ?? + membership?.name ?? + 'release-track'; + + const releaseId = + release?.id ?? + release?.snapshot_id ?? + release?.modified ?? + this.normalizeVersionValue(release?.version) ?? + release?.tag ?? + release?.name ?? + release?.status ?? + JSON.stringify(release); + + return `${membershipId}:${releaseId}`; + } + + private getReleaseTimestamp(release: any): number { + const date = + release?.released_at ?? + release?.releasedAt ?? + release?.release_date ?? + release?.releaseDate ?? + release?.modified ?? + release?.created_at ?? + release?.createdAt ?? + release?.created ?? + release?.date; + + if (!date) { + return 0; + } + + const timestamp = new Date(date).getTime(); + + return Number.isNaN(timestamp) ? 0 : timestamp; + } + + private normalizeVersionValue(version: any): string | null { + if (version === null || version === undefined) { + return null; + } + + if (Array.isArray(version)) { + return version.join('.'); + } + + if (typeof version === 'object') { + const nestedVersion = + version._version ?? version.version ?? version.value; + + if (Array.isArray(nestedVersion)) { + return nestedVersion.join('.'); + } + + return nestedVersion === null || nestedVersion === undefined + ? null + : String(nestedVersion); + } + + return String(version); + } + + private normalizeStatus(status: unknown): string { + return String(status ?? '') + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-'); + } + + private formatStatus(status: unknown): string { + if (!status) { + return 'Work In Progress'; + } + + return String(status) + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, character => character.toUpperCase()); + } + + private formatDate(value: string | Date, includeTime = false): string { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return String(value); + } + + const options: Intl.DateTimeFormatOptions = includeTime + ? { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + } + : { + year: 'numeric', + month: 'short', + day: 'numeric', + }; + + return new Intl.DateTimeFormat('en-US', options).format(date); + } + + private formatIdentifier(identifier: unknown): string | null { + if (!identifier) { + return null; + } + + const value = String(identifier); + const identifierName = value.includes('--') ? value.split('--')[0] : value; + + return this.formatStatus(identifierName); + } +} diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html index f78ec49b3..0741c172a 100644 --- a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html @@ -21,6 +21,16 @@ + + + MEMBERSHIP + + + + + + + HISTORY diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts index 184c5526c..fc4f67dfb 100644 --- a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts @@ -20,4 +20,5 @@ export class StixPageTabsComponent { @Input() customTabs: CustomTab[] = []; @Input() showHistory = true; @Input() showNotes = true; + @Input() showMembership = true; } diff --git a/src/app/services/connectors/rest-api/membership-section-data.service.ts b/src/app/services/connectors/rest-api/membership-section-data.service.ts new file mode 100644 index 000000000..28ef90a5c --- /dev/null +++ b/src/app/services/connectors/rest-api/membership-section-data.service.ts @@ -0,0 +1,615 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { forkJoin, Observable, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; + +import { environment } from 'src/environments/environment'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; + +export interface MembershipTrack { + id: string; + name: string; + description: string; + type: string; + + apiId?: string; + track_id?: string; + tier?: string; + status?: string; + + current_draft?: any; + currentDraft?: any; + draft?: any; + + production_releases?: any[]; + productionReleases?: any[]; + releases?: any[]; + versions?: any[]; + + track?: any; + release_track?: any; + + [key: string]: any; +} + +interface MembershipReference { + id: string; + tier?: string; + status?: string; + [key: string]: any; +} + +@Injectable({ + providedIn: 'root', +}) +export class MembershipSectionDataService { + private get apiUrl(): string { + return environment.integrations.rest_api.url.replace(/\/+$/, ''); + } + + private readonly defaultTracks: MembershipTrack[] = [ + { + id: 'core-objects', + name: 'Core Objects', + description: + 'Standard release track for core ATT&CK objects like techniques and mitigations.', + type: 'STANDARD', + current_draft: null, + production_releases: [], + releases: [], + versions: [], + }, + { + id: 'enterprise-attack', + name: 'Enterprise ATT&CK', + description: + 'Virtual track consolidating all Enterprise-related objects.', + type: 'VIRTUAL', + current_draft: null, + production_releases: [], + releases: [], + versions: [], + }, + ]; + + constructor( + private readonly http: HttpClient, + private readonly releaseTracksConnector: ReleaseTracksConnectorService + ) {} + + public getDefaultTracks(): MembershipTrack[] { + return this.defaultTracks.map(track => this.copyTrack(track)); + } + + public loadMemberships( + objectRef: string, + objectInput: any, + configInput: any + ): Observable { + const suppliedMemberships = this.extractMemberships( + objectInput, + configInput + ); + + const rawObject$ = suppliedMemberships.length + ? of(objectInput ?? configInput?.object ?? null) + : this.loadRawObject(objectRef, objectInput ?? configInput?.object); + + return forkJoin({ + rawObject: rawObject$, + releaseTracksResponse: this.releaseTracksConnector + .listReleaseTracks({ + limit: 100, + offset: 0, + }) + .pipe( + catchError(error => { + console.error('Failed to load release tracks', error); + return of(null); + }) + ), + }).pipe( + map(({ rawObject, releaseTracksResponse }) => { + const memberships = suppliedMemberships.length + ? suppliedMemberships + : this.extractMemberships(rawObject, { + object: rawObject, + }); + + const availableTracks = this.normalizeReleaseTrackList( + releaseTracksResponse + ); + + return this.buildTracks(availableTracks, memberships, objectRef); + }), + switchMap(tracks => + this.loadVersionsForMembershipTracks(tracks, objectRef) + ), + catchError(error => { + console.error('Failed to load membership data', error); + return of(this.getDefaultTracks()); + }) + ); + } + + private loadRawObject(objectRef: string, objectInput: any): Observable { + const objectType = + objectInput?.type ?? + objectInput?.stix?.type ?? + objectInput?.attackType ?? + this.getTypeFromStixId(objectRef); + + const resource = this.getResourceName(objectType); + + if (!resource) { + console.warn(`Unable to determine REST resource for object ${objectRef}`); + return of(objectInput ?? null); + } + + const url = `${this.apiUrl}/${resource}/${encodeURIComponent(objectRef)}`; + + return this.http.get(url).pipe( + map(response => { + if (Array.isArray(response)) { + return response[0] ?? objectInput ?? null; + } + + return response ?? objectInput ?? null; + }), + catchError(error => { + console.error(`Failed to load raw object ${objectRef}`, error); + return of(objectInput ?? null); + }) + ); + } + + private extractMemberships( + objectInput: any, + configInput: any + ): MembershipReference[] { + const releaseTracks = + objectInput?.workspace?.release_tracks ?? + objectInput?.workspace?.releaseTracks ?? + objectInput?.release_tracks ?? + objectInput?.releaseTracks ?? + configInput?.object?.workspace?.release_tracks ?? + configInput?.object?.workspace?.releaseTracks ?? + configInput?.workspace?.release_tracks ?? + configInput?.workspace?.releaseTracks ?? + []; + + return Array.isArray(releaseTracks) + ? releaseTracks.filter(membership => !!membership?.id) + : []; + } + + private normalizeReleaseTrackList(response: any): MembershipTrack[] { + if (!response) { + return []; + } + + if (Array.isArray(response)) { + return response; + } + + const tracks = + response.data ?? + response.items ?? + response.results ?? + response.release_tracks ?? + response.releaseTracks ?? + response.tracks ?? + []; + + return Array.isArray(tracks) ? tracks : []; + } + + private buildTracks( + availableTracks: MembershipTrack[], + memberships: MembershipReference[], + objectRef: string + ): MembershipTrack[] { + const memberTracks = memberships.map(membership => { + const metadata = availableTracks.find( + track => this.getTrackApiId(track) === membership.id + ); + + const track: MembershipTrack = { + ...(metadata ?? {}), + ...membership, + + id: metadata?.track_id ?? metadata?.id ?? membership.id, + + apiId: membership.id, + + name: + metadata?.name ?? + metadata?.title ?? + this.formatIdentifier(membership.id) ?? + 'Release Track', + + description: + metadata?.description?.trim() || + 'Release track containing this ATT&CK object.', + + type: metadata?.type ?? metadata?.track_type ?? 'STANDARD', + + tier: membership.tier, + status: membership.status, + + current_draft: this.createCurrentDraft(membership, metadata, objectRef), + + production_releases: [], + releases: [], + versions: [], + }; + + return track; + }); + + return this.defaultTracks.map(defaultTrack => { + const matchingTrack = memberTracks.find( + memberTrack => + this.normalizeTrackType(memberTrack.type) === + this.normalizeTrackType(defaultTrack.type) + ); + + return matchingTrack + ? this.mergeTrack(defaultTrack, matchingTrack) + : this.copyTrack(defaultTrack); + }); + } + + private loadVersionsForMembershipTracks( + tracks: MembershipTrack[], + objectRef: string + ): Observable { + const requests = tracks.map(track => { + if (!track.apiId) { + return of(track); + } + + return this.releaseTracksConnector + .listObjectVersions(track.apiId, objectRef) + .pipe( + map(response => this.applyVersionResponse(track, response)), + catchError(error => { + console.error( + `Failed to load versions for track ${track.apiId}`, + error + ); + return of(track); + }) + ); + }); + + return requests.length ? forkJoin(requests) : of(tracks); + } + + private applyVersionResponse( + track: MembershipTrack, + response: any + ): MembershipTrack { + const versions = this.normalizeVersionList(response); + + const responseDraft = + response?.current_draft ?? + response?.currentDraft ?? + response?.draft ?? + null; + + const directReleases = + response?.production_releases ?? + response?.productionReleases ?? + response?.releases ?? + null; + + const currentDraft = + responseDraft ?? + versions.find(version => this.isDraftVersion(version)) ?? + track.current_draft ?? + null; + + const productionReleases = Array.isArray(directReleases) + ? directReleases + : versions.filter(version => this.isProductionVersion(version)); + + return { + ...track, + current_draft: currentDraft, + production_releases: productionReleases, + releases: productionReleases, + versions, + }; + } + + private normalizeVersionList(response: any): any[] { + if (!response) { + return []; + } + + if (Array.isArray(response)) { + return response; + } + + const versions = + response.data ?? + response.items ?? + response.results ?? + response.versions ?? + response.object_versions ?? + response.objectVersions ?? + response.snapshots ?? + []; + + return Array.isArray(versions) ? versions : []; + } + + private createCurrentDraft( + membership: MembershipReference, + metadata: MembershipTrack | undefined, + objectRef: string + ): any { + const existingDraft = + metadata?.current_draft ?? metadata?.currentDraft ?? metadata?.draft; + + if (existingDraft) { + return { + ...existingDraft, + status: membership.status ?? existingDraft.status, + tier: membership.tier ?? existingDraft.tier, + in_current_draft: true, + }; + } + + const tier = this.normalizeStatus(membership.tier); + const status = this.normalizeStatus(membership.status); + + const draftStatuses = [ + 'candidate', + 'candidates', + 'draft', + 'work-in-progress', + 'wip', + 'awaiting-review', + 'review', + 'reviewed', + 'staged', + ]; + + if (!draftStatuses.includes(tier) && !draftStatuses.includes(status)) { + return null; + } + + return { + id: `${membership.id}:${objectRef}`, + status: membership.status ?? membership.tier ?? 'work-in-progress', + tier: membership.tier ?? membership.status, + in_current_draft: true, + }; + } + + private isDraftVersion(version: any): boolean { + if (version?.is_draft === true || version?.isDraft === true) { + return true; + } + + const status = this.normalizeStatus( + version?.status ?? + version?.state ?? + version?.tier ?? + version?.snapshot_status + ); + + return [ + 'candidate', + 'candidates', + 'draft', + 'work-in-progress', + 'wip', + 'awaiting-review', + 'review', + 'reviewed', + 'staged', + ].includes(status); + } + + private isProductionVersion(version: any): boolean { + if ( + version?.is_release === true || + version?.isRelease === true || + version?.released === true || + version?.tagged === true + ) { + return true; + } + + if ( + version?.released_at || + version?.releasedAt || + version?.release_date || + version?.releaseDate || + version?.tag + ) { + return true; + } + + const status = this.normalizeStatus( + version?.status ?? + version?.state ?? + version?.tier ?? + version?.snapshot_status + ); + + if ( + ['production', 'release', 'released', 'published', 'tagged'].includes( + status + ) + ) { + return true; + } + + return ( + !this.isDraftVersion(version) && + !!this.normalizeVersionValue(version?.version) + ); + } + + private mergeTrack( + displayTrack: MembershipTrack, + sourceTrack: MembershipTrack + ): MembershipTrack { + return { + ...sourceTrack, + id: sourceTrack.id, + apiId: sourceTrack.apiId, + name: displayTrack.name, + description: displayTrack.description, + type: displayTrack.type, + current_draft: + sourceTrack.current_draft ?? + sourceTrack.currentDraft ?? + sourceTrack.draft ?? + null, + production_releases: + sourceTrack.production_releases ?? + sourceTrack.productionReleases ?? + sourceTrack.releases ?? + [], + releases: + sourceTrack.production_releases ?? + sourceTrack.productionReleases ?? + sourceTrack.releases ?? + [], + versions: sourceTrack.versions ?? [], + }; + } + + private getTrackApiId(track: MembershipTrack): string | null { + const id = + track.track_id ?? + track.apiId ?? + track.track?.id ?? + track.release_track?.id ?? + track.id; + + return id ? String(id) : null; + } + + private getResourceName(type: unknown): string | null { + const normalized = String(type ?? '') + .trim() + .toLowerCase(); + + const resourceMap: Record = { + 'attack-pattern': 'techniques', + 'technique': 'techniques', + + 'x-mitre-tactic': 'tactics', + 'tactic': 'tactics', + + 'intrusion-set': 'groups', + 'group': 'groups', + + 'campaign': 'campaigns', + + 'x-mitre-asset': 'assets', + 'asset': 'assets', + + 'malware': 'software', + 'tool': 'software', + 'software': 'software', + + 'course-of-action': 'mitigations', + 'mitigation': 'mitigations', + + 'x-mitre-data-source': 'data-sources', + 'data-source': 'data-sources', + + 'x-mitre-data-component': 'data-components', + 'data-component': 'data-components', + + 'x-mitre-detection-strategy': 'detection-strategies', + 'detection-strategy': 'detection-strategies', + + 'x-mitre-analytic': 'analytics', + 'analytic': 'analytics', + + 'x-mitre-matrix': 'matrices', + 'matrix': 'matrices', + + 'relationship': 'relationships', + 'note': 'notes', + + 'x-mitre-collection': 'collections', + 'collection': 'collections', + + 'identity': 'identities', + 'marking-definition': 'marking-definitions', + }; + + return resourceMap[normalized] ?? null; + } + + private getTypeFromStixId(objectRef: string): string { + return objectRef.split('--')[0] ?? ''; + } + + private normalizeStatus(value: unknown): string { + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-'); + } + + private normalizeTrackType(value: unknown): string { + return String(value ?? 'standard') + .trim() + .toLowerCase(); + } + + private normalizeVersionValue(version: any): string | null { + if (version === null || version === undefined) { + return null; + } + + if (Array.isArray(version)) { + return version.join('.'); + } + + if (typeof version === 'object') { + const nested = version._version ?? version.version ?? version.value; + + if (Array.isArray(nested)) { + return nested.join('.'); + } + + return nested === null || nested === undefined ? null : String(nested); + } + + return String(version); + } + + private formatIdentifier(identifier: unknown): string | null { + if (!identifier) { + return null; + } + + const value = String(identifier); + const identifierName = value.includes('--') ? value.split('--')[0] : value; + + return identifierName + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, character => character.toUpperCase()); + } + + private copyTrack(track: MembershipTrack): MembershipTrack { + return { + ...track, + current_draft: null, + production_releases: [], + releases: [], + versions: [], + }; + } +} diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html index 4ff125996..f9741e743 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -31,6 +31,7 @@

[detailsTemplate]="detailsTpl" [showHistory]="false" [showNotes]="false" + [showMembership]="false" [customTabs]="[ { label: 'History', template: historyTpl }, { label: 'Config', template: configTpl }, From cdb73e3fc72d985b58ef3b39c9089e6bcdd00b2a Mon Sep 17 00:00:00 2001 From: avwchapman Date: Mon, 20 Jul 2026 11:12:58 -0400 Subject: [PATCH 2/3] fix: refine release track membership statuses and releases --- .../membership-section.component.html | 8 +- .../membership-section.component.scss | 45 ++++++++ .../membership-section.component.spec.ts | 72 +++++++++++-- .../membership-section.component.ts | 52 ++++++++- .../membership-section-data.service.spec.ts | 101 ++++++++++++++++++ .../membership-section-data.service.ts | 54 ++++++++-- .../rest-api/release-tracks.service.ts | 15 +++ 7 files changed, 324 insertions(+), 23 deletions(-) create mode 100644 src/app/services/connectors/rest-api/membership-section-data.service.spec.ts diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html index e8723ea3d..3a3b52a42 100644 --- a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html @@ -40,6 +40,7 @@

{{ getTrackName(membership) }}

*ngIf="canViewReleaseTrackDashboard" type="button" class="view-track-button" + [disabled]="!hasSelectedReleaseForTrack(membership)" (click)="viewTrack(membership, $event)"> View Track @@ -86,7 +87,10 @@

Current Draft Status

" /> - + {{ getDraftStatusLabel(membership) }} @@ -107,6 +111,7 @@

Current Draft Status

@@ -115,6 +120,7 @@

Current Draft Status

diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss index 938352795..7a556bff2 100644 --- a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss @@ -5,6 +5,7 @@ $membership-accent: colors.color(mitre-light-blue); $membership-accent-dark: #62c9ff; $membership-success: colors.color(secondary); +$membership-release: colors.color(active); // Explicit purple values for the virtual release-track badge. $membership-virtual: #8c5bd6; @@ -260,6 +261,18 @@ button.membership-action-button { transition: background-color 120ms ease, border-color 120ms ease; + + .release-information strong { + color: $membership-release; + } + + .release-tag-icon { + border-color: $membership-release; + + &::after { + background: $membership-release; + } + } } .release-information { @@ -288,6 +301,14 @@ button.membership-action-button { text-transform: capitalize; } +.draft-status--in-review { + color: colors.color(pending) !important; +} + +.draft-status--staged { + color: colors.color(active) !important; +} + .release-tag-icon { position: relative; width: 13px; @@ -348,6 +369,18 @@ input[type='checkbox'] { color: $membership-success !important; } +.status-step--review-active { + border-color: rgba(colors.color(pending), 0.8) !important; + background: rgba(colors.color(pending), 0.16) !important; + color: colors.color(pending) !important; +} + +.status-step--staged-active { + border-color: rgba(colors.color(active), 0.8) !important; + background: rgba(colors.color(active), 0.16) !important; + color: colors.color(active) !important; +} + /* * Light mode * @@ -616,6 +649,18 @@ input[type='checkbox'] { background: rgba($membership-accent-dark, 0.2) !important; color: $membership-accent-dark !important; } + + .status-step--review-active { + border-color: rgba(colors.color(pending), 0.9) !important; + background: rgba(colors.color(pending), 0.2) !important; + color: colors.color(pending) !important; + } + + .status-step--staged-active { + border-color: rgba(colors.color(active), 0.9) !important; + background: rgba(colors.color(active), 0.2) !important; + color: colors.color(active) !important; + } } @media (max-width: 900px) { diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts index b91164014..6ed8a0fa9 100644 --- a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { Router } from '@angular/router'; +import { vi } from 'vitest'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; import { MembershipSectionComponent } from './membership-section.component'; @@ -40,25 +41,24 @@ describe('MembershipSectionComponent', () => { ]; beforeEach(async () => { - membershipDataService = jasmine.createSpyObj( - 'MembershipSectionDataService', - ['getDefaultTracks', 'loadMemberships'] - ); + membershipDataService = { + getDefaultTracks: vi.fn(), + loadMemberships: vi.fn(), + }; - membershipDataService.getDefaultTracks.and.callFake(() => + membershipDataService.getDefaultTracks.mockImplementation(() => defaultTracks.map(track => ({ ...track })) ); - membershipDataService.loadMemberships.and.returnValue( + membershipDataService.loadMemberships.mockReturnValue( of(defaultTracks.map(track => ({ ...track }))) ); - authenticationService = jasmine.createSpyObj('AuthenticationService', [ - 'isAuthorized', - ]); - authenticationService.isAuthorized.and.returnValue(true); + authenticationService = { + isAuthorized: vi.fn().mockReturnValue(true), + }; - router = jasmine.createSpyObj('Router', ['navigate']); + router = { navigate: vi.fn() }; await TestBed.configureTestingModule({ declarations: [MembershipSectionComponent], @@ -140,6 +140,36 @@ describe('MembershipSectionComponent', () => { expect(component.getDraftDateLabel(membership)).toBe('As of TBD'); }); + it('should identify a draft that is awaiting review', () => { + const membership = { + ...component.memberships[0], + current_draft: { + status: 'awaiting-review', + in_current_draft: true, + }, + }; + + expect(component.getDraftStatusLabel(membership)).toBe('In Review'); + expect(component.isDraftInReview(membership)).toBe(true); + }); + + it('should identify a staged draft', () => { + const membership = { + ...component.memberships[0], + tier: 'staged', + status: 'reviewed', + current_draft: { + tier: 'staged', + status: 'reviewed', + in_current_draft: true, + }, + }; + + expect(component.getDraftStatusLabel(membership)).toBe('Staged'); + expect(component.isDraftStaged(membership)).toBe(true); + expect(component.isDraftInReview(membership)).toBe(false); + }); + it('should render Clear and Compare buttons', () => { const element: HTMLElement = fixture.nativeElement; @@ -150,6 +180,12 @@ describe('MembershipSectionComponent', () => { it('should navigate authorized users to the release track dashboard', () => { const track = component.memberships[0]; + component.toggleReleaseSelection( + { id: 'release--1', version: '1.0' }, + track, + true + ); + component.viewTrack(track); expect(router.navigate).toHaveBeenCalledWith([ @@ -158,6 +194,20 @@ describe('MembershipSectionComponent', () => { ]); }); + it('should enable View Track only for a track with a selected release', () => { + const selectedTrack = component.memberships[0]; + const otherTrack = component.memberships[1]; + + component.toggleReleaseSelection( + { id: 'release--1', version: '1.0' }, + selectedTrack, + true + ); + + expect(component.hasSelectedReleaseForTrack(selectedTrack)).toBe(true); + expect(component.hasSelectedReleaseForTrack(otherTrack)).toBe(false); + }); + it('should allow no more than two selected releases', () => { const membership = component.memberships[0]; diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts index 8cd1d9022..061f17575 100644 --- a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts @@ -198,7 +198,10 @@ export class MembershipSectionComponent viewTrack(membership: MembershipTrack, event?: Event): void { event?.stopPropagation(); - if (!this.canViewReleaseTrackDashboard) { + if ( + !this.canViewReleaseTrackDashboard || + !this.hasSelectedReleaseForTrack(membership) + ) { return; } @@ -237,6 +240,10 @@ export class MembershipSectionComponent membership?.tier ?? 'work-in-progress'; + if (this.isDraftStaged(membership)) { + return 'Staged'; + } + if ( ['work-in-progress', 'wip', 'candidate', 'candidates', 'draft'].includes( this.normalizeStatus(status) @@ -245,6 +252,10 @@ export class MembershipSectionComponent return 'Work in Progress'; } + if (this.isDraftInReview(membership)) { + return 'In Review'; + } + return this.formatStatus(status); } @@ -261,6 +272,31 @@ export class MembershipSectionComponent return date ? `As of ${this.formatDate(date, true)}` : 'As of TBD'; } + isDraftInReview(membership: MembershipTrack): boolean { + const draft = this.getCurrentDraft(membership); + + const status = this.normalizeStatus( + draft?.status ?? draft?.state ?? membership?.status + ); + + return ( + !this.isDraftStaged(membership) && + ['awaiting-review', 'review', 'reviewed'].includes(status) + ); + } + + isDraftStaged(membership: MembershipTrack): boolean { + const draft = this.getCurrentDraft(membership); + + return [ + draft?.tier, + draft?.state, + draft?.status, + membership?.tier, + membership?.status, + ].some(value => this.normalizeStatus(value) === 'staged'); + } + isDraftStepActive(membership: MembershipTrack, step: string): boolean { const draft = this.getCurrentDraft(membership); @@ -285,11 +321,11 @@ export class MembershipSectionComponent } if (normalizedStep === 'review') { - return ['awaiting-review', 'review', 'reviewed'].includes(status); + return this.isDraftInReview(membership); } if (normalizedStep === 'staged') { - return status === 'staged'; + return this.isDraftStaged(membership); } return status === normalizedStep; @@ -336,6 +372,8 @@ export class MembershipSectionComponent release?.releasedAt ?? release?.release_date ?? release?.releaseDate ?? + release?.tagged_at ?? + release?.taggedAt ?? release?.modified ?? release?.created_at ?? release?.createdAt ?? @@ -384,6 +422,14 @@ export class MembershipSectionComponent ); } + hasSelectedReleaseForTrack(membership: MembershipTrack): boolean { + const trackId = this.getTrackId(membership); + + return [...this.selectedReleases.values()].some( + selection => this.getTrackId(selection.membership) === trackId + ); + } + clearSelection(): void { this.selectedReleases = new Map(); diff --git a/src/app/services/connectors/rest-api/membership-section-data.service.spec.ts b/src/app/services/connectors/rest-api/membership-section-data.service.spec.ts new file mode 100644 index 000000000..ff1a151a6 --- /dev/null +++ b/src/app/services/connectors/rest-api/membership-section-data.service.spec.ts @@ -0,0 +1,101 @@ +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom, of } from 'rxjs'; +import { vi } from 'vitest'; + +import { MembershipSectionDataService } from './membership-section-data.service'; +import { ReleaseTracksConnectorService } from './release-tracks.service'; + +describe('MembershipSectionDataService', () => { + const objectRef = 'attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8'; + const trackId = 'release-track--c3d8c25d-0dfe-4d8a-8249-1fc4016d0555'; + + let service: MembershipSectionDataService; + let releaseTracksConnector: { + listReleaseTracks: ReturnType; + listReleasesForObject: ReturnType; + listObjectVersions: ReturnType; + }; + + beforeEach(() => { + releaseTracksConnector = { + listReleaseTracks: vi.fn().mockReturnValue( + of({ + data: [ + { + id: trackId, + track_id: trackId, + name: 'Release Track v1', + type: 'standard', + }, + ], + }) + ), + listReleasesForObject: vi.fn().mockReturnValue( + of({ + data: [ + { + track_id: trackId, + version: '1.0', + tagged_at: '2026-07-20T14:57:40.134Z', + object_modified: '2025-04-15T19:58:03.170Z', + }, + { + track_id: 'release-track--other', + version: '2.0', + tagged_at: '2026-07-21T14:57:40.134Z', + }, + ], + }) + ), + listObjectVersions: vi.fn().mockReturnValue( + of({ + versions: [ + { + tier: 'members', + object_ref: objectRef, + object_modified: '2025-04-15T19:58:03.170Z', + }, + ], + }) + ), + }; + + service = new MembershipSectionDataService( + {} as HttpClient, + releaseTracksConnector as unknown as ReleaseTracksConnectorService + ); + }); + + it('maps tagged releases to the correct track without retaining a stale draft', async () => { + const tracks = await firstValueFrom( + service.loadMemberships( + objectRef, + { + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'staged', + status: 'reviewed', + }, + ], + }, + }, + null + ) + ); + + const standardTrack = tracks.find(track => track.type === 'STANDARD'); + + expect(standardTrack?.current_draft).toBeNull(); + expect(standardTrack?.production_releases).toHaveLength(1); + expect(standardTrack?.production_releases?.[0]).toMatchObject({ + track_id: trackId, + version: '1.0', + }); + expect(releaseTracksConnector.listReleasesForObject).toHaveBeenCalledWith( + objectRef, + { order: 'desc', limit: 100, offset: 0 } + ); + }); +}); diff --git a/src/app/services/connectors/rest-api/membership-section-data.service.ts b/src/app/services/connectors/rest-api/membership-section-data.service.ts index 28ef90a5c..78236b163 100644 --- a/src/app/services/connectors/rest-api/membership-section-data.service.ts +++ b/src/app/services/connectors/rest-api/membership-section-data.service.ts @@ -108,8 +108,12 @@ export class MembershipSectionDataService { return of(null); }) ), + releasesResponse: this.releaseTracksConnector.listReleasesForObject( + objectRef, + { order: 'desc', limit: 100, offset: 0 } + ), }).pipe( - map(({ rawObject, releaseTracksResponse }) => { + map(({ rawObject, releaseTracksResponse, releasesResponse }) => { const memberships = suppliedMemberships.length ? suppliedMemberships : this.extractMemberships(rawObject, { @@ -120,7 +124,10 @@ export class MembershipSectionDataService { releaseTracksResponse ); - return this.buildTracks(availableTracks, memberships, objectRef); + return this.applyTaggedReleases( + this.buildTracks(availableTracks, memberships, objectRef), + releasesResponse + ); }), switchMap(tracks => this.loadVersionsForMembershipTracks(tracks, objectRef) @@ -304,15 +311,21 @@ export class MembershipSectionDataService { response?.releases ?? null; - const currentDraft = - responseDraft ?? - versions.find(version => this.isDraftVersion(version)) ?? - track.current_draft ?? - null; + const currentDraft = responseDraft + ? responseDraft + : versions.length + ? (versions.find(version => this.isDraftVersion(version)) ?? null) + : (track.current_draft ?? null); + + const versionReleases = versions.filter(version => + this.isProductionVersion(version) + ); const productionReleases = Array.isArray(directReleases) ? directReleases - : versions.filter(version => this.isProductionVersion(version)); + : versionReleases.length + ? versionReleases + : (track.production_releases ?? []); return { ...track, @@ -323,6 +336,31 @@ export class MembershipSectionDataService { }; } + private applyTaggedReleases( + tracks: MembershipTrack[], + response: any + ): MembershipTrack[] { + const releases = Array.isArray(response) + ? response + : Array.isArray(response?.data) + ? response.data + : []; + + return tracks.map(track => { + const trackId = this.getTrackApiId(track); + const trackReleases = releases.filter( + (release: any) => + String(release?.track_id ?? release?.trackId ?? '') === trackId + ); + + return { + ...track, + production_releases: trackReleases, + releases: trackReleases, + }; + }); + } + private normalizeVersionList(response: any): any[] { if (!response) { return []; diff --git a/src/app/services/connectors/rest-api/release-tracks.service.ts b/src/app/services/connectors/rest-api/release-tracks.service.ts index 44c2e0523..db45cf0d8 100644 --- a/src/app/services/connectors/rest-api/release-tracks.service.ts +++ b/src/app/services/connectors/rest-api/release-tracks.service.ts @@ -152,6 +152,21 @@ export class ReleaseTracksConnectorService extends ApiConnector { ); } + /** List tagged releases that directly contain an object. */ + public listReleasesForObject( + objectRef: string, + options?: { order?: 'asc' | 'desc'; limit?: number; offset?: number } + ): Observable { + const params = this.buildHttpParams(options); + const url = `${this.apiUrl}/release-tracks/objects/${encodeURIComponent(objectRef)}/releases`; + + return this.http.get(url, { params }).pipe( + tap(() => logger.log(`retrieved tagged releases for ${objectRef}`)), + catchError(this.handleError_continue(null)), + share() + ); + } + /** * POST /api/release-tracks/new * Create a new release track. From 39e64785040dada9a242d71a168ba80d09314a5d Mon Sep 17 00:00:00 2001 From: avwchapman Date: Mon, 20 Jul 2026 16:45:38 -0400 Subject: [PATCH 3/3] fix: show release track status transition dates --- .../membership-section-data.service.spec.ts | 148 +++++++++++- .../membership-section-data.service.ts | 228 +++++++++++++++--- 2 files changed, 345 insertions(+), 31 deletions(-) diff --git a/src/app/services/connectors/rest-api/membership-section-data.service.spec.ts b/src/app/services/connectors/rest-api/membership-section-data.service.spec.ts index ff1a151a6..e2239d887 100644 --- a/src/app/services/connectors/rest-api/membership-section-data.service.spec.ts +++ b/src/app/services/connectors/rest-api/membership-section-data.service.spec.ts @@ -10,13 +10,46 @@ describe('MembershipSectionDataService', () => { const trackId = 'release-track--c3d8c25d-0dfe-4d8a-8249-1fc4016d0555'; let service: MembershipSectionDataService; + let httpClient: { get: ReturnType }; let releaseTracksConnector: { listReleaseTracks: ReturnType; listReleasesForObject: ReturnType; listObjectVersions: ReturnType; + getLatestSnapshot: ReturnType; }; beforeEach(() => { + httpClient = { + get: vi.fn().mockReturnValue( + of([ + { + stix: { modified: '2026-07-10T10:00:00.000Z' }, + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'candidates', + status: 'work-in-progress', + }, + ], + }, + }, + { + stix: { modified: '2026-07-12T12:30:00.000Z' }, + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'candidates', + status: 'awaiting-review', + }, + ], + }, + }, + ]) + ), + }; + releaseTracksConnector = { listReleaseTracks: vi.fn().mockReturnValue( of({ @@ -58,14 +91,127 @@ describe('MembershipSectionDataService', () => { ], }) ), + getLatestSnapshot: vi.fn().mockReturnValue( + of({ + modified: '2026-07-20T15:32:46.907Z', + candidates: [ + { + object_ref: objectRef, + object_status: 'work-in-progress', + object_added_at: '2026-07-20T15:32:46.901Z', + }, + ], + staged: [], + }) + ), }; service = new MembershipSectionDataService( - {} as HttpClient, + httpClient as unknown as HttpClient, releaseTracksConnector as unknown as ReleaseTracksConnectorService ); }); + it('uses all object revisions to date the latest status change', async () => { + releaseTracksConnector.listReleasesForObject.mockReturnValue( + of({ data: [] }) + ); + releaseTracksConnector.listObjectVersions.mockReturnValue( + of({ + versions: [ + { + tier: 'candidates', + object_ref: objectRef, + object_status: 'awaiting-review', + }, + ], + }) + ); + + const tracks = await firstValueFrom( + service.loadMemberships( + objectRef, + { + type: 'attack-pattern', + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'candidates', + status: 'awaiting-review', + }, + ], + }, + }, + null + ) + ); + + expect(tracks[0].current_draft?.updated_at).toBe( + '2026-07-12T12:30:00.000Z' + ); + expect(httpClient.get).toHaveBeenCalledWith( + expect.stringContaining(`/techniques/${objectRef}`), + { params: { versions: 'all' } } + ); + }); + + it('falls back to the track snapshot date when revisions show no transition', async () => { + httpClient.get.mockReturnValue( + of([ + { + stix: { modified: '2025-04-15T19:58:03.170Z' }, + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'candidates', + status: 'work-in-progress', + }, + ], + }, + }, + ]) + ); + releaseTracksConnector.listReleasesForObject.mockReturnValue( + of({ data: [] }) + ); + releaseTracksConnector.listObjectVersions.mockReturnValue( + of({ + versions: [ + { + tier: 'candidates', + object_ref: objectRef, + object_status: 'work-in-progress', + }, + ], + }) + ); + + const tracks = await firstValueFrom( + service.loadMemberships( + objectRef, + { + type: 'attack-pattern', + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'candidates', + status: 'work-in-progress', + }, + ], + }, + }, + null + ) + ); + + expect(tracks[0].current_draft?.updated_at).toBe( + '2026-07-20T15:32:46.901Z' + ); + }); + it('maps tagged releases to the correct track without retaining a stale draft', async () => { const tracks = await firstValueFrom( service.loadMemberships( diff --git a/src/app/services/connectors/rest-api/membership-section-data.service.ts b/src/app/services/connectors/rest-api/membership-section-data.service.ts index 78236b163..d54085617 100644 --- a/src/app/services/connectors/rest-api/membership-section-data.service.ts +++ b/src/app/services/connectors/rest-api/membership-section-data.service.ts @@ -112,23 +112,37 @@ export class MembershipSectionDataService { objectRef, { order: 'desc', limit: 100, offset: 0 } ), + objectVersionsResponse: this.loadAllObjectVersions( + objectRef, + objectInput + ), }).pipe( - map(({ rawObject, releaseTracksResponse, releasesResponse }) => { - const memberships = suppliedMemberships.length - ? suppliedMemberships - : this.extractMemberships(rawObject, { - object: rawObject, - }); - - const availableTracks = this.normalizeReleaseTrackList( - releaseTracksResponse - ); - - return this.applyTaggedReleases( - this.buildTracks(availableTracks, memberships, objectRef), - releasesResponse - ); - }), + map( + ({ + rawObject, + releaseTracksResponse, + releasesResponse, + objectVersionsResponse, + }) => { + const memberships = suppliedMemberships.length + ? suppliedMemberships + : this.extractMemberships(rawObject, { + object: rawObject, + }); + + const availableTracks = this.normalizeReleaseTrackList( + releaseTracksResponse + ); + + return this.applyStatusChangeDates( + this.applyTaggedReleases( + this.buildTracks(availableTracks, memberships, objectRef), + releasesResponse + ), + objectVersionsResponse + ); + } + ), switchMap(tracks => this.loadVersionsForMembershipTracks(tracks, objectRef) ), @@ -170,6 +184,31 @@ export class MembershipSectionDataService { ); } + private loadAllObjectVersions( + objectRef: string, + objectInput: any + ): Observable { + const objectType = + objectInput?.type ?? + objectInput?.stix?.type ?? + objectInput?.attackType ?? + this.getTypeFromStixId(objectRef); + const resource = this.getResourceName(objectType); + + if (!resource) { + return of([]); + } + + const url = `${this.apiUrl}/${resource}/${encodeURIComponent(objectRef)}`; + + return this.http.get(url, { params: { versions: 'all' } }).pipe( + catchError(error => { + console.error(`Failed to load versions for object ${objectRef}`, error); + return of([]); + }) + ); + } + private extractMemberships( objectInput: any, configInput: any @@ -276,18 +315,28 @@ export class MembershipSectionDataService { return of(track); } - return this.releaseTracksConnector - .listObjectVersions(track.apiId, objectRef) - .pipe( - map(response => this.applyVersionResponse(track, response)), - catchError(error => { - console.error( - `Failed to load versions for track ${track.apiId}`, - error - ); - return of(track); - }) - ); + return forkJoin({ + versionsResponse: this.releaseTracksConnector.listObjectVersions( + track.apiId, + objectRef + ), + snapshot: this.releaseTracksConnector.getLatestSnapshot(track.apiId), + }).pipe( + map(({ versionsResponse, snapshot }) => + this.applySnapshotStatusDate( + this.applyVersionResponse(track, versionsResponse), + snapshot, + objectRef + ) + ), + catchError(error => { + console.error( + `Failed to load versions for track ${track.apiId}`, + error + ); + return of(track); + }) + ); }); return requests.length ? forkJoin(requests) : of(tracks); @@ -311,10 +360,15 @@ export class MembershipSectionDataService { response?.releases ?? null; + const detectedDraft = versions.find(version => + this.isDraftVersion(version) + ); const currentDraft = responseDraft - ? responseDraft + ? { ...(track.current_draft ?? {}), ...responseDraft } : versions.length - ? (versions.find(version => this.isDraftVersion(version)) ?? null) + ? detectedDraft + ? { ...(track.current_draft ?? {}), ...detectedDraft } + : null : (track.current_draft ?? null); const versionReleases = versions.filter(version => @@ -336,6 +390,40 @@ export class MembershipSectionDataService { }; } + private applySnapshotStatusDate( + track: MembershipTrack, + snapshot: any, + objectRef: string + ): MembershipTrack { + if (!track.current_draft || track.current_draft.updated_at || !snapshot) { + return track; + } + + const stagedEntry = snapshot.staged?.find( + (entry: any) => entry?.object_ref === objectRef + ); + const candidateEntry = snapshot.candidates?.find( + (entry: any) => entry?.object_ref === objectRef + ); + const date = stagedEntry + ? (stagedEntry.object_staged_at ?? snapshot.modified) + : candidateEntry + ? ((candidateEntry.object_status === 'work-in-progress' + ? candidateEntry.object_added_at + : snapshot.modified) ?? candidateEntry.object_added_at) + : null; + + return date + ? { + ...track, + current_draft: { + ...track.current_draft, + updated_at: date, + }, + } + : track; + } + private applyTaggedReleases( tracks: MembershipTrack[], response: any @@ -361,6 +449,86 @@ export class MembershipSectionDataService { }); } + private applyStatusChangeDates( + tracks: MembershipTrack[], + response: any + ): MembershipTrack[] { + const revisions = this.normalizeObjectRevisionList(response).sort( + (first, second) => + this.getObjectRevisionTimestamp(first) - + this.getObjectRevisionTimestamp(second) + ); + + return tracks.map(track => { + if (!track.current_draft) { + return track; + } + + const trackId = this.getTrackApiId(track); + let previousStatus: string | null = null; + let changedAt: unknown = null; + + revisions.forEach(revision => { + const membership = this.extractMemberships(revision, { + object: revision, + }).find(item => item.id === trackId); + const status = membership + ? this.normalizeStatus(membership.status ?? membership.tier) + : ''; + + if (!status) { + return; + } + + if (previousStatus === null) { + previousStatus = status; + return; + } + + if (status !== previousStatus) { + previousStatus = status; + changedAt = this.getObjectRevisionDate(revision); + } + }); + + return changedAt + ? { + ...track, + current_draft: { + ...track.current_draft, + updated_at: changedAt, + }, + } + : track; + }); + } + + private normalizeObjectRevisionList(response: any): any[] { + if (Array.isArray(response)) { + return response; + } + + const revisions = + response?.data ?? response?.items ?? response?.results ?? response ?? []; + + return Array.isArray(revisions) ? revisions : []; + } + + private getObjectRevisionTimestamp(revision: any): number { + const value = this.getObjectRevisionDate(revision); + const timestamp = value ? new Date(value).getTime() : 0; + + return Number.isNaN(timestamp) ? 0 : timestamp; + } + + private getObjectRevisionDate(revision: any): string | number | Date | null { + return (revision?.stix?.modified ?? + revision?.modified ?? + revision?.updated_at ?? + revision?.updatedAt ?? + null) as string | number | Date | null; + } + private normalizeVersionList(response: any): any[] { if (!response) { return [];