diff --git a/.changeset/upset-hands-hang.md b/.changeset/upset-hands-hang.md new file mode 100644 index 0000000..a579738 --- /dev/null +++ b/.changeset/upset-hands-hang.md @@ -0,0 +1,7 @@ +--- +"specif-ai": patch +--- + +make prd user story export format consistent with prds export format +extract clipboard logic into a clipboard service +removed CSV export option when exporting prd user stories diff --git a/ui/src/app/constants/export.constants.ts b/ui/src/app/constants/export.constants.ts index 737a47c..ae390c2 100644 --- a/ui/src/app/constants/export.constants.ts +++ b/ui/src/app/constants/export.constants.ts @@ -1,7 +1,19 @@ +import { REQUIREMENT_TYPE } from "./app.constants"; + export const EXPORT_FILE_FORMATS = { JSON: 'json', EXCEL: 'xlsx', } as const; +export const SPREADSHEET_HEADER_ROW = { + [REQUIREMENT_TYPE.BRD]: ['Id', 'Title', 'Description'], + [REQUIREMENT_TYPE.PRD]: ['Id', 'Title', 'Description'], + [REQUIREMENT_TYPE.NFR]: ['Id', 'Title', 'Description'], + [REQUIREMENT_TYPE.UIR]: ['Id', 'Title', 'Description'], + [REQUIREMENT_TYPE.BP]: ['Id', 'Title', 'Description'], + [REQUIREMENT_TYPE.US]: ['Id', 'Parent Id', 'Name', 'Description'], + [REQUIREMENT_TYPE.TASK]: ['Id', 'Parent Id', 'Title', 'Acceptance Criteria'], +} + export type ExportFileFormat = (typeof EXPORT_FILE_FORMATS)[keyof typeof EXPORT_FILE_FORMATS]; diff --git a/ui/src/app/pages/tasks/task-list/task-list.component.ts b/ui/src/app/pages/tasks/task-list/task-list.component.ts index d8f9e96..ce6693d 100644 --- a/ui/src/app/pages/tasks/task-list/task-list.component.ts +++ b/ui/src/app/pages/tasks/task-list/task-list.component.ts @@ -25,7 +25,7 @@ import { ButtonComponent } from '../../../components/core/button/button.componen import { NgIconComponent } from '@ng-icons/core'; import { ListItemComponent } from '../../../components/core/list-item/list-item.component'; import { BadgeComponent } from '../../../components/core/badge/badge.component'; -import { Clipboard } from '@angular/cdk/clipboard'; +import { ClipboardService } from '../../../services/clipboard.service'; import { TOASTER_MESSAGES } from 'src/app/constants/app.constants'; import { ToasterService } from 'src/app/services/toaster/toaster.service'; import { SearchInputComponent } from '../../../components/core/search-input/search-input.component'; @@ -53,7 +53,7 @@ export class TaskListComponent implements OnInit, OnDestroy { store = inject(Store); logger = inject(NGXLogger); router = inject(Router); - clipboard = inject(Clipboard); + clipboardService = inject(ClipboardService); searchService = inject(SearchService); userStoryId: string | null = ''; userStories: IUserStory[] = []; @@ -226,10 +226,16 @@ export class TaskListComponent implements OnInit, OnDestroy { copyTaskContent(event: Event, task: any) { event.stopPropagation(); const taskContent = `${task.id}: ${task.list}\n${task.acceptance || ''}`; - this.clipboard.copy(taskContent); - this.toastService.showSuccess( - TOASTER_MESSAGES.ENTITY.COPY.SUCCESS(this.entityType, task.id), - ); + const success = this.clipboardService.copyToClipboard(taskContent); + if (success) { + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.COPY.SUCCESS(this.entityType, task.id), + ); + } else { + this.toastService.showError( + TOASTER_MESSAGES.ENTITY.COPY.FAILURE(this.entityType, task.id), + ); + } } ngOnInit() { diff --git a/ui/src/app/pages/user-stories/user-stories.component.html b/ui/src/app/pages/user-stories/user-stories.component.html index 08731c0..6269cd5 100644 --- a/ui/src/app/pages/user-stories/user-stories.component.html +++ b/ui/src/app/pages/user-stories/user-stories.component.html @@ -46,14 +46,14 @@

User Stories

@@ -64,13 +64,6 @@

User Stories

