From 0aa1e73fdbd3fa1313786869365f2acd48614e40 Mon Sep 17 00:00:00 2001 From: Thomas Lund Fagermyr <35408743+thomaslf97@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:04:16 +0100 Subject: [PATCH] Feat: excel export (#430) * feat: Added button to export all/selected models. Added API to export excel * chore: generate api --- src/api/generated/core/request.ts | 103 +++++++++--------- .../generated/services/DownloadsService.ts | 21 ++++ src/features/ModelTable/ModelTable.tsx | 37 ++++++- src/hooks/GlobalState.tsx | 12 ++ src/hooks/useFetchAnaloguesExcel.tsx | 43 ++++++++ src/pages/Browse/Browse.tsx | 38 ++++++- 6 files changed, 197 insertions(+), 57 deletions(-) create mode 100644 src/hooks/useFetchAnaloguesExcel.tsx diff --git a/src/api/generated/core/request.ts b/src/api/generated/core/request.ts index b018a07c..1142d432 100644 --- a/src/api/generated/core/request.ts +++ b/src/api/generated/core/request.ts @@ -2,6 +2,10 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + import { ApiError } from './ApiError'; import type { ApiRequestOptions } from './ApiRequestOptions'; import type { ApiResult } from './ApiResult'; @@ -38,6 +42,10 @@ export const isFormData = (value: any): value is FormData => { return value instanceof FormData; }; +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + export const base64 = (str: string): string => { try { return btoa(str); @@ -136,22 +144,24 @@ export const resolve = async (options: ApiRequestOptions, resolver?: T | Reso return resolver; }; -export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise => { +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => { const token = await resolve(options, config.TOKEN); const username = await resolve(options, config.USERNAME); const password = await resolve(options, config.PASSWORD); const additionalHeaders = await resolve(options, config.HEADERS); + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} const headers = Object.entries({ Accept: 'application/json', ...additionalHeaders, ...options.headers, + ...formHeaders, }) - .filter(([_, value]) => isDefined(value)) - .reduce((headers, [key, value]) => ({ - ...headers, - [key]: String(value), - }), {} as Record); + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record); if (isStringWithValue(token)) { headers['Authorization'] = `Bearer ${token}`; @@ -174,52 +184,53 @@ export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptio } } - return new Headers(headers); + return headers; }; export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body !== undefined) { - if (options.mediaType?.includes('/json')) { - return JSON.stringify(options.body) - } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { - return options.body; - } else { - return JSON.stringify(options.body); - } + if (options.body) { + return options.body; } return undefined; }; -export const sendRequest = async ( +export const sendRequest = async ( config: OpenAPIConfig, options: ApiRequestOptions, url: string, body: any, formData: FormData | undefined, - headers: Headers, - onCancel: OnCancel -): Promise => { - const controller = new AbortController(); - - const request: RequestInit = { + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, headers, - body: body ?? formData, + data: body ?? formData, method: options.method, - signal: controller.signal, + withCredentials: config.WITH_CREDENTIALS, + cancelToken: source.token, }; - if (config.WITH_CREDENTIALS) { - request.credentials = config.CREDENTIALS; - } - - onCancel(() => controller.abort()); + onCancel(() => source.cancel('The user aborted a request.')); - return await fetch(url, request); + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } }; -export const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => { +export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { if (responseHeader) { - const content = response.headers.get(responseHeader); + const content = response.headers[responseHeader]; if (isString(content)) { return content; } @@ -227,22 +238,9 @@ export const getResponseHeader = (response: Response, responseHeader?: string): return undefined; }; -export const getResponseBody = async (response: Response): Promise => { +export const getResponseBody = (response: AxiosResponse): any => { if (response.status !== 204) { - try { - const contentType = response.headers.get('Content-Type'); - if (contentType) { - const jsonTypes = ['application/json', 'application/problem+json'] - const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type)); - if (isJSON) { - return await response.json(); - } else { - return await response.text(); - } - } - } catch (error) { - console.error(error); - } + return response.data; } return undefined; }; @@ -285,25 +283,26 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): * Request method * @param config The OpenAPI configuration object * @param options The request options from the service + * @param axiosClient The axios client instance to use * @returns CancelablePromise * @throws ApiError */ -export const request = (config: OpenAPIConfig, options: ApiRequestOptions): CancelablePromise => { +export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { return new CancelablePromise(async (resolve, reject, onCancel) => { try { const url = getUrl(config, options); const formData = getFormData(options); const body = getRequestBody(options); - const headers = await getHeaders(config, options); + const headers = await getHeaders(config, options, formData); if (!onCancel.isCancelled) { - const response = await sendRequest(config, options, url, body, formData, headers, onCancel); - const responseBody = await getResponseBody(response); + const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); const responseHeader = getResponseHeader(response, options.responseHeader); const result: ApiResult = { url, - ok: response.ok, + ok: isSuccess(response.status), status: response.status, statusText: response.statusText, body: responseHeader ?? responseBody, diff --git a/src/api/generated/services/DownloadsService.ts b/src/api/generated/services/DownloadsService.ts index 6cf7f1ec..9a558c5a 100644 --- a/src/api/generated/services/DownloadsService.ts +++ b/src/api/generated/services/DownloadsService.ts @@ -31,4 +31,25 @@ export class DownloadsService { }); } + /** + * @param analogueModelIds + * @returns File Success + * @throws ApiError + */ + public static getApiDownloadsAnalogueModelsExcel( + analogueModelIds?: Array, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/downloads/analogue-models-excel', + query: { + 'AnalogueModelIds': analogueModelIds, + }, + errors: { + 403: `Forbidden`, + 404: `Not Found`, + }, + }); + } + } diff --git a/src/features/ModelTable/ModelTable.tsx b/src/features/ModelTable/ModelTable.tsx index 545a872b..4bc42423 100644 --- a/src/features/ModelTable/ModelTable.tsx +++ b/src/features/ModelTable/ModelTable.tsx @@ -1,8 +1,8 @@ /* eslint-disable no-empty-pattern */ /* eslint-disable max-lines-per-function */ -import { CSSProperties } from 'react'; +import { ChangeEvent, CSSProperties } from 'react'; import { useMsal } from '@azure/msal-react'; -import { Button } from '@equinor/eds-core-react'; +import { Button, Checkbox } from '@equinor/eds-core-react'; import { EdsDataGrid } from '@equinor/eds-data-grid-react'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; @@ -14,8 +14,11 @@ import { } from '../../api/generated'; import { useAccessToken } from '../../hooks/useAccessToken'; import * as Styled from './ModelTable.styled'; +import { usePepmContextStore } from '../../hooks/GlobalState'; export const ModelTable = () => { + const { addExportModel, deleteExportModel, exportModels } = + usePepmContextStore(); const { instance, accounts } = useMsal(); const token = useAccessToken(instance, accounts[0]); if (token) OpenAPI.TOKEN = token; @@ -83,6 +86,11 @@ export const ModelTable = () => { return status; }; + const addModelToExport = (checked: boolean, modelId: string) => { + if (checked) addExportModel(modelId); + else deleteExportModel(modelId); + }; + /* Make sure the header row in EdsDataGrid is vertically middle-aligned when filter icons are shown */ const headerStyle = (): CSSProperties => ({ verticalAlign: 'middle' }); @@ -102,6 +110,31 @@ export const ModelTable = () => { headerStyle={headerStyle} width="min(calc(100vw - 64px), 1400px)" columns={[ + { + accessorKey: 'analogueModelId', + enableColumnFilter: false, + header: function () { + {}; + }, + id: 'expand', + cell: ({ row }) => ( + + { + -1 + } + onChange={(e: ChangeEvent) => + addModelToExport( + e.target.checked, + row.original.analogueModelId, + ) + } + > + } + + ), + }, { accessorKey: 'name', header: 'Model name', id: 'name', size: 200 }, { id: 'outcrops', diff --git a/src/hooks/GlobalState.tsx b/src/hooks/GlobalState.tsx index d0f71cd9..3deab920 100644 --- a/src/hooks/GlobalState.tsx +++ b/src/hooks/GlobalState.tsx @@ -64,6 +64,7 @@ type IPepmContext = { computeSettings: ListComputeSettingsMethodDto[]; objectResults: GetObjectResultsDto[]; variogramResults: GetVariogramResultsDto[]; + exportModels: string[]; }; type IPepmContextActions = { @@ -94,6 +95,8 @@ type IPepmContextActions = { setVariogramResults: (variogramResults: GetVariogramResultsDto[]) => void; updateObjectResult: (objectResult: GetObjectResultsDto) => void; updateVariogramResult: (variogramResult: GetVariogramResultsDto) => void; + addExportModel: (modelId: string) => void; + deleteExportModel: (modelId: string) => void; }; export const usePepmContextStore = create()( @@ -111,6 +114,7 @@ export const usePepmContextStore = create()( computeSettings: [], objectResults: [], variogramResults: [], + exportModels: [], setAnalogueModel: (analogueModel: AnalogueModelDetail) => set((state) => { state.analogueModel = analogueModel; @@ -247,5 +251,13 @@ export const usePepmContextStore = create()( : variogramResult, ); }), + addExportModel: (modelId: string) => + set((state) => { + state.exportModels.push(modelId); + }), + deleteExportModel: (modelId: string) => + set((state) => { + state.exportModels = state.exportModels.filter((id) => id !== modelId); + }), })), ); diff --git a/src/hooks/useFetchAnaloguesExcel.tsx b/src/hooks/useFetchAnaloguesExcel.tsx new file mode 100644 index 00000000..e06f9056 --- /dev/null +++ b/src/hooks/useFetchAnaloguesExcel.tsx @@ -0,0 +1,43 @@ +import { OpenAPI } from '../api/generated'; +import axios from 'axios'; + +export const getFetchAnaloguesExcelAxios = async ( + exportModels: string[], +): Promise => { + const token = OpenAPI.TOKEN; // replace with your bearer token + const base = OpenAPI.BASE; + let params = ''; + + if (exportModels.length > 0) { + exportModels.forEach((element, index) => { + if (index === 0) params = '?AnalogueModelIds=' + element; + else params += '&AnalogueModelIds=' + element; + }); + } + + const response = await axios.get( + '/api/downloads/analogue-models-excel' + params, + { + headers: { Authorization: `Bearer ${token}` }, + responseType: 'blob', // response type of blob to handle images + baseURL: base, + }, + ); + + if (response.data) { + const fileURL = window.URL.createObjectURL(response.data); + + const link = document.createElement('a'); + link.href = fileURL; + link.download = 'export.xlsx'; + + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + URL.revokeObjectURL(fileURL); + } + + // create an object URL for the image blob and return it + return URL.createObjectURL(response.data); +}; diff --git a/src/pages/Browse/Browse.tsx b/src/pages/Browse/Browse.tsx index a44b9b31..d95bc306 100644 --- a/src/pages/Browse/Browse.tsx +++ b/src/pages/Browse/Browse.tsx @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ /* eslint-disable max-lines-per-function */ -import { Button, Snackbar, Typography } from '@equinor/eds-core-react'; +import { Button, Dialog, Snackbar, Typography } from '@equinor/eds-core-react'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ModelTable } from '../../features/ModelTable/ModelTable'; @@ -10,11 +10,15 @@ import { analogueModelDefault, usePepmContextStore, } from '../../hooks/GlobalState'; +import { getFetchAnaloguesExcelAxios } from '../../hooks/useFetchAnaloguesExcel'; +import * as StyledDialog from '../../styles/addRowDialog/AddRowDialog.styled'; export const Browse = () => { - const { analogueModel, setAnalogueModelDefault } = usePepmContextStore(); + const { analogueModel, setAnalogueModelDefault, exportModels } = + usePepmContextStore(); const isOwnerOrAdmin = useIsOwnerOrAdmin(); const [uploadStatus, setUploadStatus] = useState(); + const [isOpen, setIsOpen] = useState(false); useEffect(() => { if (analogueModel !== analogueModelDefault) setAnalogueModelDefault(); @@ -30,6 +34,11 @@ export const Browse = () => { navigate('/add-model'); } + const handleOpen = () => { + getFetchAnaloguesExcelAxios(exportModels); + setIsOpen(false); + }; + return ( <> @@ -41,7 +50,13 @@ export const Browse = () => { - {/* TODO Add the export button */} + + {exportModels.length === 0 ? 'All ' : exportModels.length + ' '} + selected ) : ( <> @@ -55,6 +70,23 @@ export const Browse = () => { > {uploadStatus} + {isOpen && ( + + + + Note that all case results is part of the downloaded file, + including unpublished results. Be sure to filter your Excel file + if you only want to work with published results. + + + + + + + + )} ); };