diff --git a/backend/src/main/java/heartbeat/handler/AsyncMetricsDataHandler.java b/backend/src/main/java/heartbeat/handler/AsyncMetricsDataHandler.java index 38f7ed6c21..4e619ac6c9 100644 --- a/backend/src/main/java/heartbeat/handler/AsyncMetricsDataHandler.java +++ b/backend/src/main/java/heartbeat/handler/AsyncMetricsDataHandler.java @@ -26,6 +26,8 @@ public class AsyncMetricsDataHandler extends AsyncDataBaseHandler { private static final String SLASH = "/"; + private final Object readWriteLock = new Object(); + public void putMetricsDataCompleted(String timeStamp, MetricsDataCompleted metricsDataCompleted) { try { acquireLock(METRICS_DATA_COMPLETED, timeStamp); @@ -46,12 +48,13 @@ public void deleteExpireMetricsDataCompletedFile(long currentTimeStamp, File dir deleteExpireFileByType(METRICS_DATA_COMPLETED, currentTimeStamp, directory); } - @Synchronized + @Synchronized("readWriteLock") public void updateMetricsDataCompletedInHandler(String metricDataFileId, MetricType metricType, boolean isCreateCsvSuccess) { MetricsDataCompleted previousMetricsCompleted = getMetricsDataCompleted(metricDataFileId); if (previousMetricsCompleted == null) { - log.error(GENERATE_REPORT_ERROR); + String filename = OUTPUT_FILE_PATH + METRICS_DATA_COMPLETED.getPath() + metricDataFileId; + log.error(GENERATE_REPORT_ERROR + "; filename: " + filename); throw new GenerateReportException(GENERATE_REPORT_ERROR); } if (isCreateCsvSuccess) { @@ -66,10 +69,12 @@ public void updateMetricsDataCompletedInHandler(String metricDataFileId, MetricT putMetricsDataCompleted(metricDataFileId, previousMetricsCompleted); } + @Synchronized("readWriteLock") public void updateOverallMetricsCompletedInHandler(String metricDataFileId) { MetricsDataCompleted previousMetricsCompleted = getMetricsDataCompleted(metricDataFileId); if (previousMetricsCompleted == null) { - log.error(GENERATE_REPORT_ERROR); + String filename = OUTPUT_FILE_PATH + METRICS_DATA_COMPLETED.getPath() + metricDataFileId; + log.error(GENERATE_REPORT_ERROR + "; filename: " + filename); throw new GenerateReportException(GENERATE_REPORT_ERROR); } previousMetricsCompleted.setOverallMetricCompleted(true); diff --git a/backend/src/main/java/heartbeat/handler/base/AsyncDataBaseHandler.java b/backend/src/main/java/heartbeat/handler/base/AsyncDataBaseHandler.java index d1ead8f3ed..71c998c2d0 100644 --- a/backend/src/main/java/heartbeat/handler/base/AsyncDataBaseHandler.java +++ b/backend/src/main/java/heartbeat/handler/base/AsyncDataBaseHandler.java @@ -156,7 +156,7 @@ private void deleteOldFiles(FIleType fIleType, long currentTimeStamp, File direc for (File file : files) { String fileName = file.getName(); String[] splitResult = fileName.split(FILENAME_SPLIT_PATTERN); - String timeStamp = splitResult[1]; + String timeStamp = splitResult[3]; if (validateExpire(currentTimeStamp, Long.parseLong(timeStamp)) && !file.delete() && file.exists()) { log.error("Failed to deleted expired fIleType: {} file, file name: {}", fIleType.getType(), fileName); diff --git a/backend/src/main/java/heartbeat/service/report/GenerateReporterService.java b/backend/src/main/java/heartbeat/service/report/GenerateReporterService.java index 8936de4379..f31c422f08 100644 --- a/backend/src/main/java/heartbeat/service/report/GenerateReporterService.java +++ b/backend/src/main/java/heartbeat/service/report/GenerateReporterService.java @@ -337,7 +337,7 @@ private void deleteOldCSV(long currentTimeStamp, File directory) { for (File file : files) { String fileName = file.getName(); String[] splitResult = fileName.split("[-.]"); - String timeStamp = splitResult[1]; + String timeStamp = splitResult[3]; if (validateExpire(currentTimeStamp, Long.parseLong(timeStamp)) && !file.delete() && file.exists()) { log.error("Failed to deleted expired CSV file, file name: {}", fileName); } diff --git a/backend/src/main/java/heartbeat/service/report/ReportService.java b/backend/src/main/java/heartbeat/service/report/ReportService.java index 3e8778e3f6..6e88e4cea3 100644 --- a/backend/src/main/java/heartbeat/service/report/ReportService.java +++ b/backend/src/main/java/heartbeat/service/report/ReportService.java @@ -67,11 +67,8 @@ public void generateReport(GenerateReportRequest request) { threadList.add(metricTypeThread); } - CompletableFuture.runAsync(() -> { - for (CompletableFuture thread : threadList) { - thread.join(); - } - + CompletableFuture allFutures = CompletableFuture.allOf(threadList.toArray(new CompletableFuture[0])); + allFutures.thenRun(() -> { ReportResponse reportResponse = generateReporterService.getComposedReportResponse(request.getCsvTimeStamp(), convertTimeStampToYYYYMMDD(request.getStartTime()), convertTimeStampToYYYYMMDD(request.getEndTime())); diff --git a/backend/src/test/java/heartbeat/handler/AsyncExceptionHandlerTest.java b/backend/src/test/java/heartbeat/handler/AsyncExceptionHandlerTest.java index bbae219d2e..19aa8e5f71 100644 --- a/backend/src/test/java/heartbeat/handler/AsyncExceptionHandlerTest.java +++ b/backend/src/test/java/heartbeat/handler/AsyncExceptionHandlerTest.java @@ -34,6 +34,10 @@ class AsyncExceptionHandlerTest { public static final String APP_OUTPUT_ERROR = "./app/output/error"; + public static final String START_TIME = "20240417"; + + public static final String END_TIME = "20240418"; + @InjectMocks AsyncExceptionHandler asyncExceptionHandler; @@ -56,8 +60,8 @@ void shouldDeleteAsyncException() { long fileId = System.currentTimeMillis(); String currentTime = Long.toString(fileId); String expireTime = Long.toString(fileId - 1900000L); - String unExpireFile = IdUtil.getBoardReportFileId(currentTime); - String expireFile = IdUtil.getBoardReportFileId(expireTime); + String unExpireFile = getBoardReportFileId(currentTime); + String expireFile = getBoardReportFileId(expireTime); asyncExceptionHandler.put(unExpireFile, new UnauthorizedException("")); asyncExceptionHandler.put(expireFile, new UnauthorizedException("")); @@ -74,8 +78,8 @@ void shouldDeleteAsyncExceptionTmpFile() { long fileId = System.currentTimeMillis(); String currentTime = Long.toString(fileId); String expireTime = Long.toString(fileId - 1900000L); - String unExpireFile = IdUtil.getBoardReportFileId(currentTime) + ".tmp"; - String expireFile = IdUtil.getBoardReportFileId(expireTime) + ".tmp"; + String unExpireFile = getBoardReportFileId(currentTime) + ".tmp"; + String expireFile = getBoardReportFileId(expireTime) + ".tmp"; asyncExceptionHandler.put(unExpireFile, new UnauthorizedException("")); asyncExceptionHandler.put(expireFile, new UnauthorizedException("")); @@ -92,8 +96,8 @@ void shouldSafeDeleteAsyncExceptionWhenHaveManyThordToDeleteFile() throws Interr long fileId = System.currentTimeMillis(); String currentTime = Long.toString(fileId); String expireTime = Long.toString(fileId - 1900000L); - String unExpireFile = IdUtil.getBoardReportFileId(currentTime); - String expireFile = IdUtil.getBoardReportFileId(expireTime); + String unExpireFile = getBoardReportFileId(currentTime); + String expireFile = getBoardReportFileId(expireTime); asyncExceptionHandler.put(unExpireFile, new UnauthorizedException("")); asyncExceptionHandler.put(expireFile, new UnauthorizedException("")); CyclicBarrier barrier = new CyclicBarrier(3); @@ -125,7 +129,7 @@ void shouldSafeDeleteAsyncExceptionWhenHaveManyThordToDeleteFile() throws Interr void shouldPutAndGetAsyncException() { long currentTimeMillis = System.currentTimeMillis(); String currentTime = Long.toString(currentTimeMillis); - String boardReportId = IdUtil.getBoardReportFileId(currentTime); + String boardReportId = getBoardReportFileId(currentTime); asyncExceptionHandler.put(boardReportId, new UnauthorizedException("test")); var baseException = asyncExceptionHandler.get(boardReportId); @@ -147,7 +151,7 @@ void shouldThrowExceptionGivenCantWriteFileWhenPutFile() { @Test void shouldThrowExceptionGivenCannotReadFileWhenGetFile() throws IOException { new File("./app/output/error/").mkdirs(); - String boardReportId = IdUtil.getBoardReportFileId(Long.toString(System.currentTimeMillis())); + String boardReportId = getBoardReportFileId(Long.toString(System.currentTimeMillis())); Path filePath = Paths.get("./app/output/error/" + boardReportId); Files.createFile(filePath); Files.write(filePath, "test".getBytes()); @@ -163,7 +167,7 @@ void shouldCreateTargetDirWhenPutAsyncException() { boolean mkdirs = new File(APP_OUTPUT_ERROR).mkdirs(); long currentTimeMillis = System.currentTimeMillis(); String currentTime = Long.toString(currentTimeMillis); - String boardReportId = IdUtil.getBoardReportFileId(currentTime); + String boardReportId = getBoardReportFileId(currentTime); asyncExceptionHandler.put(boardReportId, new UnauthorizedException("test")); @@ -177,7 +181,7 @@ void shouldCreateTargetDirWhenPutAsyncException() { void shouldPutAndRemoveAsyncException() { long currentTimeMillis = System.currentTimeMillis(); String currentTime = Long.toString(currentTimeMillis); - String boardReportId = IdUtil.getBoardReportFileId(currentTime); + String boardReportId = getBoardReportFileId(currentTime); asyncExceptionHandler.put(boardReportId, new UnauthorizedException("test")); AsyncExceptionDTO baseException = asyncExceptionHandler.remove(boardReportId); @@ -190,7 +194,7 @@ void shouldPutAndRemoveAsyncException() { @Test void shouldReturnExceptionGivenWrongFileWhenReadAndRemoveAsyncException() throws IOException { new File("./app/output/error/").mkdirs(); - String boardReportId = IdUtil.getBoardReportFileId(Long.toString(System.currentTimeMillis())); + String boardReportId = getBoardReportFileId(Long.toString(System.currentTimeMillis())); Path filePath = Paths.get("./app/output/error/" + boardReportId); Files.createFile(filePath); Files.write(filePath, "test".getBytes()); @@ -204,7 +208,7 @@ void shouldReturnExceptionGivenWrongFileWhenReadAndRemoveAsyncException() throws @Test void shouldThrowExceptionWhenDeleteFile() { File mockFile = mock(File.class); - when(mockFile.getName()).thenReturn("board-1683734399999"); + when(mockFile.getName()).thenReturn("board-20240417-20240418-1683734399999"); when(mockFile.delete()).thenThrow(new RuntimeException("test")); File[] mockFiles = new File[] { mockFile }; File directory = mock(File.class); @@ -217,7 +221,7 @@ void shouldThrowExceptionWhenDeleteFile() { @Test void shouldDeleteFailWhenDeleteFile() { File mockFile = mock(File.class); - when(mockFile.getName()).thenReturn("board-1683734399999"); + when(mockFile.getName()).thenReturn("board-20240417-20240418-1683734399999"); when(mockFile.delete()).thenReturn(false); when(mockFile.exists()).thenReturn(true); File[] mockFiles = new File[] { mockFile }; @@ -237,4 +241,8 @@ private void deleteTestFile(String reportId) { asyncExceptionHandler.remove(reportId); } + private String getBoardReportFileId(String timestamp) { + return "board-" + START_TIME + "-" + END_TIME + "-" + timestamp; + } + } diff --git a/backend/src/test/java/heartbeat/handler/AsyncMetricsDataHandlerTest.java b/backend/src/test/java/heartbeat/handler/AsyncMetricsDataHandlerTest.java index 612465955d..143902446a 100644 --- a/backend/src/test/java/heartbeat/handler/AsyncMetricsDataHandlerTest.java +++ b/backend/src/test/java/heartbeat/handler/AsyncMetricsDataHandlerTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; @@ -17,9 +18,15 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import static heartbeat.controller.report.dto.request.MetricType.BOARD; +import static heartbeat.controller.report.dto.request.MetricType.DORA; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -90,7 +97,7 @@ class DeleteExpireMetricsDataCompletedFile { @Test void shouldDeleteMetricsDataReadyWhenMetricsFileIsExpire() throws IOException { long currentTimeMillis = System.currentTimeMillis(); - String prefix = "prefix-"; + String prefix = "prefix-20240417-20240418-"; String currentTimeFileId = prefix + currentTimeMillis; String expireTimeFileId = prefix + (currentTimeMillis - 1900000L); String expireTimeLockFileId = prefix + (currentTimeMillis - 1900000L) + ".lock"; @@ -142,8 +149,7 @@ void shouldThrowGenerateReportExceptionWhenPreviousMetricsStatusIsNull() { String currentTime = Long.toString(currentTimeMillis); GenerateReportException exception = assertThrows(GenerateReportException.class, - () -> asyncMetricsDataHandler.updateMetricsDataCompletedInHandler(currentTime, MetricType.BOARD, - false)); + () -> asyncMetricsDataHandler.updateMetricsDataCompletedInHandler(currentTime, BOARD, false)); assertEquals("Failed to update metrics data completed through this timestamp.", exception.getMessage()); } @@ -157,7 +163,7 @@ void shouldUpdateBoardMetricDataWhenPreviousMetricsStatusIsNotNullAndMetricTypeI .build(); asyncMetricsDataHandler.putMetricsDataCompleted(currentTime, metricsDataCompleted); - asyncMetricsDataHandler.updateMetricsDataCompletedInHandler(currentTime, MetricType.BOARD, true); + asyncMetricsDataHandler.updateMetricsDataCompletedInHandler(currentTime, BOARD, true); MetricsDataCompleted completed = asyncMetricsDataHandler.getMetricsDataCompleted(currentTime); assertTrue(completed.boardMetricsCompleted()); @@ -239,6 +245,67 @@ void shouldUpdateAllMetricDataWhenPreviousMetricsStatusIsNotNull() throws IOExce } + @Nested + class UpdateAllMetricsCompletedInHandlerAtTheSameTime { + + // The test should be moved to integration test next. + @RepeatedTest(100) + @SuppressWarnings("unchecked") + void shouldUpdateAllMetricDataAtTheSameTimeWhenPreviousMetricsStatusIsNotNull() throws IOException { + long currentTimeMillis = System.currentTimeMillis(); + String currentTime = Long.toString(currentTimeMillis); + List sleepTime = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + sleepTime.add(new Random().nextInt(100)); + } + MetricsDataCompleted metricsDataCompleted = MetricsDataCompleted.builder() + .boardMetricsCompleted(false) + .doraMetricsCompleted(false) + .overallMetricCompleted(false) + .build(); + asyncMetricsDataHandler.putMetricsDataCompleted(currentTime, metricsDataCompleted); + + List> threadList = new ArrayList<>(); + + threadList.add(CompletableFuture.runAsync(() -> { + try { + TimeUnit.MILLISECONDS.sleep(sleepTime.get(0)); // NOSONAR + } + catch (InterruptedException ignored) { + } + asyncMetricsDataHandler.updateMetricsDataCompletedInHandler(currentTime, BOARD, true); + })); + threadList.add(CompletableFuture.runAsync(() -> { + try { + TimeUnit.MILLISECONDS.sleep(sleepTime.get(1)); // NOSONAR + } + catch (InterruptedException ignored) { + } + asyncMetricsDataHandler.updateMetricsDataCompletedInHandler(currentTime, DORA, true); + })); + threadList.add(CompletableFuture.runAsync(() -> { + try { + TimeUnit.MILLISECONDS.sleep(sleepTime.get(2)); // NOSONAR + } + catch (InterruptedException ignored) { + } + asyncMetricsDataHandler.updateOverallMetricsCompletedInHandler(currentTime); + })); + + for (CompletableFuture thread : threadList) { + thread.join(); + } + + MetricsDataCompleted completed = asyncMetricsDataHandler.getMetricsDataCompleted(currentTime); + assertTrue(completed.boardMetricsCompleted()); + assertTrue(completed.doraMetricsCompleted()); + assertTrue(completed.allMetricsCompleted()); + Files.deleteIfExists(Path.of(APP_OUTPUT_METRICS + "/" + currentTime)); + assertNull(asyncMetricsDataHandler.getMetricsDataCompleted(currentTime)); + } + + } + private void createLockFile(String currentTime) throws IOException { String fileName = APP_OUTPUT_METRICS + "/" + currentTime + ".lock"; File file = new File(fileName); diff --git a/backend/src/test/java/heartbeat/handler/AsyncReportRequestHandlerTest.java b/backend/src/test/java/heartbeat/handler/AsyncReportRequestHandlerTest.java index 85740864e6..a09cab1bac 100644 --- a/backend/src/test/java/heartbeat/handler/AsyncReportRequestHandlerTest.java +++ b/backend/src/test/java/heartbeat/handler/AsyncReportRequestHandlerTest.java @@ -25,6 +25,10 @@ class AsyncReportRequestHandlerTest { public static final String APP_OUTPUT_REPORT = "./app/output/report"; + public static final String START_TIME = "20240417"; + + public static final String END_TIME = "20240418"; + @InjectMocks AsyncReportRequestHandler asyncReportRequestHandler; @@ -47,8 +51,8 @@ void shouldDeleteReportWhenReportIsExpire() throws IOException { long currentTimeMillis = System.currentTimeMillis(); String currentTime = Long.toString(currentTimeMillis); String expireTime = Long.toString(currentTimeMillis - 1900000L); - String unExpireFile = IdUtil.getBoardReportFileId(currentTime); - String expireFile = IdUtil.getBoardReportFileId(expireTime); + String unExpireFile = getBoardReportFileId(currentTime); + String expireFile = getBoardReportFileId(expireTime); asyncReportRequestHandler.putReport(unExpireFile, ReportResponse.builder().build()); asyncReportRequestHandler.putReport(expireFile, ReportResponse.builder().build()); @@ -79,4 +83,8 @@ void shouldThrowGenerateReportExceptionGivenFileNameInvalidWhenHandlerPutData() () -> asyncReportRequestHandler.putReport("../", ReportResponse.builder().build())); } + private String getBoardReportFileId(String timestamp) { + return "board-" + START_TIME + "-" + END_TIME + "-" + timestamp; + } + } diff --git a/backend/src/test/java/heartbeat/service/report/GenerateReporterServiceTest.java b/backend/src/test/java/heartbeat/service/report/GenerateReporterServiceTest.java index f6786fdca1..d3f3083cf6 100644 --- a/backend/src/test/java/heartbeat/service/report/GenerateReporterServiceTest.java +++ b/backend/src/test/java/heartbeat/service/report/GenerateReporterServiceTest.java @@ -957,7 +957,7 @@ void shouldNotDeleteOldCsvWhenExportCsvWithoutOldCsvInsideThirtyMinutes() throws @Test void shouldDeleteFailWhenDeleteCSV() { File mockFile = mock(File.class); - when(mockFile.getName()).thenReturn("file1-1683734399999.CSV"); + when(mockFile.getName()).thenReturn("file1-20240417-20240418-1683734399999.CSV"); when(mockFile.delete()).thenReturn(false); File[] mockFiles = new File[] { mockFile }; File directory = mock(File.class); @@ -985,7 +985,7 @@ void shouldThrowExceptionWhenDeleteCSV() { @Test void shouldDeleteFailWhenDeleteFile() { File mockFile = mock(File.class); - when(mockFile.getName()).thenReturn("board-1683734399999"); + when(mockFile.getName()).thenReturn("board-20240417-20240418-1683734399999"); when(mockFile.delete()).thenReturn(false); when(mockFile.exists()).thenReturn(true); File[] mockFiles = new File[] { mockFile }; @@ -1000,7 +1000,7 @@ void shouldDeleteFailWhenDeleteFile() { @Test void shouldDeleteTempFailWhenDeleteFile() { File mockFile = mock(File.class); - when(mockFile.getName()).thenReturn("board-1683734399999.tmp"); + when(mockFile.getName()).thenReturn("board-20240417-20240418-1683734399999.tmp"); when(mockFile.delete()).thenReturn(true); when(mockFile.exists()).thenReturn(false); File[] mockFiles = new File[] { mockFile }; diff --git a/frontend/__tests__/containers/MetricsStep/DeploymentFrequencySettings/PipelineMetricSelection.test.tsx b/frontend/__tests__/containers/MetricsStep/DeploymentFrequencySettings/PipelineMetricSelection.test.tsx index b93549b9d9..ea2b3b4a45 100644 --- a/frontend/__tests__/containers/MetricsStep/DeploymentFrequencySettings/PipelineMetricSelection.test.tsx +++ b/frontend/__tests__/containers/MetricsStep/DeploymentFrequencySettings/PipelineMetricSelection.test.tsx @@ -241,7 +241,7 @@ describe('PipelineMetricSelection', () => { }); await waitFor(() => { - expect(mockHandleClickRemoveButton).toHaveBeenCalledTimes(2); + expect(mockHandleClickRemoveButton).toHaveBeenCalledTimes(0); }); }); diff --git a/frontend/__tests__/containers/ReportButtonGroup.test.tsx b/frontend/__tests__/containers/ReportButtonGroup.test.tsx index 2e01a9fc53..baaeb428c4 100644 --- a/frontend/__tests__/containers/ReportButtonGroup.test.tsx +++ b/frontend/__tests__/containers/ReportButtonGroup.test.tsx @@ -1,31 +1,95 @@ -import { EXPORT_METRIC_DATA, MOCK_REPORT_RESPONSE } from '../fixtures'; +import { EXPORT_BOARD_DATA, EXPORT_METRIC_DATA, EXPORT_PIPELINE_DATA } from '../fixtures'; import { ReportButtonGroup } from '@src/containers/ReportButtonGroup'; +import { DateRangeRequestResult } from '@src/containers/ReportStep'; +import { render, renderHook, screen } from '@testing-library/react'; +import { useExportCsvEffect } from '@src/hooks/useExportCsvEffect'; import { setupStore } from '@test/utils/setupStoreUtil'; -import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Provider } from 'react-redux'; -describe('test', () => { +jest.mock('@src/hooks/useExportCsvEffect', () => ({ + useExportCsvEffect: jest.fn().mockReturnValue({ + fetchExportData: jest.fn(), + isExpired: false, + }), +})); + +describe('ReportButtonGroup', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + const mockHandler = jest.fn(); - const mockData = { - ...MOCK_REPORT_RESPONSE, - exportValidityTime: 30, - reportMetricsError: { - boardMetricsError: { - status: 401, - message: 'Unauthorized', - }, - pipelineMetricsError: { - status: 401, - message: 'Unauthorized', - }, - sourceControlMetricsError: { - status: 401, - message: 'Unauthorized', - }, + const buttonNames = [EXPORT_METRIC_DATA, EXPORT_BOARD_DATA, EXPORT_PIPELINE_DATA]; + const basicMockError = { + boardMetricsError: { + status: 500, + message: 'mockError', + }, + pipelineMetricsError: { + status: 500, + message: 'mockError', + }, + sourceControlMetricsError: { + status: 500, + message: 'mockError', }, }; + const nullMockError = { + boardMetricsError: null, + pipelineMetricsError: null, + sourceControlMetricsError: null, + }; + + const firstBasicMockDateRangeRequestResult = { + startDate: '2024-01-01T00:00:00.000+08:00', + endDate: '2024-01-14T23:59:59.000+08:00', + overallMetricsCompleted: true, + boardMetricsCompleted: true, + doraMetricsCompleted: true, + reportMetricsError: nullMockError, + }; + const secondBasicMockDateRangeRequestResult = { + startDate: '2024-01-15T00:00:00.000+08:00', + endDate: '2024-01-31T23:59:59.000+08:00', + overallMetricsCompleted: true, + boardMetricsCompleted: true, + doraMetricsCompleted: true, + reportMetricsError: nullMockError, + }; + + const successMockData: DateRangeRequestResult[] = [ + firstBasicMockDateRangeRequestResult, + secondBasicMockDateRangeRequestResult, + ]; + const pendingMockData: DateRangeRequestResult[] = [ + firstBasicMockDateRangeRequestResult, + { + ...secondBasicMockDateRangeRequestResult, + overallMetricsCompleted: false, + boardMetricsCompleted: false, + doraMetricsCompleted: false, + }, + ]; + const partialFailedMockData: DateRangeRequestResult[] = [ + firstBasicMockDateRangeRequestResult, + { + ...secondBasicMockDateRangeRequestResult, + reportMetricsError: basicMockError, + }, + ]; + const allFailedMockData: DateRangeRequestResult[] = [ + { + ...firstBasicMockDateRangeRequestResult, + reportMetricsError: basicMockError, + }, + { + ...secondBasicMockDateRangeRequestResult, + reportMetricsError: basicMockError, + }, + ]; - const setup = () => { + const setup = (dateRangeRequestResults: DateRangeRequestResult[]) => { const store = setupStore(); render( @@ -36,18 +100,108 @@ describe('test', () => { isShowExportPipelineButton={true} handleBack={mockHandler} handleSave={mockHandler} - reportData={mockData} - startDate={''} - endDate={''} - csvTimeStamp={1239013} + csvTimeStamp={1715250961572} + dateRangeRequestResults={dateRangeRequestResults} /> , ); }; - it('test', () => { - setup(); + it('should all buttons be clickable given the request successfully finishes', () => { + setup(successMockData); + + expect(screen.getByRole('button', { name: EXPORT_METRIC_DATA })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: EXPORT_BOARD_DATA })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: EXPORT_PIPELINE_DATA })).not.toBeDisabled(); + }); + + it.each(buttonNames)( + 'should be able to export all the overall metrics CSV files when clicking the %s button given the request successfully finishes', + async (buttonName) => { + setup(successMockData); + const exportButton = screen.getByRole('button', { name: buttonName }); + expect(exportButton).not.toBeDisabled(); + + await userEvent.click(exportButton); + + expect(screen.getByText('2024/01/01 - 2024/01/14')).toBeInTheDocument(); + expect(screen.getByText('2024/01/15 - 2024/01/31')).toBeInTheDocument(); + expect(screen.getAllByRole('checkbox')[0]).not.toBeDisabled(); + expect(screen.getAllByRole('checkbox')[1]).not.toBeDisabled(); + }, + ); + + it('should export data buttons be not clickable given the CSV file for one of the dataRanges is still in the process of generating.', () => { + setup(pendingMockData); + + expect(screen.getByRole('button', { name: EXPORT_METRIC_DATA })).toBeDisabled(); + expect(screen.getByRole('button', { name: EXPORT_BOARD_DATA })).toBeDisabled(); + expect(screen.getByRole('button', { name: EXPORT_PIPELINE_DATA })).toBeDisabled(); + }); + + it.each(buttonNames)( + 'should not be able to export the CSV file when clicking the %s button given an error occurs for the dataRanges', + async (buttonName) => { + setup(partialFailedMockData); + const exportButton = screen.getByRole('button', { name: buttonName }); + expect(exportButton).not.toBeDisabled(); + + await userEvent.click(exportButton); + + expect(screen.getByText('2024/01/15 - 2024/01/31')).toBeInTheDocument(); + const checkbox = screen.getAllByRole('checkbox')[1]; + expect(checkbox).toBeDisabled(); + }, + ); + + it.each(buttonNames)( + 'should not open download dialog when clicking the %s button given only setting one dataRange', + async (buttonName) => { + setup([firstBasicMockDateRangeRequestResult]); + const exportButton = screen.getByRole('button', { name: buttonName }); + expect(exportButton).not.toBeDisabled(); + + await userEvent.click(exportButton); + + expect(screen.queryByText('Select the time period for the exporting data')).not.toBeInTheDocument(); + }, + ); + + it('should close download dialog when clicking the close button given the download dialog is open', async () => { + setup(successMockData); + const exportMetricDataButton = screen.getByRole('button', { name: EXPORT_METRIC_DATA }); + expect(exportMetricDataButton).not.toBeDisabled(); + await userEvent.click(exportMetricDataButton); + expect(screen.getByText('Select the time period for the exporting data')).toBeInTheDocument(); + + const closeButton = screen.getByTestId('CloseIcon'); + await userEvent.click(closeButton); + + expect(screen.queryByText('Select the time period for the exporting data')).not.toBeInTheDocument(); + }); + + it('should close download dialog and download csv file when clicking the confirm button given the download dialog is open and one of the dataRanges is checked', async () => { + const { result } = renderHook(() => useExportCsvEffect()); + setup(successMockData); + const exportMetricDataButton = screen.getByRole('button', { name: EXPORT_METRIC_DATA }); + expect(exportMetricDataButton).not.toBeDisabled(); + await userEvent.click(exportMetricDataButton); + expect(screen.getByText('Select the time period for the exporting data')).toBeInTheDocument(); + const checkbox = screen.getAllByRole('checkbox')[0]; + expect(checkbox).not.toBeDisabled(); + await userEvent.click(checkbox); + + await userEvent.click(screen.getByText('Confirm')); + + expect(screen.queryByText('Select the time period for the exporting data')).not.toBeInTheDocument(); + expect(result.current.fetchExportData).toBeCalledTimes(1); + }); + + it(`should not be able to click the export buttons when all dataRanges encounter errors`, async () => { + setup(allFailedMockData); - expect(screen.queryByRole('button', { name: EXPORT_METRIC_DATA })).toBeDisabled(); + expect(screen.getByRole('button', { name: EXPORT_METRIC_DATA })).toBeDisabled(); + expect(screen.getByRole('button', { name: EXPORT_BOARD_DATA })).toBeDisabled(); + expect(screen.getByRole('button', { name: EXPORT_PIPELINE_DATA })).toBeDisabled(); }); }); diff --git a/frontend/__tests__/containers/ReportStep/DownloadDialog.test.tsx b/frontend/__tests__/containers/ReportStep/DownloadDialog.test.tsx new file mode 100644 index 0000000000..1a37edc3f6 --- /dev/null +++ b/frontend/__tests__/containers/ReportStep/DownloadDialog.test.tsx @@ -0,0 +1,106 @@ +import { DateRangeItem, DownloadDialog } from '@src/containers/ReportStep/DownloadDialog'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +describe('DownloadDialog', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const handleCloseFn = jest.fn(); + const downloadCSVFileFn = jest.fn(); + const mockData = [ + { + startDate: '2024-01-01', + endDate: '2024-01-14', + disabled: false, + }, + { + startDate: '2024-01-15', + endDate: '2024-01-31', + disabled: false, + }, + ]; + + const setup = (dateRangeList: DateRangeItem[]) => { + render( + , + ); + }; + + it('should show all dateRanges in DownloadDialog', () => { + setup(mockData); + + expect(screen.getByText('2024/01/01 - 2024/01/14')).toBeInTheDocument(); + expect(screen.getByText('2024/01/15 - 2024/01/31')).toBeInTheDocument(); + expect(screen.getAllByRole('checkbox')[0]).not.toBeDisabled(); + expect(screen.getAllByRole('checkbox')[1]).not.toBeDisabled(); + expect(screen.getByText('Confirm')).toBeDisabled(); + }); + + it('should not be clickable given there is an error fetching data for this dataRange', () => { + const mockDataWithDisabledDateRange = [ + ...mockData, + { + startDate: '2024-02-01', + endDate: '2024-02-14', + disabled: true, + }, + ]; + setup(mockDataWithDisabledDateRange); + const checkbox = screen.getAllByRole('checkbox')[2]; + + expect(checkbox).toBeDisabled(); + }); + + it('should confirm button be clickable when choosing one dateRange', async () => { + setup(mockData); + const checkbox = screen.getAllByRole('checkbox')[0]; + expect(checkbox).not.toBeDisabled(); + expect(screen.getByText('Confirm')).toBeDisabled(); + + await userEvent.click(checkbox); + + expect(screen.getByText('Confirm')).not.toBeDisabled(); + }); + + it('should close download dialog when clicking the close button', async () => { + setup(mockData); + + const closeButton = screen.getByTestId('CloseIcon'); + await userEvent.click(closeButton); + + expect(handleCloseFn).toBeCalledTimes(1); + }); + + it('should close download dialog and download csv file when clicking the confirm button given that a dataRange is checked', async () => { + setup(mockData); + const checkbox = screen.getAllByRole('checkbox')[0]; + expect(checkbox).not.toBeDisabled(); + await userEvent.click(checkbox); + const confirmButton = screen.getByText('Confirm'); + expect(confirmButton).not.toBeDisabled(); + + await userEvent.click(confirmButton); + + expect(handleCloseFn).toBeCalledTimes(1); + expect(downloadCSVFileFn).toBeCalledTimes(1); + }); + + it('should not check the dataRange when clicking on the checkbox given that the dataRange is already checked', async () => { + setup(mockData); + const checkbox = screen.getAllByRole('checkbox')[0]; + expect(checkbox).not.toBeDisabled(); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + await userEvent.click(checkbox); + + expect(checkbox).not.toBeChecked(); + }); +}); diff --git a/frontend/__tests__/context/metricsSlice.test.ts b/frontend/__tests__/context/metricsSlice.test.ts index 7ad58523ba..443f87d511 100644 --- a/frontend/__tests__/context/metricsSlice.test.ts +++ b/frontend/__tests__/context/metricsSlice.test.ts @@ -593,6 +593,12 @@ describe('saveMetricsSetting reducer', () => { expect(savedPipelineCrews.pipelineCrews).toBe(crews); }); + it('should return empty array given crews is undefined', () => { + const savedPipelineCrews = saveMetricsSettingReducer(initState, savePipelineCrews(undefined)); + + expect(savedPipelineCrews.pipelineCrews).toEqual([]); + }); + it('should update ShouldRetryPipelineConfig', async () => { store.dispatch(updateShouldRetryPipelineConfig(true)); expect(selectShouldRetryPipelineConfig(store.getState())).toEqual(true); diff --git a/frontend/__tests__/utils/Util.test.tsx b/frontend/__tests__/utils/Util.test.tsx index bd250764e2..6d08f05895 100644 --- a/frontend/__tests__/utils/Util.test.tsx +++ b/frontend/__tests__/utils/Util.test.tsx @@ -14,10 +14,12 @@ import { sortDateRanges, sortDisabledOptions, transformToCleanedBuildKiteEmoji, + updateResponseCrews, } from '@src/utils/util'; import { CleanedBuildKiteEmoji, OriginBuildKiteEmoji } from '@src/constants/emojis/emoji'; import { CYCLE_TIME_SETTINGS_TYPES, METRICS_CONSTANTS } from '@src/constants/resources'; import { ICycleTimeSetting, IPipelineConfig } from '@src/context/Metrics/metricsSlice'; +import { IPipeline } from '@src/context/config/pipelineTool/verifyResponseSlice'; import { BoardInfoResponse } from '@src/hooks/useGetBoardInfo'; import { EMPTY_STRING } from '@src/constants/commons'; import { PIPELINE_TOOL_TYPES } from '../fixtures'; @@ -570,3 +572,36 @@ describe('combineBoardInfo function', () => { expect(combineBoardData).toStrictEqual(expectResults); }); }); + +describe('updateResponseCrews function', () => { + const mockData = { + id: '0', + name: 'pipelineName', + orgId: '', + orgName: 'orgName', + repository: '', + steps: [] as string[], + branches: [] as string[], + crews: ['a', 'b', 'c'], + } as IPipeline; + it('should update crews when pipelineName and org both matched', () => { + const expectData = [ + { + ...mockData, + crews: [], + }, + ]; + const result = updateResponseCrews('orgName', 'pipelineName', [mockData]); + expect(result).toEqual(expectData); + }); + + it('should not update crews when pipelineName or org not matched', () => { + const expectData = [ + { + ...mockData, + }, + ]; + const result = updateResponseCrews('xxx', 'xxx', [mockData]); + expect(result).toEqual(expectData); + }); +}); diff --git a/frontend/e2e/pages/metrics/metrics-step.ts b/frontend/e2e/pages/metrics/metrics-step.ts index 7969a4978d..e8836a8277 100644 --- a/frontend/e2e/pages/metrics/metrics-step.ts +++ b/frontend/e2e/pages/metrics/metrics-step.ts @@ -582,13 +582,27 @@ export class MetricsStep { .getByLabel('Open') .nth(1) .click(); - await expect(this.page.getByRole('option', { name: firstPipelineConfig.pipelineName })).not.toBeEnabled(); } - async RemoveFirstNewPipeline() { - const pipelineList = this.pipelineSettingSection.getByText('Organization *Pipeline Name *Remove'); + async addNewPipelineAndSelectOrgAndName() { + await this.pipelineNewPipelineButton.click(); + await this.pipelineSettingSection.getByText('Organization *Remove').getByLabel('Open').click(); + await this.page.getByRole('option', { name: 'Thoughtworks-Heartbeat' }).click(); + await this.pipelineSettingSection + .getByText('Organization *Pipeline Name *Remove') + .getByLabel('Open') + .nth(1) + .click(); + await this.page.getByRole('option', { name: 'Heartbeat-E2E' }).click(); + } + + async checkPipelineLength(length: number) { + const pipelineLength = await this.pipelineSettingSection.getByText('Organization *').count(); + expect(pipelineLength).toEqual(length); + } - await pipelineList.nth(0).getByRole('button', { name: 'remove' }).click(); + async removePipeline(index: number) { + await this.pipelineSettingSection.getByText('Remove').nth(index).click(); } async checkPipelineFillNoStep(pipelineSettings: typeof metricsStepData.deployment) { diff --git a/frontend/e2e/specs/side-path/unhappy-path.spec.ts b/frontend/e2e/specs/side-path/unhappy-path.spec.ts index 68f0789a8d..ba00a2d2b3 100644 --- a/frontend/e2e/specs/side-path/unhappy-path.spec.ts +++ b/frontend/e2e/specs/side-path/unhappy-path.spec.ts @@ -56,6 +56,9 @@ test('unhappy path when import file', async ({ homePage, configStep, metricsStep await configStep.goToMetrics(); await metricsStep.checkBoardNoCard(); + await metricsStep.addNewPipelineAndSelectOrgAndName(); + await metricsStep.checkPipelineLength(2); + await metricsStep.removePipeline(1); await metricsStep.checkPipelineFillNoStep(importUnhappyPathProjectFromFile.deployment); await metricsStep.goToPreviousStep(); await configStep.typeInDateRange(dateRange); @@ -72,7 +75,7 @@ test('unhappy path when import file', async ({ homePage, configStep, metricsStep await metricsStep.selectCrews(modifiedCorrectProjectFromFile.crews); await metricsStep.deselectBranch(modifiedCorrectProjectFromFile.deletedBranch); await metricsStep.addNewPipelineAndSelectSamePipeline(importUnhappyPathProjectFromFile.deployment); - await metricsStep.RemoveFirstNewPipeline(); + await metricsStep.removePipeline(1); await metricsStep.selectDoneHeartbeatState(ModifiedhbStateData[6]); await metricsStep.validateNextButtonNotClickable(); await metricsStep.selectDoneHeartbeatState(hbStateData[6]); diff --git a/frontend/src/clients/pipeline/dto/response.ts b/frontend/src/clients/pipeline/dto/response.ts index f15061508c..8125bda16a 100644 --- a/frontend/src/clients/pipeline/dto/response.ts +++ b/frontend/src/clients/pipeline/dto/response.ts @@ -1,5 +1,5 @@ -import { pipeline } from '@src/context/config/pipelineTool/verifyResponseSlice'; +import { IPipeline } from '@src/context/config/pipelineTool/verifyResponseSlice'; export interface IPipelineInfoResponseDTO { - pipelineList: pipeline[]; + pipelineList: IPipeline[]; } diff --git a/frontend/src/constants/resources.ts b/frontend/src/constants/resources.ts index 064512f7fc..7a855282cb 100644 --- a/frontend/src/constants/resources.ts +++ b/frontend/src/constants/resources.ts @@ -443,4 +443,5 @@ export enum SORTING_DATE_RANGE_TEXT { DESCENDING = 'Descending', } -export const BLOCK_COLUMN_NAME = ['BLOCKED', 'BLOCK']; +export const DISABLED_DATE_RANGE_MESSAGE = + 'Unavailable time period indicates that report generation during this period has failed.'; diff --git a/frontend/src/containers/MetricsStep/DeploymentFrequencySettings/PipelineMetricSelection/index.tsx b/frontend/src/containers/MetricsStep/DeploymentFrequencySettings/PipelineMetricSelection/index.tsx index cba67b5d90..0b0614cf0e 100644 --- a/frontend/src/containers/MetricsStep/DeploymentFrequencySettings/PipelineMetricSelection/index.tsx +++ b/frontend/src/containers/MetricsStep/DeploymentFrequencySettings/PipelineMetricSelection/index.tsx @@ -110,24 +110,20 @@ export const PipelineMetricSelection = ({ ); setLoadingCompletedNumber((value) => Math.max(value - 1, 0)); getSteps(params, organizationId, buildId, pipelineType, token).then((res) => { - if (res && !res.haveStep) { - isShowRemoveButton && handleRemoveClick(); - } else { - const steps = res?.response ?? []; - const branches = res?.branches ?? []; - const pipelineCrews = res?.pipelineCrews ?? []; - dispatch( - updatePipelineToolVerifyResponseSteps({ - organization, - pipelineName: _pipelineName, - steps, - branches, - pipelineCrews, - }), - ); - res?.haveStep && dispatch(updatePipelineStep({ steps, id, type, branches, pipelineCrews })); - dispatch(updateShouldGetPipelineConfig(false)); - } + const steps = res?.response ?? []; + const branches = res?.branches ?? []; + const pipelineCrews = res?.pipelineCrews ?? []; + dispatch( + updatePipelineToolVerifyResponseSteps({ + organization, + pipelineName: _pipelineName, + steps, + branches, + pipelineCrews, + }), + ); + res?.haveStep && dispatch(updatePipelineStep({ steps, id, type, branches, pipelineCrews })); + dispatch(updateShouldGetPipelineConfig(false)); res && setIsShowNoStepWarning(!res.haveStep); }); }; diff --git a/frontend/src/containers/ReportButtonGroup/index.tsx b/frontend/src/containers/ReportButtonGroup/index.tsx index b635b18dbc..088e24570d 100644 --- a/frontend/src/containers/ReportButtonGroup/index.tsx +++ b/frontend/src/containers/ReportButtonGroup/index.tsx @@ -1,42 +1,81 @@ import { StyledButtonGroup, StyledExportButton, StyledRightButtonGroup } from '@src/containers/ReportButtonGroup/style'; +import { DateRangeItem, DownloadDialog } from '@src/containers/ReportStep/DownloadDialog'; import { BackButton, SaveButton } from '@src/containers/MetricsStepper/style'; import { ExpiredDialog } from '@src/containers/ReportStep/ExpiredDialog'; import { CSVReportRequestDTO } from '@src/clients/report/dto/request'; import { COMMON_BUTTONS, REPORT_TYPES } from '@src/constants/commons'; -import { ReportResponseDTO } from '@src/clients/report/dto/response'; +import { AllErrorResponse } from '@src/clients/report/dto/response'; +import { DateRangeRequestResult } from '@src/containers/ReportStep'; import { useExportCsvEffect } from '@src/hooks/useExportCsvEffect'; import SaveAltIcon from '@mui/icons-material/SaveAlt'; import { TIPS } from '@src/constants/resources'; import { Tooltip } from '@mui/material'; -import React from 'react'; +import React, { useState } from 'react'; interface ReportButtonGroupProps { handleSave?: () => void; handleBack: () => void; csvTimeStamp: number; - startDate: string; - endDate: string; - reportData: ReportResponseDTO | undefined; isShowSave: boolean; isShowExportBoardButton: boolean; isShowExportPipelineButton: boolean; isShowExportMetrics: boolean; + dateRangeRequestResults: DateRangeRequestResult[]; } export const ReportButtonGroup = ({ handleSave, handleBack, csvTimeStamp, - startDate, - endDate, - reportData, isShowSave, isShowExportMetrics, isShowExportBoardButton, isShowExportPipelineButton, + dateRangeRequestResults, }: ReportButtonGroupProps) => { + const [isShowDialog, setIsShowDialog] = useState(false); + const [downloadReportList, setDownloadReportList] = useState([]); + const [dataType, setDataType] = useState(null); const { fetchExportData, isExpired } = useExportCsvEffect(); + const isReportHasError = (reportMetricsError: AllErrorResponse) => { + return ( + !!reportMetricsError.boardMetricsError || + !!reportMetricsError.pipelineMetricsError || + !!reportMetricsError.sourceControlMetricsError + ); + }; + + const isReportHasDoraError = (reportMetricsError: AllErrorResponse) => { + return !!reportMetricsError.pipelineMetricsError || !!reportMetricsError.sourceControlMetricsError; + }; + + const overallMetricsResults = dateRangeRequestResults.map((item) => ({ + startDate: item.startDate, + endDate: item.endDate, + disabled: !(item.overallMetricsCompleted && !isReportHasError(item.reportMetricsError)), + })); + const boardMetricsResults = dateRangeRequestResults.map((item) => ({ + startDate: item.startDate, + endDate: item.endDate, + disabled: !(item.boardMetricsCompleted && !item.reportMetricsError.boardMetricsError), + })); + const pipelineMetricsResults = dateRangeRequestResults.map((item) => ({ + startDate: item.startDate, + endDate: item.endDate, + disabled: !(item.doraMetricsCompleted && !isReportHasDoraError(item.reportMetricsError)), + })); + + const isExportMetricsButtonClickable = + dateRangeRequestResults.every(({ overallMetricsCompleted }) => overallMetricsCompleted) && + overallMetricsResults.some(({ disabled }) => !disabled); + const isExportBoardButtonClickable = + dateRangeRequestResults.every(({ boardMetricsCompleted }) => boardMetricsCompleted) && + boardMetricsResults.some(({ disabled }) => !disabled); + const isExportPipelineButtonClickable = + dateRangeRequestResults.every(({ doraMetricsCompleted }) => doraMetricsCompleted) && + pipelineMetricsResults.some(({ disabled }) => !disabled); + const exportCSV = (dataType: REPORT_TYPES, startDate: string, endDate: string): CSVReportRequestDTO => ({ dataType: dataType, csvTimeStamp: csvTimeStamp, @@ -44,23 +83,31 @@ export const ReportButtonGroup = ({ endDate: endDate, }); - const handleDownload = (dataType: REPORT_TYPES, startDate: string, endDate: string) => { - fetchExportData(exportCSV(dataType, startDate, endDate)); + const handleDownload = (dateRange: DateRangeItem[], dataType: REPORT_TYPES) => { + if (dateRange.length > 1) { + setDownloadReportList(dateRange); + setDataType(dataType); + setIsShowDialog(true); + } else { + fetchExportData(exportCSV(dataType, dateRange[0].startDate, dateRange[0].endDate)); + } }; - const pipelineButtonDisabled = - !reportData || - reportData.doraMetricsCompleted === false || - reportData?.reportMetricsError?.pipelineMetricsError || - reportData?.reportMetricsError?.sourceControlMetricsError; - - const isReportHasError = - !!reportData?.reportMetricsError.boardMetricsError || - !!reportData?.reportMetricsError.pipelineMetricsError || - !!reportData?.reportMetricsError.sourceControlMetricsError; + const handleCloseDialog = () => { + setIsShowDialog(false); + setDataType(null); + }; return ( <> + {dataType && ( + fetchExportData(exportCSV(dataType, startDate, endDate))} + /> + )} {isShowSave && ( @@ -75,24 +122,24 @@ export const ReportButtonGroup = ({ {isShowExportMetrics && ( handleDownload(REPORT_TYPES.METRICS, startDate, endDate)} + disabled={!isExportMetricsButtonClickable} + onClick={() => handleDownload(overallMetricsResults, REPORT_TYPES.METRICS)} > {COMMON_BUTTONS.EXPORT_METRIC_DATA} )} {isShowExportBoardButton && ( handleDownload(REPORT_TYPES.BOARD, startDate, endDate)} + disabled={!isExportBoardButtonClickable} + onClick={() => handleDownload(boardMetricsResults, REPORT_TYPES.BOARD)} > {COMMON_BUTTONS.EXPORT_BOARD_DATA} )} {isShowExportPipelineButton && ( handleDownload(REPORT_TYPES.PIPELINE, startDate, endDate)} + disabled={!isExportPipelineButtonClickable} + onClick={() => handleDownload(pipelineMetricsResults, REPORT_TYPES.PIPELINE)} > {COMMON_BUTTONS.EXPORT_PIPELINE_DATA} diff --git a/frontend/src/containers/ReportStep/DownloadDialog/index.tsx b/frontend/src/containers/ReportStep/DownloadDialog/index.tsx new file mode 100644 index 0000000000..1a52e9eabe --- /dev/null +++ b/frontend/src/containers/ReportStep/DownloadDialog/index.tsx @@ -0,0 +1,100 @@ +import { + CloseButton, + DialogContainer, + StyledButton, + StyledCalendarToday, + StyledDialogContent, + StyledDialogTitle, + StyledFormControlLabel, + StyledFormGroup, + TimePeriodSelectionMessage, + tooltipModifiers, +} from '@src/containers/ReportStep/DownloadDialog/style'; +import { DISABLED_DATE_RANGE_MESSAGE } from '@src/constants/resources'; +import { Checkbox, Dialog, Tooltip } from '@mui/material'; +import { COMMON_BUTTONS } from '@src/constants/commons'; +import { formatDate } from '@src/utils/util'; +import React, { useState } from 'react'; + +interface DownloadDialogProps { + isShowDialog: boolean; + handleClose: () => void; + dateRangeList: DateRangeItem[]; + downloadCSVFile: (startDate: string, endDate: string) => void; +} + +export interface DateRangeItem { + startDate: string; + endDate: string; + disabled: boolean; +} + +export const DownloadDialog = ({ isShowDialog, handleClose, dateRangeList, downloadCSVFile }: DownloadDialogProps) => { + const [selectedRangeItems, setSelectedRangeItems] = useState([]); + const confirmButtonDisabled = selectedRangeItems.length === 0; + + const handleChange = (targetItem: DateRangeItem) => { + if (selectedRangeItems.includes(targetItem)) { + setSelectedRangeItems(selectedRangeItems.filter((item) => targetItem !== item)); + } else { + setSelectedRangeItems([...selectedRangeItems, targetItem]); + } + }; + + const handleDownload = () => { + selectedRangeItems.forEach((item) => { + downloadCSVFile(item.startDate, item.endDate); + }); + handleClose(); + }; + + const getLabel = (item: DateRangeItem) => { + if (item.disabled) { + return ( + + {`${formatDate(item.startDate)} - ${formatDate(item.endDate)}`} + + ); + } else { + return `${formatDate(item.startDate)} - ${formatDate(item.endDate)}`; + } + }; + + return ( + + + + Export Board Data + + + + + + Select the time period for the exporting data + + + {dateRangeList.map((item) => ( + handleChange(item)} />} + label={getLabel(item)} + checked={selectedRangeItems.includes(item)} + disabled={item.disabled} + /> + ))} + + + {COMMON_BUTTONS.CONFIRM} + + + + + ); +}; diff --git a/frontend/src/containers/ReportStep/DownloadDialog/style.tsx b/frontend/src/containers/ReportStep/DownloadDialog/style.tsx new file mode 100644 index 0000000000..9b2311d401 --- /dev/null +++ b/frontend/src/containers/ReportStep/DownloadDialog/style.tsx @@ -0,0 +1,80 @@ +import { Button, DialogContent, DialogTitle, FormControlLabel, FormGroup } from '@mui/material'; +import { CalendarToday } from '@mui/icons-material'; +import CloseIcon from '@mui/icons-material/Close'; +import { styled } from '@mui/material/styles'; +import { theme } from '@src/theme'; + +export const DialogContainer = styled('div')({ + width: '38rem', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'flex-start', +}); + +export const StyledDialogTitle = styled(DialogTitle)({ + boxSizing: 'border-box', + width: '100%', + padding: '1.5rem 2.5rem 1.5rem 2.5rem', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + fontSize: '1rem', +}); + +export const StyledDialogContent = styled(DialogContent)({ + boxSizing: 'border-box', + width: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + padding: '1.25rem 3.125rem 1.875rem 3.125rem', +}); + +export const StyledCalendarToday = styled(CalendarToday)({ + color: theme.palette.text.disabled, + marginRight: '0.75rem', + fontSize: '1.5rem', +}); + +export const TimePeriodSelectionMessage = styled('div')({ + width: '100%', + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'center', + fontSize: '1rem', +}); + +export const StyledFormGroup = styled(FormGroup)({ + margin: '2.5rem 0', +}); + +export const StyledButton = styled(Button)({ + alignSelf: 'flex-end', +}); + +export const tooltipModifiers = { + modifiers: [ + { + name: 'offset', + options: { + offset: [190, 0], + }, + }, + ], +}; + +export const StyledFormControlLabel = styled(FormControlLabel)(({ checked }) => ({ + width: '15.5rem', + border: `0.0625rem solid ${theme.main.boardColor}`, + margin: '0.375rem 0', + + ...(checked && { + background: theme.main.downloadListLabel.backgroundColor, + }), +})); + +export const CloseButton = styled(CloseIcon)({ + cursor: 'pointer', +}); diff --git a/frontend/src/containers/ReportStep/index.tsx b/frontend/src/containers/ReportStep/index.tsx index 72af57d453..8499b355b7 100644 --- a/frontend/src/containers/ReportStep/index.tsx +++ b/frontend/src/containers/ReportStep/index.tsx @@ -15,17 +15,18 @@ import { useGenerateReportEffect, } from '@src/hooks/useGenerateReportEffect'; import { - addNotification, - closeAllNotifications, - closeNotification, - Notification, -} from '@src/context/notification/NotificationSlice'; -import { + DateRange, isOnlySelectClassification, isSelectBoardMetrics, isSelectDoraMetrics, selectConfig, } from '@src/context/config/configSlice'; +import { + addNotification, + closeAllNotifications, + closeNotification, + Notification, +} from '@src/context/notification/NotificationSlice'; import { BOARD_METRICS, CALENDAR, @@ -35,11 +36,11 @@ import { REQUIRED_DATA, } from '@src/constants/resources'; import { IPipelineConfig, selectMetricsContent } from '@src/context/Metrics/metricsSlice'; +import { AllErrorResponse, ReportResponseDTO } from '@src/clients/report/dto/response'; import { backStep, selectTimeStamp } from '@src/context/stepper/StepperSlice'; import { StyledCalendarWrapper } from '@src/containers/ReportStep/style'; import { ReportButtonGroup } from '@src/containers/ReportButtonGroup'; import DateRangeViewer from '@src/components/Common/DateRangeViewer'; -import { ReportResponseDTO } from '@src/clients/report/dto/response'; import BoardMetrics from '@src/containers/ReportStep/BoardMetrics'; import DoraMetrics from '@src/containers/ReportStep/DoraMetrics'; import React, { useEffect, useMemo, useState } from 'react'; @@ -59,6 +60,15 @@ const timeoutNotificationMessages = { [TimeoutErrorKey[METRIC_TYPES.ALL]]: 'Report', }; +export interface DateRangeRequestResult { + startDate: string; + endDate: string; + overallMetricsCompleted: boolean | null; + boardMetricsCompleted: boolean | null; + doraMetricsCompleted: boolean | null; + reportMetricsError: AllErrorResponse; +} + const ReportStep = ({ handleSave }: ReportStepProps) => { const dispatch = useAppDispatch(); const configData = useAppSelector(selectConfig); @@ -111,6 +121,23 @@ const ReportStep = ({ handleSave }: ReportStepProps) => { const onlySelectClassification = useAppSelector(isOnlySelectClassification); const isSummaryPage = useMemo(() => pageType === REPORT_PAGE_TYPE.SUMMARY, [pageType]); + const mapDateResult = (descendingDateRanges: DateRange, reportInfos: IReportInfo[]) => + descendingDateRanges.map(({ startDate, endDate }) => { + const reportData = reportInfos.find((singleResult) => singleResult.id === startDate)?.reportData ?? null; + return { + startDate: startDate, + endDate: endDate, + overallMetricsCompleted: reportData?.overallMetricsCompleted ?? null, + boardMetricsCompleted: reportData?.boardMetricsCompleted ?? null, + doraMetricsCompleted: reportData?.doraMetricsCompleted ?? null, + reportMetricsError: reportData?.reportMetricsError ?? { + boardMetricsError: null, + pipelineMetricsError: null, + sourceControlMetricsError: null, + }, + } as DateRangeRequestResult; + }); + const getErrorMessage4Board = () => { if (currentDataInfo.reportData?.reportMetricsError.boardMetricsError) { return `Failed to get Jira info, status: ${currentDataInfo.reportData.reportMetricsError.boardMetricsError.status}`; @@ -460,10 +487,8 @@ const ReportStep = ({ handleSave }: ReportStepProps) => { isShowExportPipelineButton={isSummaryPage ? shouldShowDoraMetrics : pageType === REPORT_PAGE_TYPE.DORA} handleBack={() => handleBack()} handleSave={() => handleSave()} - reportData={currentDataInfo.reportData} - startDate={startDate} - endDate={endDate} csvTimeStamp={csvTimeStamp} + dateRangeRequestResults={mapDateResult(descendingDateRanges, reportInfos)} /> ); diff --git a/frontend/src/context/Metrics/metricsSlice.ts b/frontend/src/context/Metrics/metricsSlice.ts index dca1ce04d3..c5f95e0f17 100644 --- a/frontend/src/context/Metrics/metricsSlice.ts +++ b/frontend/src/context/Metrics/metricsSlice.ts @@ -6,7 +6,7 @@ import { METRICS_CONSTANTS, } from '@src/constants/resources'; import { convertCycleTimeSettings, getSortedAndDeduplicationBoardingMapping } from '@src/utils/util'; -import { pipeline } from '@src/context/config/pipelineTool/verifyResponseSlice'; +import { IPipeline } from '@src/context/config/pipelineTool/verifyResponseSlice'; import _, { omit, uniqWith, isEqual, intersection, concat } from 'lodash'; import { createSlice } from '@reduxjs/toolkit'; import camelCase from 'lodash.camelcase'; @@ -309,7 +309,7 @@ export const metricsSlice = createSlice({ state.users = action.payload; }, savePipelineCrews: (state, action) => { - state.pipelineCrews = action.payload; + state.pipelineCrews = action.payload || []; }, updateCycleTimeSettings: (state, action) => { state.cycleTimeSettings = action.payload; @@ -477,11 +477,11 @@ export const metricsSlice = createSlice({ if (pipelineCrews) { state.pipelineCrews = setPipelineCrews(isProjectCreated, pipelineCrews, state.pipelineCrews); } - const orgNames: Array = _.uniq(pipelineList.map((item: pipeline) => item.orgName)); + const orgNames: Array = _.uniq(pipelineList.map((item: IPipeline) => item.orgName)); const filteredPipelineNames = (organization: string) => pipelineList - .filter((pipeline: pipeline) => pipeline.orgName.toLowerCase() === organization.toLowerCase()) - .map((item: pipeline) => item.name); + .filter((pipeline: IPipeline) => pipeline.orgName.toLowerCase() === organization.toLowerCase()) + .map((item: IPipeline) => item.name); const uniqueResponse = (res: IPipelineConfig[]) => { let itemsOmitId = uniqWith( diff --git a/frontend/src/context/config/configSlice.ts b/frontend/src/context/config/configSlice.ts index dc2e5370db..511dc8626a 100644 --- a/frontend/src/context/config/configSlice.ts +++ b/frontend/src/context/config/configSlice.ts @@ -10,7 +10,7 @@ import { import { initialPipelineToolState, IPipelineToolState } from '@src/context/config/pipelineTool/pipelineToolSlice'; import { initialSourceControlState, ISourceControl } from '@src/context/config/sourceControl/sourceControlSlice'; import { IBoardState, initialBoardState } from '@src/context/config/board/boardSlice'; -import { pipeline } from '@src/context/config/pipelineTool/verifyResponseSlice'; +import { IPipeline } from '@src/context/config/pipelineTool/verifyResponseSlice'; import { uniqPipelineListCrews, updateResponseCrews } from '@src/utils/util'; import { SortType } from '@src/containers/ConfigStep/DateRangePicker/types'; import { createSlice } from '@reduxjs/toolkit'; @@ -171,7 +171,7 @@ export const configSlice = createSlice({ }, updatePipelineToolVerifyResponse: (state, action) => { const { pipelineList } = action.payload; - state.pipelineTool.verifiedResponse.pipelineList = pipelineList.map((pipeline: pipeline) => ({ + state.pipelineTool.verifiedResponse.pipelineList = pipelineList.map((pipeline: IPipeline) => ({ ...pipeline, steps: [], })); diff --git a/frontend/src/context/config/pipelineTool/verifyResponseSlice.ts b/frontend/src/context/config/pipelineTool/verifyResponseSlice.ts index 40bffe4887..30f288e765 100644 --- a/frontend/src/context/config/pipelineTool/verifyResponseSlice.ts +++ b/frontend/src/context/config/pipelineTool/verifyResponseSlice.ts @@ -1,8 +1,8 @@ export interface IPipelineToolVerifyResponse { - pipelineList: pipeline[]; + pipelineList: IPipeline[]; } -export interface pipeline { +export interface IPipeline { id: string; name: string; orgId: string; diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index 1a7eb7e20e..ccf93ea355 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -59,6 +59,9 @@ declare module '@mui/material/styles' { borderColor: string; }; }; + downloadListLabel: { + backgroundColor: string; + }; }; } @@ -143,6 +146,9 @@ export const theme = createTheme({ borderColor: '#939DDA', }, }, + downloadListLabel: { + backgroundColor: '#4350AF1A', + }, }, typography: { button: { diff --git a/frontend/src/utils/util.ts b/frontend/src/utils/util.ts index 98351fe3df..b523b79173 100644 --- a/frontend/src/utils/util.ts +++ b/frontend/src/utils/util.ts @@ -2,7 +2,7 @@ import { CYCLE_TIME_LIST, CYCLE_TIME_SETTINGS_TYPES, METRICS_CONSTANTS } from '@ import { CleanedBuildKiteEmoji, OriginBuildKiteEmoji } from '@src/constants/emojis/emoji'; import { ICycleTimeSetting, IPipelineConfig } from '@src/context/Metrics/metricsSlice'; import { ITargetFieldType } from '@src/components/Common/MultiAutoComplete/styles'; -import { pipeline } from '@src/context/config/pipelineTool/verifyResponseSlice'; +import { IPipeline } from '@src/context/config/pipelineTool/verifyResponseSlice'; import { includes, isEqual, sortBy, uniq, uniqBy } from 'lodash'; import { BoardInfoResponse } from '@src/hooks/useGetBoardInfo'; import { DATE_FORMAT_TEMPLATE } from '@src/constants/template'; @@ -159,7 +159,7 @@ export const convertCycleTimeSettings = ( return cycleTimeSettings?.map(({ status, value }: ICycleTimeSetting) => ({ [status]: value })); }; -export const updateResponseCrews = (organization: string, pipelineName: string, pipelineList: pipeline[]) => { +export const updateResponseCrews = (organization: string, pipelineName: string, pipelineList: IPipeline[]) => { return pipelineList.map((pipeline) => pipeline.name === pipelineName && pipeline.orgName === organization ? { @@ -170,7 +170,7 @@ export const updateResponseCrews = (organization: string, pipelineName: string, ); }; -export const uniqPipelineListCrews = (pipelineList: pipeline[]) => +export const uniqPipelineListCrews = (pipelineList: IPipeline[]) => uniq(pipelineList.flatMap(({ crews }) => crews)).filter((crew) => crew !== undefined); export function existBlockState(cycleTimeSettings: ICycleTimeSetting[]) {