> Sync with Jira - { - this.exportData = this.prepareExportData(res); - this.jsonOutput = { - userStories: res.map((userStory) => ({ - name: userStory.name, - description: userStory.description, - tasks: - userStory.tasks?.map((task) => ({ - list: task.list, - acceptanceCriteria: task.acceptance, - })) || [], - })), - }; - }); - } - - private prepareExportData(stories: any): any { - const worksheetData = [ - ['User Story', 'Description', 'Task', 'Acceptance Criteria'], - ]; - - stories.forEach((userStory: any) => { - userStory.tasks.forEach((task: any) => { - worksheetData.push([ - userStory.name, - userStory.description, - task.list, - task.acceptance, - ]); - }); - }); - return worksheetData; } copyUserStoryContent(event: Event, userStory: IUserStory) { event.stopPropagation(); const userStoryContent = `${userStory.id}: ${userStory.name}\n${userStory.description || ''}`; - this.clipboard.copy(userStoryContent); - this.toast.showSuccess( - TOASTER_MESSAGES.ENTITY.COPY.SUCCESS(this.entityType, userStory.id), - ); - } - - copyToClipboard() { - this.clipboard.copy(JSON.stringify(this.jsonOutput)); - } - - exportToExcel() { - this.spreadSheetService.exportToExcel( - [ - { - data: this.exportData, - }, - ], - `${this.navigation.data.name}_${this.navigation.fileName.split('-')[0]}`, - ); + const success = this.clipboardService.copyToClipboard(userStoryContent); + if (success) { + this.toast.showSuccess( + TOASTER_MESSAGES.ENTITY.COPY.SUCCESS(this.entityType, userStory.id), + ); + } else { + this.toast.showError( + TOASTER_MESSAGES.ENTITY.COPY.FAILURE(this.entityType, userStory.id), + ); + } } - exportToCSV() { - this.spreadSheetService.exportToCsv( - this.exportData, - `${this.navigation.data.name}_${this.navigation.fileName.split('-')[0]}`, + exportUserStories(exportType: ExportFileFormat) { + this.store.dispatch( + new ExportUserStories({ + type: exportType, + }), ); } diff --git a/ui/src/app/services/clipboard.service.ts b/ui/src/app/services/clipboard.service.ts new file mode 100644 index 0000000..c9d63f3 --- /dev/null +++ b/ui/src/app/services/clipboard.service.ts @@ -0,0 +1,31 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { Injectable } from '@angular/core'; +import { NGXLogger } from 'ngx-logger'; + +@Injectable({ + providedIn: 'root', +}) +export class ClipboardService { + constructor(private logger: NGXLogger, private clipboard: Clipboard) {} + + public copyToClipboard(data: string | object): boolean { + if (data === null || data === undefined) { + return false; + } + + try { + let contentToCopy: string; + + if (typeof data === 'object') { + contentToCopy = JSON.stringify(data); + } else { + contentToCopy = data; + } + + return this.clipboard.copy(contentToCopy); + } catch (error) { + this.logger.error('Failed to copy to clipboard:', error); + return false; + } + } +} diff --git a/ui/src/app/services/export/requirement-export-strategy.manager.ts b/ui/src/app/services/export/requirement-export-strategy.manager.ts index ff5c907..2aef778 100644 --- a/ui/src/app/services/export/requirement-export-strategy.manager.ts +++ b/ui/src/app/services/export/requirement-export-strategy.manager.ts @@ -1,4 +1,3 @@ -import { Clipboard } from '@angular/cdk/clipboard'; import { Injectable } from '@angular/core'; import { NGXLogger } from 'ngx-logger'; import { REQUIREMENT_TYPE } from 'src/app/constants/app.constants'; @@ -10,6 +9,8 @@ import { NFRExportStrategy } from './strategies/nfr-export.strategy'; import { PRDExportStrategy } from './strategies/prd-export.strategy'; import { UIRExportStrategy } from './strategies/uir-export.strategy'; import { AppSystemService } from '../app-system/app-system.service'; +import { ClipboardService } from '../clipboard.service'; +import { UserStoriesExportStrategy } from './strategies/user-stories-export.strategy'; @Injectable({ providedIn: 'root', @@ -21,7 +22,7 @@ export class RequirementExportStrategyManager { private logger: NGXLogger, private exportService: SpreadSheetService, private appSystemService: AppSystemService, - private clipboard: Clipboard, + private clipboardService: ClipboardService, ) {} initializeStrategy(requirementType: string) { @@ -31,35 +32,42 @@ export class RequirementExportStrategyManager { this.exportService, this.appSystemService, this.logger, - this.clipboard, + this.clipboardService, ); } case REQUIREMENT_TYPE.BRD: { return new BRDExportStrategy( this.exportService, this.logger, - this.clipboard, + this.clipboardService, ); } case REQUIREMENT_TYPE.BP: { return new BPExportStrategy( this.exportService, this.logger, - this.clipboard, + this.clipboardService, ); } case REQUIREMENT_TYPE.NFR: { return new NFRExportStrategy( this.exportService, this.logger, - this.clipboard, + this.clipboardService, ); } case REQUIREMENT_TYPE.UIR: { return new UIRExportStrategy( this.exportService, this.logger, - this.clipboard, + this.clipboardService, + ); + } + case REQUIREMENT_TYPE.US: { + return new UserStoriesExportStrategy( + this.exportService, + this.logger, + this.clipboardService, ); } default: { diff --git a/ui/src/app/services/export/requirement-export.service.ts b/ui/src/app/services/export/requirement-export.service.ts index 2cfdbb0..9fc5bbd 100644 --- a/ui/src/app/services/export/requirement-export.service.ts +++ b/ui/src/app/services/export/requirement-export.service.ts @@ -7,6 +7,18 @@ import { REQUIREMENT_DISPLAY_NAME_MAP, RequirementType, } from 'src/app/constants/app.constants'; +import { IUserStory } from 'src/app/model/interfaces/IUserStory'; +import { IList } from 'src/app/model/interfaces/IList'; + +// types + +type RequirementExportInputData = { + prdId:string; + userStories: Array; +} | Array; + +// types + @Injectable({ providedIn: 'root', @@ -19,14 +31,14 @@ export class RequirementExportService { ) {} public async exportRequirementData( - files: any[], + data: RequirementExportInputData, options: ExportRequirementDataOptions & { projectName: string }, requirementType: string, ): Promise { try { const strategy = this.strategyManager.getStrategy(requirementType); - const result = await strategy.export(files, { + const result = await strategy.export(data, { format: options.type, projectName: options.projectName, }); diff --git a/ui/src/app/services/export/strategies/base-requirement-export.strategy.ts b/ui/src/app/services/export/strategies/base-requirement-export.strategy.ts index a8d880e..972e99b 100644 --- a/ui/src/app/services/export/strategies/base-requirement-export.strategy.ts +++ b/ui/src/app/services/export/strategies/base-requirement-export.strategy.ts @@ -1,42 +1,44 @@ -import { Clipboard } from '@angular/cdk/clipboard'; import { NGXLogger } from 'ngx-logger'; +import { IList } from 'src/app/model/interfaces/IList'; import { - REQUIREMENT_TYPE, REQUIREMENT_DISPLAY_NAME_MAP, + REQUIREMENT_TYPE, RequirementType, } from '../../../constants/app.constants'; -import { EXPORT_FILE_FORMATS } from '../../../constants/export.constants'; +import { + EXPORT_FILE_FORMATS, + SPREADSHEET_HEADER_ROW, +} from '../../../constants/export.constants'; +import { ClipboardService } from '../../clipboard.service'; import { SpreadSheetService } from '../../spreadsheet.service'; import { ExportOptions, ExportResult, ExportStrategy } from './export.strategy'; // types -type BaseRequirementData = { +type FormattedRequirementItem = { id: string; title: string; requirement: string; }; +type RequirementItemRowArr = [string, string, string]; + // types export abstract class BaseRequirementExportStrategy implements ExportStrategy { constructor( - protected exportService: SpreadSheetService, protected logger: NGXLogger, - protected clipboard: Clipboard, protected requirementType: RequirementType, + protected exportService: SpreadSheetService, + protected clipboardService: ClipboardService, ) {} - supports(type: string): boolean { - return type === this.requirementType; - } - - async prepareData(files: any[]): Promise> { + protected prepareData(files: Array): Array { try { - const data: BaseRequirementData[] = files.map((file) => ({ + const data: FormattedRequirementItem[] = files.map((file) => ({ id: file.fileName.split('-')[0], - title: file.content.title, - requirement: file.content.requirement, + title: file.content.title!, + requirement: file.content.requirement!, })); return data; @@ -49,58 +51,57 @@ export abstract class BaseRequirementExportStrategy implements ExportStrategy { } } - async export(data: any[], options: ExportOptions): Promise { + async export( + data: Array, + options: ExportOptions, + ): Promise { try { const { format, projectName } = options; - - const preparedData = await this.prepareData(data); - - if (format === EXPORT_FILE_FORMATS.JSON) { - const success = this.exportToJSON(preparedData); - return { - success: success, - }; - } - - const transformedData = this.transformData(preparedData); - const fileName = `${projectName}_${REQUIREMENT_TYPE[this.requirementType].toLowerCase()}`; - - if (format === EXPORT_FILE_FORMATS.EXCEL) { - await this.exportToExcel(transformedData, fileName); - } else { - throw new Error(`Format ${format} not supported`); + const preparedData = this.prepareData(data); + + let success = true; + + switch (format) { + case EXPORT_FILE_FORMATS.JSON: { + success = this.clipboardService.copyToClipboard(preparedData); + break; + } + case EXPORT_FILE_FORMATS.EXCEL: { + const transformedData = this.transformData(preparedData); + const fileName = `${projectName}_${REQUIREMENT_TYPE[this.requirementType].toLowerCase()}`; + this.exportToExcel(transformedData, fileName); + break; + } + default: { + throw new Error(`Format ${format} not supported`); + } } - return { success: true }; + return { success: success }; } catch (error) { this.logger.error(`${this.requirementType} export failed:`, error); return { success: false, error: error as Error }; } } - protected transformData( - data: BaseRequirementData[], - ): Array<[string, string, string]> { - return data.map((d) => [d.id, d.title, d.requirement]); + protected transformData(data: FormattedRequirementItem[]) { + return data.map( + (d) => [d.id, d.title, d.requirement] as RequirementItemRowArr, + ); } - protected async exportToExcel( - data: Array<[string, string, string]>, + protected exportToExcel( + data: Array, fileName: string, - ): Promise { + ) { this.exportService.exportToExcel( [ { name: REQUIREMENT_DISPLAY_NAME_MAP[this.requirementType], - data: [['Id', 'Title', 'Requirement'], ...data], + data: [SPREADSHEET_HEADER_ROW[this.requirementType], ...data], }, ], fileName, ); } - - protected exportToJSON(data: any) { - const success = this.clipboard.copy(JSON.stringify(data, null, 2)); - return success; - } } diff --git a/ui/src/app/services/export/strategies/bp-export.strategy.ts b/ui/src/app/services/export/strategies/bp-export.strategy.ts index 91831e0..8cc8c66 100644 --- a/ui/src/app/services/export/strategies/bp-export.strategy.ts +++ b/ui/src/app/services/export/strategies/bp-export.strategy.ts @@ -1,4 +1,4 @@ -import { Clipboard } from '@angular/cdk/clipboard'; +import { ClipboardService } from '../../clipboard.service'; import { NGXLogger } from 'ngx-logger'; import { REQUIREMENT_TYPE } from 'src/app/constants/app.constants'; import { SpreadSheetService } from '../../spreadsheet.service'; @@ -8,8 +8,8 @@ export class BPExportStrategy extends BaseRequirementExportStrategy { constructor( exportService: SpreadSheetService, logger: NGXLogger, - clipboard: Clipboard, + clipboardService: ClipboardService, ) { - super(exportService, logger, clipboard, REQUIREMENT_TYPE.BP); + super(logger, REQUIREMENT_TYPE.BP, exportService, clipboardService); } } diff --git a/ui/src/app/services/export/strategies/brd-export.strategy.ts b/ui/src/app/services/export/strategies/brd-export.strategy.ts index 4ff5a3a..ab043e0 100644 --- a/ui/src/app/services/export/strategies/brd-export.strategy.ts +++ b/ui/src/app/services/export/strategies/brd-export.strategy.ts @@ -1,5 +1,5 @@ import { NGXLogger } from 'ngx-logger'; -import { Clipboard } from '@angular/cdk/clipboard'; +import { ClipboardService } from '../../clipboard.service'; import { SpreadSheetService } from '../../spreadsheet.service'; import { BaseRequirementExportStrategy } from './base-requirement-export.strategy'; import { REQUIREMENT_TYPE } from 'src/app/constants/app.constants'; @@ -8,8 +8,8 @@ export class BRDExportStrategy extends BaseRequirementExportStrategy { constructor( exportService: SpreadSheetService, logger: NGXLogger, - clipboard: Clipboard, + clipboardService: ClipboardService, ) { - super(exportService, logger, clipboard, REQUIREMENT_TYPE.BRD); + super(logger, REQUIREMENT_TYPE.BP, exportService, clipboardService); } } diff --git a/ui/src/app/services/export/strategies/export.strategy.ts b/ui/src/app/services/export/strategies/export.strategy.ts index 7ea52ea..865f86f 100644 --- a/ui/src/app/services/export/strategies/export.strategy.ts +++ b/ui/src/app/services/export/strategies/export.strategy.ts @@ -11,6 +11,5 @@ export interface ExportResult { } export interface ExportStrategy { - supports(requirementType: string): boolean; - export(data: any[], options: ExportOptions): Promise; + export(data: unknown, options: ExportOptions): Promise; } diff --git a/ui/src/app/services/export/strategies/nfr-export.strategy.ts b/ui/src/app/services/export/strategies/nfr-export.strategy.ts index 8aea05c..7a0bb7f 100644 --- a/ui/src/app/services/export/strategies/nfr-export.strategy.ts +++ b/ui/src/app/services/export/strategies/nfr-export.strategy.ts @@ -1,15 +1,15 @@ -import { Clipboard } from '@angular/cdk/clipboard'; import { NGXLogger } from 'ngx-logger'; -import { REQUIREMENT_TYPE } from 'src/app/constants/app.constants'; +import { ClipboardService } from '../../clipboard.service'; import { SpreadSheetService } from '../../spreadsheet.service'; import { BaseRequirementExportStrategy } from './base-requirement-export.strategy'; +import { REQUIREMENT_TYPE } from 'src/app/constants/app.constants'; export class NFRExportStrategy extends BaseRequirementExportStrategy { constructor( exportService: SpreadSheetService, logger: NGXLogger, - clipboard: Clipboard, + clipboardService: ClipboardService, ) { - super(exportService, logger, clipboard, REQUIREMENT_TYPE.NFR); + super(logger, REQUIREMENT_TYPE.BP, exportService, clipboardService); } } diff --git a/ui/src/app/services/export/strategies/prd-export.strategy.ts b/ui/src/app/services/export/strategies/prd-export.strategy.ts index 9e7605f..75b834f 100644 --- a/ui/src/app/services/export/strategies/prd-export.strategy.ts +++ b/ui/src/app/services/export/strategies/prd-export.strategy.ts @@ -1,40 +1,41 @@ import { NGXLogger } from 'ngx-logger'; -import { Clipboard } from '@angular/cdk/clipboard'; +import { IUserStory } from 'src/app/model/interfaces/IUserStory'; import { - REQUIREMENT_TYPE, + FILTER_STRINGS, REQUIREMENT_DISPLAY_NAME_MAP, + REQUIREMENT_TYPE, REQUIREMENT_TYPE_FOLDER_MAP, - FILTER_STRINGS, } from '../../../constants/app.constants'; -import { EXPORT_FILE_FORMATS } from '../../../constants/export.constants'; -import { SpreadSheetService } from '../../spreadsheet.service'; -import { ExportStrategy, ExportOptions, ExportResult } from './export.strategy'; +import { EXPORT_FILE_FORMATS, SPREADSHEET_HEADER_ROW } from '../../../constants/export.constants'; import { AppSystemService } from '../../app-system/app-system.service'; -import { IUserStory } from 'src/app/model/interfaces/IUserStory'; +import { ClipboardService } from '../../clipboard.service'; +import { SpreadSheetService } from '../../spreadsheet.service'; +import { ExportOptions, ExportResult, ExportStrategy } from './export.strategy'; +import { IList } from 'src/app/model/interfaces/IList'; // types -type PRDTask = { +type FormattedTask = { id: string; title: string; acceptance: string; }; -type PRDUserStory = { +type FormattedUserStory = { id: string; name: string; description: string; - tasks: PRDTask[]; + tasks: FormattedTask[]; }; -type PRDData = { +type FormattedPRD = { id: string; title: string; requirement: string; - features: PRDUserStory[]; + features: FormattedUserStory[]; }; -type PRDExportData = { +type PRDExportRows = { prdRows: Array<[string, string, string]>; userStories: Array<[string, string, string, string]>; tasks: Array<[string, string, string, string]>; @@ -47,20 +48,19 @@ export class PRDExportStrategy implements ExportStrategy { private exportService: SpreadSheetService, private appSystemService: AppSystemService, private logger: NGXLogger, - private clipboard: Clipboard, + private clipboardService: ClipboardService, ) {} supports(requirementType: string): boolean { return requirementType === REQUIREMENT_TYPE.PRD; } - async prepareData(prdFiles: any[], projectName: string): Promise { + async prepareData(prdFiles: IList[], projectName: string): Promise { try { - // Initialize PRDs with basic data - const prds: PRDData[] = prdFiles.map((prdFile) => ({ + const prds: FormattedPRD[] = prdFiles.map((prdFile) => ({ id: prdFile.fileName.split('-')[0], - title: prdFile.content.title, - requirement: prdFile.content.requirement, + title: prdFile.content.title!, + requirement: prdFile.content.requirement!, features: [], })); @@ -90,7 +90,7 @@ export class PRDExportStrategy implements ExportStrategy { const userStories: Array = JSON.parse(res).features || []; - const userStoriesFormatted: PRDUserStory[] = userStories.map( + const userStoriesFormatted: FormattedUserStory[] = userStories.map( (userStory) => { const storyId = `${prdId}-${userStory.id}`; return { @@ -110,7 +110,7 @@ export class PRDExportStrategy implements ExportStrategy { }, ); - return [prdId, userStoriesFormatted] as [string, PRDUserStory[]]; + return [prdId, userStoriesFormatted] as [string, FormattedUserStory[]]; } catch (error) { this.logger.error('Error processing feature file:', { path, @@ -138,47 +138,46 @@ export class PRDExportStrategy implements ExportStrategy { } } - async export(data: any[], options: ExportOptions): Promise { + async export(data: IList[], options: ExportOptions): Promise { try { - const { format, projectName } = options; + const { format: exportFormat, projectName } = options; - // First prepare the data const preparedData = await this.prepareData(data, projectName); let success = true; - if (format === EXPORT_FILE_FORMATS.JSON) { - const success = this.exportToJSON(preparedData); - return { - success: success, - }; - } - - const transformedData = this.transformData(preparedData); - const fileName = `${projectName}_${REQUIREMENT_TYPE.PRD.toLowerCase()}`; - - if (format === EXPORT_FILE_FORMATS.EXCEL) { - this.exportToExcel(transformedData, fileName); - } else { - throw new Error(`Format ${format} not supported`); + switch (exportFormat) { + case EXPORT_FILE_FORMATS.JSON: { + success = this.clipboardService.copyToClipboard(preparedData); + break; + } + case EXPORT_FILE_FORMATS.EXCEL: { + const transformedData = this.transformData(preparedData); + const fileName = `${projectName}_${REQUIREMENT_TYPE.PRD.toLowerCase()}`; + this.exportToExcel(transformedData, fileName); + break; + } + default: { + throw new Error(`Format ${exportFormat} not supported`); + } } - - return { success: true }; + + return { success: success }; } catch (error) { this.logger.error('PRD export failed:', error); return { success: false, error: error as Error }; } } - private transformData(data: PRDData[]): PRDExportData { - const prdRows: Array<[string, string, string]> = data.map((d) => [ + private transformData(data: FormattedPRD[]): PRDExportRows { + const prdRows: PRDExportRows["prdRows"] = data.map((d) => [ d.id, d.title, d.requirement, ]); - const userStories: Array<[string, string, string, string]> = []; - const tasks: Array<[string, string, string, string]> = []; + const userStories: PRDExportRows["userStories"] = []; + const tasks: PRDExportRows["tasks"] = []; data.forEach((prd) => { prd.features?.forEach((story) => { @@ -193,30 +192,25 @@ export class PRDExportStrategy implements ExportStrategy { return { prdRows, userStories, tasks }; } - private exportToExcel(data: PRDExportData, fileName: string) { + private exportToExcel(data: PRDExportRows, fileName: string) { const { prdRows, userStories, tasks } = data; this.exportService.exportToExcel( [ { name: REQUIREMENT_DISPLAY_NAME_MAP[REQUIREMENT_TYPE.PRD], - data: [['Id', 'Title', 'Requirement'], ...prdRows], + data: [SPREADSHEET_HEADER_ROW.PRD, ...prdRows], }, { name: REQUIREMENT_DISPLAY_NAME_MAP[REQUIREMENT_TYPE.US], - data: [['Id', 'Parent Id', 'Name', 'Description'], ...userStories], + data: [SPREADSHEET_HEADER_ROW.US, ...userStories], }, { name: REQUIREMENT_DISPLAY_NAME_MAP[REQUIREMENT_TYPE.TASK], - data: [['Id', 'Parent Id', 'Title', 'Acceptance Criteria'], ...tasks], + data: [SPREADSHEET_HEADER_ROW.TASK, ...tasks], }, ], fileName, ); } - - private exportToJSON(data: PRDData[]) { - const success = this.clipboard.copy(JSON.stringify(data, null, 2)); - return success; - } } diff --git a/ui/src/app/services/export/strategies/uir-export.strategy.ts b/ui/src/app/services/export/strategies/uir-export.strategy.ts index fdcda98..ae1337e 100644 --- a/ui/src/app/services/export/strategies/uir-export.strategy.ts +++ b/ui/src/app/services/export/strategies/uir-export.strategy.ts @@ -1,15 +1,15 @@ -import { Clipboard } from '@angular/cdk/clipboard'; import { NGXLogger } from 'ngx-logger'; -import { REQUIREMENT_TYPE } from 'src/app/constants/app.constants'; +import { ClipboardService } from '../../clipboard.service'; import { SpreadSheetService } from '../../spreadsheet.service'; import { BaseRequirementExportStrategy } from './base-requirement-export.strategy'; +import { REQUIREMENT_TYPE } from 'src/app/constants/app.constants'; export class UIRExportStrategy extends BaseRequirementExportStrategy { constructor( exportService: SpreadSheetService, logger: NGXLogger, - clipboard: Clipboard, + clipboardService: ClipboardService, ) { - super(exportService, logger, clipboard, REQUIREMENT_TYPE.UIR); + super(logger, REQUIREMENT_TYPE.BP, exportService, clipboardService); } } diff --git a/ui/src/app/services/export/strategies/user-stories-export.strategy.ts b/ui/src/app/services/export/strategies/user-stories-export.strategy.ts new file mode 100644 index 0000000..3955a11 --- /dev/null +++ b/ui/src/app/services/export/strategies/user-stories-export.strategy.ts @@ -0,0 +1,146 @@ +import { NGXLogger } from 'ngx-logger'; +import { + REQUIREMENT_DISPLAY_NAME_MAP, + REQUIREMENT_TYPE, +} from 'src/app/constants/app.constants'; +import { + EXPORT_FILE_FORMATS, + SPREADSHEET_HEADER_ROW, +} from 'src/app/constants/export.constants'; +import { IUserStory } from 'src/app/model/interfaces/IUserStory'; +import { ClipboardService } from '../../clipboard.service'; +import { SpreadSheetService } from '../../spreadsheet.service'; +import { ExportOptions, ExportResult, ExportStrategy } from './export.strategy'; + +// types + +type ExportInputData = { + prdId: string; + userStories: Array; +}; + +type FormattedStory = { + id: string; + name: string; + description: string; + tasks: { + id: string; + title: string; + acceptance: string; + }[]; +}; + +type StoriesExportData = { + userStoryRows: Array<[string, string, string, string]>; + taskRows: Array<[string, string, string, string]>; +}; + +// types + +export class UserStoriesExportStrategy implements ExportStrategy { + constructor( + private exportService: SpreadSheetService, + private logger: NGXLogger, + private clipboardService: ClipboardService, + ) {} + + private prepareData( + data: ExportInputData, + projectName: string, + ): Array { + try { + const userStories: Array = data.userStories || []; + + const userStoriesFormatted = userStories.map((userStory) => { + const storyId = `${data.prdId}-${userStory.id}`; + const tasks = + userStory.tasks?.map((task) => { + const taskId = `${storyId}-${task.id}`; + return { + id: taskId, + title: task.list, + acceptance: task.acceptance, + }; + }) ?? []; + + return { + id: storyId, + name: userStory.name, + description: userStory.description, + tasks: tasks, + }; + }); + + return userStoriesFormatted; + } catch (error) { + this.logger.error('Error preparing PRD export data:', error); + throw error; + } + } + + async export( + data: ExportInputData, + options: ExportOptions, + ): Promise { + const { projectName, format: exportFormat } = options; + + const preparedData = this.prepareData(data, options.projectName); + + let success = true; + + switch (exportFormat) { + case EXPORT_FILE_FORMATS.JSON: { + success = this.clipboardService.copyToClipboard(preparedData); + break; + } + case EXPORT_FILE_FORMATS.EXCEL: { + const transformedData = this.transformData(data.prdId, preparedData); + const fileName = `${projectName}_${REQUIREMENT_TYPE.PRD.toLowerCase()}`; + this.exportToExcel(transformedData, fileName); + break; + } + default: { + throw new Error(`Format ${exportFormat} not supported`); + } + } + + return { success: success }; + } + + private transformData( + prdId: string, + formattedUserStories: FormattedStory[], + ): StoriesExportData { + const userStoryRows: StoriesExportData["userStoryRows"] = formattedUserStories.map( + (story) => [story.id, prdId, story.name, story.description], + ); + + const taskRows: StoriesExportData["taskRows"] = []; + + formattedUserStories.forEach((story) => { + story.tasks?.forEach((task) => { + taskRows.push([task.id, story.id, task.title, task.acceptance]); + }); + }); + + return { userStoryRows, taskRows }; + } + + private exportToExcel(data: StoriesExportData, fileName: string) { + const { userStoryRows, taskRows } = data; + + this.exportService.exportToExcel( + [ + { + name: REQUIREMENT_DISPLAY_NAME_MAP[REQUIREMENT_TYPE.US], + data: [SPREADSHEET_HEADER_ROW.US, ...userStoryRows], + }, + { + name: REQUIREMENT_DISPLAY_NAME_MAP[REQUIREMENT_TYPE.TASK], + data: [SPREADSHEET_HEADER_ROW.TASK, ...taskRows], + }, + ], + fileName, + ); + } +} diff --git a/ui/src/app/store/user-stories/user-stories.actions.ts b/ui/src/app/store/user-stories/user-stories.actions.ts index 3498ae5..7a65a58 100644 --- a/ui/src/app/store/user-stories/user-stories.actions.ts +++ b/ui/src/app/store/user-stories/user-stories.actions.ts @@ -1,3 +1,4 @@ +import { ExportRequirementDataOptions } from 'src/app/model/interfaces/exports.interface'; import { ITask } from '../../model/interfaces/IList'; import { IUserStory } from '../../model/interfaces/IUserStory'; @@ -76,7 +77,7 @@ export class UpdateTask { constructor( readonly task: ITask, readonly relativePath: string, - readonly redirect?: boolean + readonly redirect?: boolean, ) {} } @@ -99,3 +100,9 @@ export class SetCurrentConfig { }, ) {} } + +export class ExportUserStories { + static readonly type = '[UserStories] Export User Stories'; + + constructor(public exportOptions: ExportRequirementDataOptions) {} +} diff --git a/ui/src/app/store/user-stories/user-stories.state.ts b/ui/src/app/store/user-stories/user-stories.state.ts index 5b10d4b..44b40d9 100644 --- a/ui/src/app/store/user-stories/user-stories.state.ts +++ b/ui/src/app/store/user-stories/user-stories.state.ts @@ -12,12 +12,16 @@ import { SetSelectedProject, SetSelectedUserStory, UpdateTask, + ExportUserStories, } from './user-stories.actions'; import { AppSystemService } from '../../services/app-system/app-system.service'; import { NGXLogger } from 'ngx-logger'; import { IUserStory } from '../../model/interfaces/IUserStory'; import { ITask } from '../../model/interfaces/ITask'; import { Router } from '@angular/router'; +import { RequirementExportService } from 'src/app/services/export/requirement-export.service'; +import { REQUIREMENT_TYPE } from 'src/app/constants/app.constants'; +import { ToasterService } from 'src/app/services/toaster/toaster.service'; export interface UserStoriesStateModel { userStories: IUserStory[]; @@ -53,6 +57,8 @@ export class UserStoriesState { private appSystemService: AppSystemService, private logger: NGXLogger, private router: Router, + private toast: ToasterService, + private requirementExportService: RequirementExportService ) { } @Selector() @@ -380,4 +386,35 @@ export class UserStoriesState { currentConfig: config, }); } + + @Action(ExportUserStories) + exportUserStories( + ctx: StateContext, + { exportOptions }: ExportUserStories, + ) { + try { + const state = ctx.getState(); + const prdId = state.currentConfig?.reqId; + + this.toast.showInfo(`Exporting user stories of prd ${prdId}`); + this.requirementExportService.exportRequirementData( + { + prdId: state.currentConfig?.reqId!, + userStories: state.userStories, + }, + { + projectName: state.selectedProject, + type: exportOptions.type, + }, + REQUIREMENT_TYPE.US, + ); + } catch (error) { + const message = `Failed to export user stories: ${ + error instanceof Error ? error.message : 'Unknown error' + }`; + this.logger.error(error); + this.logger.error(message); + this.toast.showError(message); + } + } }