diff --git a/apps/api/src/app/common/common.controller.ts b/apps/api/src/app/common/common.controller.ts index 05b1892ca..00accc3d2 100644 --- a/apps/api/src/app/common/common.controller.ts +++ b/apps/api/src/app/common/common.controller.ts @@ -1,9 +1,22 @@ -import { Body, Controller, Post, Get, UseGuards, Query, BadRequestException } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiSecurity, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiSecurity, ApiExcludeEndpoint, ApiConsumes } from '@nestjs/swagger'; +import { + Body, + Controller, + Post, + Get, + UseGuards, + Query, + BadRequestException, + UseInterceptors, + UploadedFile, +} from '@nestjs/common'; + import { ACCESS_KEY_NAME } from '@impler/shared'; import { JwtAuthGuard } from '@shared/framework/auth.gaurd'; import { ValidRequestDto, SignedUrlDto, ImportConfigResponseDto } from './dtos'; -import { ValidRequestCommand, GetSignedUrl, ValidRequest, GetImportConfig } from './usecases'; +import { ValidImportFile } from '@shared/validations/valid-import-file.validation'; +import { ValidRequestCommand, GetSignedUrl, ValidRequest, GetImportConfig, GetSheetNames } from './usecases'; @ApiTags('Common') @Controller('/common') @@ -13,6 +26,7 @@ export class CommonController { constructor( private validRequest: ValidRequest, private getSignedUrl: GetSignedUrl, + private getSheetNames: GetSheetNames, private getImportConfig: GetImportConfig ) {} @@ -50,4 +64,14 @@ export class CommonController { return this.getImportConfig.execute(projectId); } + + @Post('/sheet-names') + @ApiOperation({ + summary: 'Get sheet names for user selected file', + }) + @ApiConsumes('multipart/form-data') + @UseInterceptors(FileInterceptor('file')) + async getSheetNamesRoute(@UploadedFile('file', ValidImportFile) file: Express.Multer.File): Promise { + return this.getSheetNames.execute({ file }); + } } diff --git a/apps/api/src/app/common/dtos/index.ts b/apps/api/src/app/common/dtos/index.ts index ace75056a..1ca28b7c1 100644 --- a/apps/api/src/app/common/dtos/index.ts +++ b/apps/api/src/app/common/dtos/index.ts @@ -1,3 +1,4 @@ export { SignedUrlDto } from './signed-url.dto'; export { ValidRequestDto } from './valid.dto'; +export { SheetNamesDto } from './sheet-names.dto'; export { ImportConfigResponseDto } from './import-config-response.dto'; diff --git a/apps/api/src/app/common/dtos/sheet-names.dto.ts b/apps/api/src/app/common/dtos/sheet-names.dto.ts new file mode 100644 index 000000000..9e89b0cef --- /dev/null +++ b/apps/api/src/app/common/dtos/sheet-names.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SheetNamesDto { + @ApiProperty({ + type: 'file', + required: true, + }) + file: Express.Multer.File; +} diff --git a/apps/api/src/app/common/usecases/get-sheet-names/get-sheet-names.command.ts b/apps/api/src/app/common/usecases/get-sheet-names/get-sheet-names.command.ts new file mode 100644 index 000000000..29e160e5e --- /dev/null +++ b/apps/api/src/app/common/usecases/get-sheet-names/get-sheet-names.command.ts @@ -0,0 +1,7 @@ +import { IsDefined } from 'class-validator'; +import { BaseCommand } from '@shared/commands/base.command'; + +export class GetSheetNamesCommand extends BaseCommand { + @IsDefined() + file: Express.Multer.File; +} diff --git a/apps/api/src/app/common/usecases/get-sheet-names/get-sheet-names.usecase.ts b/apps/api/src/app/common/usecases/get-sheet-names/get-sheet-names.usecase.ts new file mode 100644 index 000000000..c624ffb2d --- /dev/null +++ b/apps/api/src/app/common/usecases/get-sheet-names/get-sheet-names.usecase.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { FileMimeTypesEnum } from '@impler/shared'; + +import { CONSTANTS } from '@shared/constants'; +import { ExcelFileService } from '@shared/services/file'; +import { GetSheetNamesCommand } from './get-sheet-names.command'; +import { FileParseException } from '@shared/exceptions/file-parse-issue.exception'; + +@Injectable() +export class GetSheetNames { + async execute({ file }: GetSheetNamesCommand): Promise { + if (file.mimetype === FileMimeTypesEnum.EXCEL || file.mimetype === FileMimeTypesEnum.EXCELX) { + try { + const fileService = new ExcelFileService(); + const sheetNames = await fileService.getExcelSheets(file); + + return sheetNames.filter((sheetName) => !sheetName.startsWith(CONSTANTS.EXCEL_DATA_SHEET_STARTER)); + } catch (error) { + throw new FileParseException(); + } + } else { + throw new Error('Invalid file type'); + } + } +} diff --git a/apps/api/src/app/common/usecases/index.ts b/apps/api/src/app/common/usecases/index.ts index 848ff3628..5d3d47c46 100644 --- a/apps/api/src/app/common/usecases/index.ts +++ b/apps/api/src/app/common/usecases/index.ts @@ -1,14 +1,18 @@ -import { GetImportConfig } from './get-import-config/get-import-config.usecase'; import { ValidRequest } from './valid-request/valid-request.usecase'; import { GetSignedUrl } from './get-signed-url/get-signed-url.usecase'; +import { GetSheetNames } from './get-sheet-names/get-sheet-names.usecase'; +import { GetImportConfig } from './get-import-config/get-import-config.usecase'; + import { ValidRequestCommand } from './valid-request/valid-request.command'; +import { GetSheetNamesCommand } from './get-sheet-names/get-sheet-names.command'; export const USE_CASES = [ ValidRequest, GetSignedUrl, + GetSheetNames, GetImportConfig, // ]; -export { GetSignedUrl, ValidRequest, GetImportConfig }; -export { ValidRequestCommand }; +export { GetSignedUrl, ValidRequest, GetImportConfig, GetSheetNames }; +export { ValidRequestCommand, GetSheetNamesCommand }; diff --git a/apps/api/src/app/shared/constants.ts b/apps/api/src/app/shared/constants.ts index 7379b8bd9..e20807837 100644 --- a/apps/api/src/app/shared/constants.ts +++ b/apps/api/src/app/shared/constants.ts @@ -31,6 +31,7 @@ export const APIMessages = { export const CONSTANTS = { PASSWORD_SALT: 10, + EXCEL_DATA_SHEET_STARTER: 'imp_', AUTH_COOKIE_NAME: 'authentication', // eslint-disable-next-line no-magic-numbers maxAge: 1000 * 60 * 60 * 24 * 1, // 1 day diff --git a/apps/api/src/app/shared/services/file/file.service.ts b/apps/api/src/app/shared/services/file/file.service.ts index 6a077bfa1..4d5f7aed4 100644 --- a/apps/api/src/app/shared/services/file/file.service.ts +++ b/apps/api/src/app/shared/services/file/file.service.ts @@ -1,17 +1,18 @@ import * as XLSX from 'xlsx'; import * as ExcelJS from 'exceljs'; import { ParseConfig, parse } from 'papaparse'; +import { CONSTANTS } from '@shared/constants'; import { ColumnTypesEnum, Defaults, FileEncodingsEnum } from '@impler/shared'; import { EmptyFileException } from '@shared/exceptions/empty-file.exception'; import { InvalidFileException } from '@shared/exceptions/invalid-file.exception'; import { IExcelFileHeading } from '@shared/types/file.types'; export class ExcelFileService { - async convertToCsv(file: Express.Multer.File): Promise { + async convertToCsv(file: Express.Multer.File, sheetName?: string): Promise { return new Promise(async (resolve, reject) => { try { const wb = XLSX.read(file.buffer); - const ws = wb.Sheets[wb.SheetNames[0]]; + const ws = sheetName && wb.SheetNames.includes(sheetName) ? wb.Sheets[sheetName] : wb.Sheets[wb.SheetNames[0]]; resolve( XLSX.utils.sheet_to_csv(ws, { blankrows: false, @@ -25,12 +26,12 @@ export class ExcelFileService { }); } formatName(name: string): string { - return name.replace(/[^a-zA-Z0-9]/g, ''); + return CONSTANTS.EXCEL_DATA_SHEET_STARTER + name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); } addSelectSheet(wb: ExcelJS.Workbook, heading: IExcelFileHeading): string { const name = this.formatName(heading.key); const companies = wb.addWorksheet(name); - const companyHeadings = [name]; + const companyHeadings = [heading.key]; companies.addRow(companyHeadings); heading.selectValues.forEach((value) => companies.addRow([value])); @@ -120,6 +121,16 @@ export class ExcelFileService { return workbook.xlsx.writeBuffer() as Promise; } + getExcelSheets(file: Express.Multer.File): Promise { + return new Promise(async (resolve, reject) => { + try { + const wb = XLSX.read(file.buffer); + resolve(wb.SheetNames); + } catch (error) { + reject(error); + } + }); + } } export class CSVFileService2 { diff --git a/apps/api/src/app/upload/dtos/upload-request.dto.ts b/apps/api/src/app/upload/dtos/upload-request.dto.ts index bc6f85320..24c4eba1d 100644 --- a/apps/api/src/app/upload/dtos/upload-request.dto.ts +++ b/apps/api/src/app/upload/dtos/upload-request.dto.ts @@ -39,4 +39,11 @@ export class UploadRequestDto { @IsOptional() @IsJSON() extra: string; + + @ApiProperty({ + description: 'Name of the excel sheet to Import', + }) + @IsOptional() + @IsString() + selectedSheetName: string; } diff --git a/apps/api/src/app/upload/upload.controller.ts b/apps/api/src/app/upload/upload.controller.ts index e93869c80..52e62c809 100644 --- a/apps/api/src/app/upload/upload.controller.ts +++ b/apps/api/src/app/upload/upload.controller.ts @@ -40,8 +40,8 @@ import { GetOriginalFileContent, } from './usecases'; -@Controller('/upload') @ApiTags('Uploads') +@Controller('/upload') @ApiSecurity(ACCESS_KEY_NAME) @UseGuards(JwtAuthGuard) export class UploadController { @@ -68,18 +68,19 @@ export class UploadController { @ApiConsumes('multipart/form-data') @UseInterceptors(FileInterceptor('file')) async uploadFile( - @UploadedFile('file', ValidImportFile) file: Express.Multer.File, @Body() body: UploadRequestDto, - @Param('templateId', ValidateTemplate) templateId: string + @Param('templateId', ValidateTemplate) templateId: string, + @UploadedFile('file', ValidImportFile) file: Express.Multer.File ) { return this.makeUploadEntry.execute( MakeUploadEntryCommand.create({ file: file, templateId, extra: body.extra, - authHeaderValue: body.authHeaderValue, schema: body.schema, output: body.output, + authHeaderValue: body.authHeaderValue, + selectedSheetName: body.selectedSheetName, }) ); } diff --git a/apps/api/src/app/upload/usecases/index.ts b/apps/api/src/app/upload/usecases/index.ts index 7746c93f8..e1787fdc0 100644 --- a/apps/api/src/app/upload/usecases/index.ts +++ b/apps/api/src/app/upload/usecases/index.ts @@ -18,8 +18,8 @@ export const USE_CASES = [ ]; export { - MakeUploadEntry, GetUpload, + MakeUploadEntry, TerminateUpload, GetUploadColumns, GetOriginalFileContent, diff --git a/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.command.ts b/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.command.ts index 8bf89a6c2..1b8a33851 100644 --- a/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.command.ts +++ b/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.command.ts @@ -24,4 +24,8 @@ export class MakeUploadEntryCommand extends BaseCommand { @IsOptional() @IsString() authHeaderValue?: string; + + @IsString() + @IsOptional() + selectedSheetName?: string; } diff --git a/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts b/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts index d71c6a7df..a4f5f7614 100644 --- a/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts +++ b/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts @@ -38,13 +38,21 @@ export class MakeUploadEntry { private customizationRepository: CustomizationRepository ) {} - async execute({ file, templateId, extra, authHeaderValue, schema, output }: MakeUploadEntryCommand) { + async execute({ + file, + templateId, + extra, + authHeaderValue, + schema, + output, + selectedSheetName, + }: MakeUploadEntryCommand) { const fileOriginalName = file.originalname; let csvFile: string | Express.Multer.File = file; if (file.mimetype === FileMimeTypesEnum.EXCEL || file.mimetype === FileMimeTypesEnum.EXCELX) { try { const fileService = new ExcelFileService(); - csvFile = await fileService.convertToCsv(file); + csvFile = await fileService.convertToCsv(file, selectedSheetName); } catch (error) { throw new FileParseException(); } diff --git a/apps/widget/src/components/widget/Phases/ConfirmModal/Styles.tsx b/apps/widget/src/components/widget/Phases/ConfirmModal/Styles.tsx deleted file mode 100644 index daa7e3403..000000000 --- a/apps/widget/src/components/widget/Phases/ConfirmModal/Styles.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars, no-unused-vars */ -import { createStyles, MantineTheme } from '@mantine/core'; - -export const getActionsStyles = (theme: MantineTheme): React.CSSProperties => ({ - width: '100%', - flexDirection: 'column', - alignItems: 'stretch', - [`@media (min-width: ${theme.breakpoints.sm}px)`]: { - flexDirection: 'row', - flexWrap: 'nowrap', - }, -}); - -export const getWrapperStyles = (theme: MantineTheme): React.CSSProperties => ({ - flexDirection: 'column', - textAlign: 'center', -}); - -export const getWarningIconStyles = (theme: MantineTheme): React.CSSProperties => ({ - width: 40, - height: 40, -}); - -export default createStyles((theme: MantineTheme, params, getRef): Record => { - return { - wrapper: getWrapperStyles(theme), - actions: getActionsStyles(theme), - warning: getWarningIconStyles(theme), - }; -}); diff --git a/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx b/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx index b860f75aa..9aa39745e 100644 --- a/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx +++ b/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx @@ -1,15 +1,18 @@ -import { TEXTS, variables } from '@config'; +import { Group } from '@mantine/core'; +import { Controller } from 'react-hook-form'; + +import { Download } from '@icons'; +import { PhasesEnum } from '@types'; import { Select } from '@ui/Select'; import { Button } from '@ui/Button'; import { Dropzone } from '@ui/Dropzone'; +import { TEXTS, variables } from '@config'; import { LoadingOverlay } from '@ui/LoadingOverlay'; -import { Group } from '@mantine/core'; -import { Download } from '@icons'; +import { usePhase1 } from '@hooks/Phase1/usePhase1'; + import useStyles from './Styles'; import { Footer } from 'components/Common/Footer'; -import { usePhase1 } from '@hooks/Phase1/usePhase1'; -import { Controller } from 'react-hook-form'; -import { PhasesEnum } from '@types'; +import { SheetSelectModal } from './SheetSelectModal'; interface IPhase1Props { onNextClick: () => void; @@ -21,14 +24,17 @@ export function Phase1(props: IPhase1Props) { const { onSubmit, control, + setError, templates, onDownload, - setError, + excelSheetNames, isUploadLoading, onTemplateChange, + onSelectExcelSheet, showSelectTemplate, isInitialDataLoaded, isDownloadInProgress, + onSelectSheetModalReset, } = usePhase1({ goNext, }); @@ -93,6 +99,14 @@ export function Phase1(props: IPhase1Props) { )} /> + +