diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4ba43f170..480b5f31c 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -151,6 +151,7 @@ import { IconViewComponent } from './components/icon-view/icon-view.component'; import { ObjectStatusComponent } from './components/object-status/object-status.component'; import { RecentActivityComponent } from './components/recent-activity/recent-activity.component'; import { NotesEditorComponent } from './components/stix/stix-page-tabs/notes-editor/notes-editor.component'; +import { ReleaseReviewDialogComponent } from './components/release-review-dialog/release-review-dialog.component'; import { IdentityPropertyComponent } from './components/stix/identity-property/identity-property.component'; import { CitationEditComponent } from './components/stix/citation-property/citation-edit/citation-edit.component'; @@ -350,6 +351,7 @@ export function initConfig(appConfigService: AppConfigService) { OrderedListViewComponent, OrderedListEditComponent, NotesEditorComponent, + ReleaseReviewDialogComponent, ObjectStatusComponent, RecentActivityComponent, IdentityPropertyComponent, diff --git a/src/app/classes/release-tracks/api.ts b/src/app/classes/release-tracks/api.ts index af8470ae4..b68e5f265 100644 --- a/src/app/classes/release-tracks/api.ts +++ b/src/app/classes/release-tracks/api.ts @@ -140,7 +140,8 @@ export interface VirtualReleasePreviewSummary extends ReleasePreviewSummaryBase } export type ReleasePreviewSummary = - StandardReleasePreviewSummary | VirtualReleasePreviewSummary; + | StandardReleasePreviewSummary + | VirtualReleasePreviewSummary; export interface SnapshotBundleHashes { manifest_id: string; diff --git a/src/app/classes/release-tracks/tiers.ts b/src/app/classes/release-tracks/tiers.ts index bae5f0ad9..29852029b 100644 --- a/src/app/classes/release-tracks/tiers.ts +++ b/src/app/classes/release-tracks/tiers.ts @@ -2,7 +2,8 @@ import { WorkflowStatusType } from 'src/app/utils/types'; import { SnapshotTier } from './enums'; export type ReleaseTrackObjectTier = - SnapshotTier.Candidate | SnapshotTier.Staged; + | SnapshotTier.Candidate + | SnapshotTier.Staged; export type WorkflowRevisionSelector = Date | 'latest'; diff --git a/src/app/components/release-review-dialog/release-review-dialog.component.html b/src/app/components/release-review-dialog/release-review-dialog.component.html new file mode 100644 index 000000000..c57cb9a3a --- /dev/null +++ b/src/app/components/release-review-dialog/release-review-dialog.component.html @@ -0,0 +1,127 @@ + diff --git a/src/app/components/release-review-dialog/release-review-dialog.component.scss b/src/app/components/release-review-dialog/release-review-dialog.component.scss new file mode 100644 index 000000000..36f1ca245 --- /dev/null +++ b/src/app/components/release-review-dialog/release-review-dialog.component.scss @@ -0,0 +1,86 @@ +@use '../../../style/colors'; + +.release-review-dialog-panel .mat-mdc-dialog-surface { + border: 1px solid; + border-radius: 10px; + overflow: hidden; + @include colors.theme-border-color; +} + +.release-review-dialog { + display: flex; + width: min(94vw, 1160px); + max-height: min(90vh, 900px); + flex-direction: column; + + .release-review-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 18px 24px; + border-bottom: 1px solid; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.045), + rgba(colors.color(mitre-black), 0.025) + ); + + h2, + p { + margin: 0; + } + + p { + margin-top: 4px; + @include colors.theme-text-deemphasis; + + span { + margin-left: 12px; + font-weight: 700; + } + } + } + + .release-review-content { + min-height: 0; + flex: 1 1 auto; + padding: 20px 24px; + overflow: auto; + } + + .release-review-note { + padding: 16px 24px 0; + border-top: 1px solid; + @include colors.theme-border-color; + + mat-form-field { + width: 100%; + } + } + + .release-review-actions { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid; + @include colors.theme-border-color; + + .action-spacer { + flex: 1 1 auto; + } + } +} + +@media (max-width: 700px) { + .release-review-dialog .release-review-actions { + align-items: stretch; + flex-direction: column; + + .action-spacer { + display: none; + } + } +} diff --git a/src/app/components/release-review-dialog/release-review-dialog.component.spec.ts b/src/app/components/release-review-dialog/release-review-dialog.component.spec.ts new file mode 100644 index 000000000..c60437cb8 --- /dev/null +++ b/src/app/components/release-review-dialog/release-review-dialog.component.spec.ts @@ -0,0 +1,99 @@ +import { ReleaseReviewDialogComponent } from './release-review-dialog.component'; + +describe('ReleaseReviewDialogComponent', () => { + const createComponent = (count = 2) => { + const dialogRef = { close: vi.fn() } as any; + const items = Array.from({ length: count }, (_, index) => ({ + item: { + object_ref: `attack-pattern--${index}`, + name: `Technique ${index}`, + }, + current: { + name: `Technique ${index}`, + type: 'attack-pattern', + }, + prior: null, + })) as any; + return { + component: new ReleaseReviewDialogComponent(dialogRef, { items }), + dialogRef, + }; + }; + + it('steps through items and returns approved objects', () => { + const { component, dialogRef } = createComponent(); + + component.approve(); + + expect(component.index).toBe(1); + expect(dialogRef.close).not.toHaveBeenCalled(); + + component.skip(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + approved: [expect.objectContaining({ object_ref: 'attack-pattern--0' })], + updateRequests: [], + }); + }); + + it('requires a note before requesting updates', () => { + const { component, dialogRef } = createComponent(1); + + component.requestUpdates(); + expect(dialogRef.close).not.toHaveBeenCalled(); + + component.note = 'Please update the description.'; + component.requestUpdates(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + approved: [], + updateRequests: [ + { + item: expect.objectContaining({ object_ref: 'attack-pattern--0' }), + note: 'Please update the description.', + }, + ], + }); + }); + + it('exposes the active object metadata and diff config', () => { + const { component } = createComponent(); + + expect(component.objectName).toBe('Technique 0'); + expect(component.progressLabel).toBe('1 of 2'); + expect(component.config).toEqual({ + mode: 'diff', + object: [component.reviewItem.current, component.reviewItem.prior], + editable: false, + sidebarControl: 'disable', + showRelationships: false, + }); + + delete (component.reviewItem.item as any).name; + (component.reviewItem.item as any).attack_id = 'T0001'; + expect(component.objectName).toBe('T0001'); + + delete (component.reviewItem.item as any).attack_id; + expect(component.objectName).toBe('Object'); + }); + + it('closes without a result when no actions have been completed', () => { + const { component, dialogRef } = createComponent(); + + component.cancel(); + + expect(dialogRef.close).toHaveBeenCalledWith(undefined); + }); + + it('preserves completed actions when closing before the final item', () => { + const { component, dialogRef } = createComponent(); + + component.approve(); + component.cancel(); + + expect(dialogRef.close).toHaveBeenLastCalledWith({ + approved: [expect.objectContaining({ object_ref: 'attack-pattern--0' })], + updateRequests: [], + }); + }); +}); diff --git a/src/app/components/release-review-dialog/release-review-dialog.component.ts b/src/app/components/release-review-dialog/release-review-dialog.component.ts new file mode 100644 index 000000000..242ba5df1 --- /dev/null +++ b/src/app/components/release-review-dialog/release-review-dialog.component.ts @@ -0,0 +1,109 @@ +import { Component, Inject, ViewEncapsulation } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +import { StixObject } from 'src/app/classes/stix'; +import { ReleaseTrackObjectItem } from 'src/app/components/release-track-object-card/release-track-object-card.component'; +import { StixViewConfig } from 'src/app/views/stix/stix-view-page'; + +export interface ReleaseReviewItem { + item: ReleaseTrackObjectItem; + current: StixObject; + prior: StixObject | null; +} + +export interface ReleaseReviewDialogData { + items: ReleaseReviewItem[]; +} + +export interface ReleaseReviewUpdateRequest { + item: ReleaseTrackObjectItem; + note: string; +} + +export interface ReleaseReviewDialogResult { + approved: ReleaseTrackObjectItem[]; + updateRequests: ReleaseReviewUpdateRequest[]; +} + +@Component({ + selector: 'app-release-review-dialog', + templateUrl: './release-review-dialog.component.html', + styleUrls: ['./release-review-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class ReleaseReviewDialogComponent { + public index = 0; + public note = ''; + public showNote = false; + + private readonly result: ReleaseReviewDialogResult = { + approved: [], + updateRequests: [], + }; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ReleaseReviewDialogData + ) {} + + public get reviewItem(): ReleaseReviewItem { + return this.data.items[this.index]; + } + + public get objectName(): string { + return ( + this.reviewItem?.item?.name || + this.reviewItem?.item?.attack_id || + 'Object' + ); + } + + public get progressLabel(): string { + return `${this.index + 1} of ${this.data.items.length}`; + } + + public get config(): StixViewConfig { + return { + mode: 'diff', + object: [this.reviewItem.current, this.reviewItem.prior], + editable: false, + sidebarControl: 'disable', + showRelationships: false, + }; + } + + public approve(): void { + this.result.approved.push(this.reviewItem.item); + this.next(); + } + + public requestUpdates(): void { + const note = this.note.trim(); + if (!note) return; + + this.result.updateRequests.push({ item: this.reviewItem.item, note }); + this.next(); + } + + public skip(): void { + this.next(); + } + + public cancel(): void { + const hasCompletedActions = + this.result.approved.length > 0 || this.result.updateRequests.length > 0; + this.dialogRef.close(hasCompletedActions ? this.result : undefined); + } + + private next(): void { + if (this.index === this.data.items.length - 1) { + this.dialogRef.close(this.result); + return; + } + + this.index += 1; + this.note = ''; + this.showNote = false; + } +} diff --git a/src/app/components/stix/stix-list/stix-list.component.ts b/src/app/components/stix/stix-list/stix-list.component.ts index a0d6ce541..8efe40625 100644 --- a/src/app/components/stix/stix-list/stix-list.component.ts +++ b/src/app/components/stix/stix-list/stix-list.component.ts @@ -393,10 +393,12 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { this.config.sourceRef ? sticky_allowed : false, ['relationship-name'] ); - if (!( - this.config.relationshipType && - this.config.relationshipType == 'subtechnique-of' - )) + if ( + !( + this.config.relationshipType && + this.config.relationshipType == 'subtechnique-of' + ) + ) this.addColumn( 'description', 'description', diff --git a/src/app/components/workbench-chip/workbench-chip.component.ts b/src/app/components/workbench-chip/workbench-chip.component.ts index 9a1d47a3d..7ba927cd7 100644 --- a/src/app/components/workbench-chip/workbench-chip.component.ts +++ b/src/app/components/workbench-chip/workbench-chip.component.ts @@ -6,7 +6,12 @@ import { } from 'src/app/utils/types'; export type WorkbenchChipVariant = - 'standard' | 'virtual' | 'tagged' | 'draft' | 'latest' | WorkflowStatusType; + | 'standard' + | 'virtual' + | 'tagged' + | 'draft' + | 'latest' + | WorkflowStatusType; const WORKBENCH_CHIP_LABELS: Record = { standard: 'Standard', diff --git a/src/app/utils/types.ts b/src/app/utils/types.ts index 68a34ac0f..89aa535e6 100644 --- a/src/app/utils/types.ts +++ b/src/app/utils/types.ts @@ -101,7 +101,11 @@ export const WORKFLOW_STATUS_RANK: Record = { * Collection/release changelog categories */ export type ChangelogCategory = - 'additions' | 'changes' | 'minor_changes' | 'revocations' | 'deprecations'; + | 'additions' + | 'changes' + | 'minor_changes' + | 'revocations' + | 'deprecations'; export interface ReleaseTrackStatus { trackId: string; 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 a5c56555b..4d05f178a 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 @@ -334,7 +334,25 @@

Resolution Details

}"> {{ lane.title }} ({{ lane.items.length }}) -
+
+ +