diff --git a/apps/api/package.json b/apps/api/package.json index fdba3c594..10a7e54fe 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -46,7 +46,7 @@ "compression": "^1.7.4", "cookie-parser": "^1.4.6", "date-fns": "^2.30.0", - "dayjs": "^1.11.10", + "dayjs": "^1.11.11", "dotenv": "^16.0.2", "envalid": "^7.3.1", "exceljs": "^4.3.0", @@ -62,7 +62,8 @@ "socket.io": "^4.7.2", "source-map-support": "^0.5.21", "uuid": "^9.0.0", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.1/xlsx-0.20.1.tgz" + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.1/xlsx-0.20.1.tgz", + "xlsx-populate": "^1.21.0" }, "devDependencies": { "@nestjs/cli": "^9.1.5", diff --git a/apps/api/src/app/column/commands/add-column.command.ts b/apps/api/src/app/column/commands/add-column.command.ts index 6feb49811..0fbe7dce0 100644 --- a/apps/api/src/app/column/commands/add-column.command.ts +++ b/apps/api/src/app/column/commands/add-column.command.ts @@ -58,4 +58,8 @@ export class AddColumnCommand extends BaseCommand { @IsOptional() @Validate(IsNumberOrString) defaultValue?: string | number; + + @IsBoolean() + @IsOptional() + allowMultiSelect?: boolean; } diff --git a/apps/api/src/app/column/commands/update-column.command.ts b/apps/api/src/app/column/commands/update-column.command.ts index baf7cbe3c..58b5c7146 100644 --- a/apps/api/src/app/column/commands/update-column.command.ts +++ b/apps/api/src/app/column/commands/update-column.command.ts @@ -54,4 +54,7 @@ export class UpdateColumnCommand extends BaseCommand { @IsOptional() @Validate(IsNumberOrString) defaultValue?: string | number; + + @IsOptional() + allowMultiSelect?: boolean; } diff --git a/apps/api/src/app/column/dtos/column-request.dto.ts b/apps/api/src/app/column/dtos/column-request.dto.ts index 40a1bfe20..83b560536 100644 --- a/apps/api/src/app/column/dtos/column-request.dto.ts +++ b/apps/api/src/app/column/dtos/column-request.dto.ts @@ -104,4 +104,11 @@ export class ColumnRequestDto { @IsOptional() @Validate(IsNumberOrString) defaultValue?: string | number; + + @ApiPropertyOptional({ + description: 'If true, column can have multiple values', + }) + @IsBoolean() + @IsOptional() + allowMultiSelect?: boolean; } diff --git a/apps/api/src/app/column/dtos/column-response.dto.ts b/apps/api/src/app/column/dtos/column-response.dto.ts index bb47fcd9f..790ce0047 100644 --- a/apps/api/src/app/column/dtos/column-response.dto.ts +++ b/apps/api/src/app/column/dtos/column-response.dto.ts @@ -59,4 +59,9 @@ export class ColumnResponseDto { description: 'Sequence of column', }) sequence?: number; + + @ApiProperty({ + description: 'If true, the column can have multiple values', + }) + allowMultiSelect?: boolean; } diff --git a/apps/api/src/app/column/usecases/update-column/update-column.usecase.ts b/apps/api/src/app/column/usecases/update-column/update-column.usecase.ts index f4cf760a1..a47b6c405 100644 --- a/apps/api/src/app/column/usecases/update-column/update-column.usecase.ts +++ b/apps/api/src/app/column/usecases/update-column/update-column.usecase.ts @@ -20,7 +20,7 @@ export class UpdateColumn { } command.dateFormats = command.dateFormats?.map((format) => format.toUpperCase()) || []; - const isKeyUpdated = command.key !== column.key; + const isKeyUpdated = command.key !== column.key || command.allowMultiSelect !== column.allowMultiSelect; const isTypeUpdated = command.type !== column.type; const isFieldConditionUpdated = JSON.stringify(column.selectValues) !== JSON.stringify(command.selectValues) || diff --git a/apps/api/src/app/mapping/usecases/rename-file-headings/rename-file-headings.usecase.ts b/apps/api/src/app/mapping/usecases/rename-file-headings/rename-file-headings.usecase.ts index e1f9458c6..47f8a967e 100644 --- a/apps/api/src/app/mapping/usecases/rename-file-headings/rename-file-headings.usecase.ts +++ b/apps/api/src/app/mapping/usecases/rename-file-headings/rename-file-headings.usecase.ts @@ -25,6 +25,8 @@ export class ReanameFileHeadings { newHeadings[columnHeadingIndex], newHeadings[keyHeadingIndex], ]; + } else if (columnHeadingIndex > -1) { + newHeadings[columnHeadingIndex] = mapping.key; } } }); diff --git a/apps/api/src/app/review/usecases/do-review/base-review.usecase.ts b/apps/api/src/app/review/usecases/do-review/base-review.usecase.ts index 2e16339de..b5af1723b 100644 --- a/apps/api/src/app/review/usecases/do-review/base-review.usecase.ts +++ b/apps/api/src/app/review/usecases/do-review/base-review.usecase.ts @@ -4,12 +4,15 @@ import * as Papa from 'papaparse'; import { Writable } from 'stream'; import addFormats from 'ajv-formats'; import addKeywords from 'ajv-keywords'; +import * as customParseFormat from 'dayjs/plugin/customParseFormat'; import Ajv, { AnySchemaObject, ErrorObject, ValidateFunction } from 'ajv'; import { ColumnTypesEnum, Defaults, ITemplateSchemaItem } from '@impler/shared'; import { SManager, BATCH_LIMIT, MAIN_CODE } from '@shared/services/sandbox'; +dayjs.extend(customParseFormat); + interface IDataItem { index: number; errors?: Record; @@ -31,6 +34,7 @@ interface IRunData { dataStream: Writable; validator: ValidateFunction; extra: any; + multiSelectColumnHeadings: string[]; dateFormats: Record; } @@ -115,11 +119,20 @@ export class BaseReview { Array.isArray(column.selectValues) && column.selectValues.length > 0 ? [...column.selectValues, ...(column.isRequired ? [] : [''])] : ['']; - property = { - type: 'string', - enum: Array.from(new Set(selectValues)), // handle duplicate - ...(!column.isRequired && { default: '' }), - }; + if (column.allowMultiSelect) + property = { + type: 'array', + items: { + type: 'string', + enum: selectValues, + }, + }; + else + property = { + type: 'string', + enum: Array.from(new Set(selectValues)), // handle duplicate + ...(!column.isRequired && { default: '' }), + }; break; case ColumnTypesEnum.REGEX: const [full, pattern, flags] = column.regex.match(/\/(.*)\/(.*)|(.*)/); @@ -152,6 +165,7 @@ export class BaseReview { let field: string; return errors.reduce((obj, error) => { + console.log(error); if (error.keyword === 'required') field = error.params.missingProperty; else [, field] = error.instancePath.split('/'); @@ -250,6 +264,7 @@ export class BaseReview { headings, dateFormats, dataStream, + multiSelectColumnHeadings, }: IRunData): Promise { return new Promise(async (resolve, reject) => { let totalRecords = -1, @@ -265,16 +280,34 @@ export class BaseReview { const record = results.data; if (totalRecords >= 1) { - const recordObj: Record = headings.reduce((acc, heading, index) => { - if (heading === '_') return acc; - - acc[heading] = typeof record[index] === 'string' ? record[index].trim() : record[index]; - - return acc; - }, {}); + const recordObj: { + checkRecord: Record; + passRecord: Record; + } = headings.reduce( + (acc, heading, index) => { + if (heading === '_') return acc; + + acc.checkRecord[heading] = multiSelectColumnHeadings.includes(heading) + ? !record[index] + ? [] + : record[index].split(',') + : typeof record[index] === 'string' + ? record[index].trim() + : record[index]; + + acc.passRecord[heading] = typeof record[index] === 'string' ? record[index].trim() : record[index]; + + return acc; + }, + { + checkRecord: {}, + passRecord: {}, + } + ); const validationResultItem = this.validateRecord({ index: totalRecords, - record: recordObj, + checkRecord: recordObj.checkRecord, + passRecord: recordObj.passRecord, validator, dateFormats, }); @@ -304,16 +337,18 @@ export class BaseReview { validateRecord({ index, - record, + passRecord, + checkRecord, validator, dateFormats, }: { index: number; - record: Record; validator: ValidateFunction; + passRecord: Record; + checkRecord: Record; dateFormats: Record; }) { - const isValid = validator({ ...record }); + const isValid = validator(checkRecord); if (!isValid) { const errors = this.getErrorsObject(validator.errors, dateFormats); @@ -321,14 +356,14 @@ export class BaseReview { index, errors: errors, isValid: false, - record, + record: passRecord, updated: {}, }; } else { return { index, isValid: true, - record, + record: passRecord, errors: {}, updated: {}, }; @@ -389,6 +424,7 @@ export class BaseReview { extra, csvFileStream, dateFormats, + multiSelectColumnHeadings, }: IRunData): Promise { return new Promise(async (resolve, reject) => { try { @@ -403,16 +439,34 @@ export class BaseReview { step: (results: Papa.ParseStepResult) => { recordsCount++; const record = results.data; - const recordObj = headings.reduce((acc, heading, index) => { - acc[heading] = typeof record[index] === 'string' ? record[index].trim() : record[index]; - - return acc; - }, {}); + const recordObj: { + checkRecord: Record; + passRecord: Record; + } = headings.reduce( + (acc, heading, index) => { + if (heading === '_') return acc; + + acc.checkRecord[heading] = multiSelectColumnHeadings.includes(heading) + ? record[index]?.split(',') + : typeof record[index] === 'string' + ? record[index].trim() + : record[index]; + + acc.passRecord[heading] = typeof record[index] === 'string' ? record[index].trim() : record[index]; + + return acc; + }, + { + checkRecord: {}, + passRecord: {}, + } + ); if (recordsCount >= 1) { const validationResultItem = this.validateRecord({ index: recordsCount, - record: recordObj, + checkRecord: recordObj.checkRecord, + passRecord: recordObj.passRecord, validator, dateFormats, }); diff --git a/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts b/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts index 99c860086..ec83ebc02 100644 --- a/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts +++ b/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts @@ -3,7 +3,7 @@ import { Writable } from 'stream'; import { Injectable, BadRequestException } from '@nestjs/common'; import { APIMessages } from '@shared/constants'; -import { UploadStatusEnum } from '@impler/shared'; +import { ITemplateSchemaItem, UploadStatusEnum } from '@impler/shared'; import { BaseReview } from './base-review.usecase'; import { BATCH_LIMIT } from '@shared/services/sandbox'; import { StorageService } from '@impler/shared/dist/services/storage'; @@ -33,22 +33,33 @@ export class DoReview extends BaseReview { async execute(_uploadId: string) { this._modal = await this.dalService.createRecordCollection(_uploadId); - const uploadInfo = await this.uploadRepository.findById(_uploadId); + const uploadInfo = await this.uploadRepository.findById( + _uploadId, + 'customSchema _uploadedFileId _templateId extra headings' + ); if (!uploadInfo) { throw new BadRequestException(APIMessages.UPLOAD_NOT_FOUND); } const dateFormats: Record = {}; const uniqueItems: Record> = {}; + const columns = JSON.parse(uploadInfo.customSchema); + const multiSelectColumnHeadings = []; + (columns as ITemplateSchemaItem[]).forEach((column) => { + if (column.allowMultiSelect) multiSelectColumnHeadings.push(column.key); + }); const schema = this.buildAJVSchema({ - columns: JSON.parse(uploadInfo.customSchema), + columns, dateFormats, uniqueItems, }); const ajv = this.getAjvValidator(dateFormats, uniqueItems); const validator = ajv.compile(schema); - const uploadedFileInfo = await this.fileRepository.findById(uploadInfo._uploadedFileId); - const validations = await this.validatorRepository.findOne({ _templateId: uploadInfo._templateId }); + const uploadedFileInfo = await this.fileRepository.findById(uploadInfo._uploadedFileId, 'path'); + const validations = await this.validatorRepository.findOne( + { _templateId: uploadInfo._templateId }, + 'onBatchInitialize' + ); let response: ISaveResults; @@ -66,6 +77,7 @@ export class DoReview extends BaseReview { extra: uploadInfo.extra, dataStream, // not-used dateFormats, + multiSelectColumnHeadings, }); response = { @@ -98,6 +110,7 @@ export class DoReview extends BaseReview { uploadId: _uploadId, validator, dateFormats, + multiSelectColumnHeadings, }); } diff --git a/apps/api/src/app/review/usecases/do-review/re-review-data.usecase.ts b/apps/api/src/app/review/usecases/do-review/re-review-data.usecase.ts index cd12459ea..f09eea126 100644 --- a/apps/api/src/app/review/usecases/do-review/re-review-data.usecase.ts +++ b/apps/api/src/app/review/usecases/do-review/re-review-data.usecase.ts @@ -61,10 +61,13 @@ export class DoReReview extends BaseReview { const validator = ajv.compile(schema); const validations = await this.validatorRepository.findOne({ _templateId: uploadInfo._templateId }); - const uniqueFields = (JSON.parse(uploadInfo.customSchema) as ITemplateSchemaItem[]) - .filter((column) => column.isUnique) - .map((column) => column.key); + const columns = JSON.parse(uploadInfo.customSchema) as ITemplateSchemaItem[]; + const uniqueFields = columns.filter((column) => column.isUnique).map((column) => column.key); const uniqueFieldData = uniqueFields.length ? await this.dalService.getFieldData(_uploadId, uniqueFields) : []; + const multiSelectColumnHeadings = []; + (columns as ITemplateSchemaItem[]).forEach((column) => { + if (column.allowMultiSelect) multiSelectColumnHeadings.push(column.key); + }); uniqueFieldData.forEach((item) => { uniqueFields.forEach((field) => { @@ -82,12 +85,13 @@ export class DoReReview extends BaseReview { if (validations && validations.onBatchInitialize) { result = await this.batchValidate({ + result, validator, dateFormats, uploadId: _uploadId, extra: uploadInfo.extra, + multiSelectColumnHeadings, onBatchInitialize: validations.onBatchInitialize, - result, }); } else { result = await this.normalValidate({ @@ -95,6 +99,7 @@ export class DoReReview extends BaseReview { validator, dateFormats, result, + multiSelectColumnHeadings, }); } @@ -108,10 +113,12 @@ export class DoReReview extends BaseReview { uploadId, validator, dateFormats, + multiSelectColumnHeadings, }: { uploadId: string; result: ISaveResults; validator: ValidateFunction; + multiSelectColumnHeadings: string[]; dateFormats: Record; }) { const bulkOp = this.dalService.getRecordBulkOp(uploadId); @@ -123,11 +130,24 @@ export class DoReReview extends BaseReview { }; for await (const record of this._modal.find({ updated: { $ne: {}, $exists: true } })) { + const checkRecord: Record = multiSelectColumnHeadings.reduce( + (acc, heading) => { + if (heading === '_') return acc; + + if (multiSelectColumnHeadings.includes(heading) && typeof record.record[heading] === 'string') { + acc[heading] = record.record[heading]?.split(','); + } + + return acc; + }, + { ...record.record } + ); const validationResult = this.validateRecord({ - index: record.index, - record: record.record, validator, + checkRecord, dateFormats, + index: record.index, + passRecord: record.record, }); response.totalRecords++; if (record.isValid && !validationResult.isValid) { @@ -174,12 +194,14 @@ export class DoReReview extends BaseReview { validator, dateFormats, onBatchInitialize, + multiSelectColumnHeadings, }: { extra: any; uploadId: string; result: ISaveResults; onBatchInitialize: string; validator: ValidateFunction; + multiSelectColumnHeadings: string[]; dateFormats: Record; }) { const { dataStream } = this.getStreams({ @@ -190,6 +212,7 @@ export class DoReReview extends BaseReview { uploadId, validator, dateFormats, + multiSelectColumnHeadings, }); await this.processBatches({ @@ -216,10 +239,12 @@ export class DoReReview extends BaseReview { uploadId, validator, dateFormats, + multiSelectColumnHeadings, }: { extra: any; uploadId: string; validator: ValidateFunction; + multiSelectColumnHeadings: string[]; dateFormats: Record; }) { let batchCount = 1; @@ -232,11 +257,21 @@ export class DoReReview extends BaseReview { const resultObj = {}; for await (const record of this._modal.find({ updated: { $ne: {}, $exists: true } })) { + const checkRecord: Record = multiSelectColumnHeadings.reduce((acc, heading) => { + if (heading === '_') return acc; + + if (multiSelectColumnHeadings.includes(heading)) { + acc[heading] = record.record[heading]?.split(','); + } + + return acc; + }, record.record); const validationResultItem = this.validateRecord({ - index: record.index, - record: record.record, validator, + checkRecord, dateFormats, + index: record.index, + passRecord: record.record, }); resultObj[Number(record.index)] = record.isValid; batchRecords.push(validationResultItem); 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 4638776b3..0d0eaf217 100644 --- a/apps/api/src/app/shared/services/file/file.service.ts +++ b/apps/api/src/app/shared/services/file/file.service.ts @@ -1,7 +1,9 @@ import * as XLSX from 'xlsx'; import * as ExcelJS from 'exceljs'; -import { ParseConfig, parse } from 'papaparse'; +import { cwd } from 'node:process'; +import * as xlsxPopulate from 'xlsx-populate'; import { CONSTANTS } from '@shared/constants'; +import { ParseConfig, parse } from 'papaparse'; import { ColumnTypesEnum, Defaults, FileEncodingsEnum } from '@impler/shared'; import { EmptyFileException } from '@shared/exceptions/empty-file.exception'; import { InvalidFileException } from '@shared/exceptions/invalid-file.exception'; @@ -89,43 +91,54 @@ export class ExcelFileService { return columnName.reverse().join(''); } - getExcelFileForHeadings(headings: IExcelFileHeading[], data?: Record[]): Promise { - const workbook = new ExcelJS.Workbook(); - const worksheet = workbook.addWorksheet('Data'); - const headingNames = headings.map((heading) => heading.key); - worksheet.columns = headings.map((heading) => { - if (heading.type === ColumnTypesEnum.DATE) - return { - header: heading.key, - key: heading.key, - style: { numFmt: '@' }, - }; + async getExcelFileForHeadings(headings: IExcelFileHeading[], data?: Record[]): Promise { + const currentDir = cwd(); + const isMultiSelect = headings.some( + (heading) => heading.type === ColumnTypesEnum.SELECT && heading.allowMultiSelect + ); + const workbook = await xlsxPopulate.fromFileAsync( + `${currentDir}/src/config/${isMultiSelect ? 'Excel Multi Select Template.xlsm' : 'Excel Template.xlsx'}` + ); + const worksheet = workbook.sheet('Data'); - return { header: heading.key, key: heading.key }; + headings.forEach((heading, index) => { + const columnName = this.getExcelColumnNameFromIndex(index + 1) + '1'; + if (heading.type === ColumnTypesEnum.SELECT && heading.allowMultiSelect) + worksheet.cell(columnName).value(heading.key + '#MULTI'); + else worksheet.cell(columnName).value(heading.key); }); + headings.forEach((heading, index) => { if (heading.type === ColumnTypesEnum.SELECT) { - const keyName = this.addSelectSheet(workbook, heading); const columnName = this.getExcelColumnNameFromIndex(index + 1); - this.addSelectValidation({ - ws: worksheet, - range: `${columnName}2:${columnName}9999`, - keyName, - isRequired: heading.isRequired, + worksheet.range(`${columnName}2:${columnName}9999`).dataValidation({ + type: 'list', + allowBlank: !heading.isRequired, + formula1: `"${heading.selectValues.join(',')}"`, + ...(!heading.allowMultiSelect + ? { + showErrorMessage: true, + error: 'Please select from the list', + errorTitle: 'Invalid Value', + } + : {}), }); } }); - + const headingNames = headings.map((heading) => heading.key); + const endColumnPosition = this.getExcelColumnNameFromIndex(headings.length + 1) + '2'; + const range = workbook.sheet(0).range(`A2:${endColumnPosition}`); if (Array.isArray(data) && data.length > 0) { const rows: string[][] = data.reduce((acc: string[][], rowItem: Record) => { acc.push(headingNames.map((headingKey) => rowItem[headingKey])); return acc; }, []); - worksheet.addRows(rows); + range.value(rows); } + const buffer = await workbook.outputAsync(); - return workbook.xlsx.writeBuffer() as Promise; + return buffer as Promise; } getExcelSheets(file: Express.Multer.File): Promise { return new Promise(async (resolve, reject) => { diff --git a/apps/api/src/app/shared/types/file.types.ts b/apps/api/src/app/shared/types/file.types.ts index 0620ea6ff..bff8efb50 100644 --- a/apps/api/src/app/shared/types/file.types.ts +++ b/apps/api/src/app/shared/types/file.types.ts @@ -6,4 +6,5 @@ export interface IExcelFileHeading { type: ColumnTypesEnum; selectValues?: string[]; dateFormats?: string[]; + allowMultiSelect?: boolean; } diff --git a/apps/api/src/app/shared/usecases/save-sample-file/save-sample-file.usecase.ts b/apps/api/src/app/shared/usecases/save-sample-file/save-sample-file.usecase.ts index c38c8b548..c20dfe8d7 100644 --- a/apps/api/src/app/shared/usecases/save-sample-file/save-sample-file.usecase.ts +++ b/apps/api/src/app/shared/usecases/save-sample-file/save-sample-file.usecase.ts @@ -21,11 +21,15 @@ export class SaveSampleFile { selectValues: columnItem.selectValues, isRequired: columnItem.isRequired, dateFormats: columnItem.dateFormats, + allowMultiSelect: columnItem.allowMultiSelect, })); - const fileName = this.fileNameService.getSampleFileName(templateId); - const sampleFileUrl = this.fileNameService.getSampleFileUrl(templateId); + const hasMultiSelect = columns.some( + (columnItem) => columnItem.type === ColumnTypesEnum.SELECT && columnItem.allowMultiSelect + ); + const fileName = this.fileNameService.getSampleFileName(templateId, hasMultiSelect); + const sampleFileUrl = this.fileNameService.getSampleFileUrl(templateId, hasMultiSelect); const sampleExcelFile = await this.excelFileService.getExcelFileForHeadings(columnKeys); - await this.storageService.uploadFile(fileName, sampleExcelFile, FileMimeTypesEnum.EXCEL); + await this.storageService.uploadFile(fileName, sampleExcelFile, FileMimeTypesEnum.EXCELM); await this.templateRepository.update({ _id: templateId }, { sampleFileUrl }); } } diff --git a/apps/api/src/app/template/template.controller.ts b/apps/api/src/app/template/template.controller.ts index 627aa0086..c47d25d69 100644 --- a/apps/api/src/app/template/template.controller.ts +++ b/apps/api/src/app/template/template.controller.ts @@ -97,11 +97,11 @@ export class TemplateController { @Body() data: DownloadSampleDto, @Res() res: Response ) { - const buffer = await this.downloadSample.execute(templateId, data); - res.header('Content-disposition', 'attachment; filename=anlikodullendirme.xlsx'); - res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + const { ext, file, type } = await this.downloadSample.execute(templateId, data); + res.header(`Content-disposition', 'attachment; filename=sample.${ext}`); + res.type(type); - return res.send(buffer); + return res.send(file); } @Get(':templateId/columns') @@ -195,6 +195,7 @@ export class TemplateController { sequence: columnData.sequence, _templateId, type: columnData.type, + allowMultiSelect: columnData.allowMultiSelect, }) ), _templateId diff --git a/apps/api/src/app/template/usecases/download-sample/download-sample.usecase.ts b/apps/api/src/app/template/usecases/download-sample/download-sample.usecase.ts index 654d5cce5..81c622d43 100644 --- a/apps/api/src/app/template/usecases/download-sample/download-sample.usecase.ts +++ b/apps/api/src/app/template/usecases/download-sample/download-sample.usecase.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ColumnRepository } from '@impler/dal'; -import { ColumnTypesEnum, ISchemaItem } from '@impler/shared'; +import { ColumnTypesEnum, FileMimeTypesEnum, ISchemaItem } from '@impler/shared'; import { ExcelFileService } from '@shared/services/file'; import { IExcelFileHeading } from '@shared/types/file.types'; @@ -13,12 +13,19 @@ export class DownloadSample { private excelFileService: ExcelFileService ) {} - async execute(_templateId: string, data: DownloadSampleCommand): Promise { + async execute( + _templateId: string, + data: DownloadSampleCommand + ): Promise<{ + file: Buffer; + type: string; + ext: string; + }> { const columns = await this.columnsRepository.find( { _templateId, }, - 'key type selectValues isRequired' + 'key type selectValues isRequired allowMultiSelect' ); let parsedSchema: ISchemaItem[], columnKeys: IExcelFileHeading[]; @@ -32,6 +39,7 @@ export class DownloadSample { type: (columnItem.type as ColumnTypesEnum) || ColumnTypesEnum.STRING, selectValues: columnItem.selectValues, isRequired: columnItem.isRequired, + allowMultiSelect: columnItem.allowMultiSelect, })); } else { // else create structure from existing defualt schema @@ -40,9 +48,18 @@ export class DownloadSample { type: columnItem.type as ColumnTypesEnum, selectValues: columnItem.selectValues, isRequired: columnItem.isRequired, + allowMultiSelect: columnItem.allowMultiSelect, })); } + const hasMultiSelect = columnKeys.some( + (columnItem) => columnItem.type === ColumnTypesEnum.SELECT && columnItem.allowMultiSelect + ); + const buffer = await this.excelFileService.getExcelFileForHeadings(columnKeys, data.data); - return await this.excelFileService.getExcelFileForHeadings(columnKeys, data.data); + return { + file: buffer, + ext: hasMultiSelect ? 'xlsm' : 'xlsx', + type: hasMultiSelect ? FileMimeTypesEnum.EXCELM : FileMimeTypesEnum.EXCELX, + }; } } diff --git a/apps/api/src/app/template/usecases/duplicate-template/duplicate-template.usecase.ts b/apps/api/src/app/template/usecases/duplicate-template/duplicate-template.usecase.ts index 49a8a7e87..a9cf25064 100644 --- a/apps/api/src/app/template/usecases/duplicate-template/duplicate-template.usecase.ts +++ b/apps/api/src/app/template/usecases/duplicate-template/duplicate-template.usecase.ts @@ -55,7 +55,8 @@ export class DuplicateTemplate { { _templateId, }, - '-_id name key alternateKeys isRequired isUnique type regex regexDescription selectValues dateFormats sequence defaultValue' + // eslint-disable-next-line max-len + '-_id name key alternateKeys isRequired isUnique type regex regexDescription selectValues dateFormats sequence defaultValue allowMultiSelect' ); await this.columnRepository.createMany( @@ -75,6 +76,7 @@ export class DuplicateTemplate { { _templateId, }, + // eslint-disable-next-line max-len '-_id recordVariables chunkVariables recordFormat chunkFormat combinedFormat isRecordFormatUpdated isChunkFormatUpdated isCombinedFormatUpdated' ); diff --git a/apps/api/src/app/template/usecases/get-columns/get-columns.usecase.ts b/apps/api/src/app/template/usecases/get-columns/get-columns.usecase.ts index 8c79cda9b..8e14992e6 100644 --- a/apps/api/src/app/template/usecases/get-columns/get-columns.usecase.ts +++ b/apps/api/src/app/template/usecases/get-columns/get-columns.usecase.ts @@ -8,7 +8,7 @@ export class GetTemplateColumns { async execute(_templateId: string) { return this.columnRepository.find( { _templateId }, - '_id name key type alternateKeys isRequired isUnique selectValues regex dateFormats defaultValue sequence' + '_id name key type alternateKeys isRequired isUnique selectValues regex dateFormats defaultValue sequence allowMultiSelect' ); } } 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 ba3b93d4d..380e64f26 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 @@ -49,7 +49,11 @@ export class MakeUploadEntry { }: MakeUploadEntryCommand) { const fileOriginalName = file.originalname; let csvFile: string | Express.Multer.File = file; - if (file.mimetype === FileMimeTypesEnum.EXCEL || file.mimetype === FileMimeTypesEnum.EXCELX) { + if ( + file.mimetype === FileMimeTypesEnum.EXCEL || + file.mimetype === FileMimeTypesEnum.EXCELX || + file.mimetype === FileMimeTypesEnum.EXCELM + ) { try { const fileService = new ExcelFileService(); csvFile = await fileService.convertToCsv(file, selectedSheetName); @@ -66,7 +70,7 @@ export class MakeUploadEntry { { _templateId: templateId, }, - 'name key isRequired isUnique selectValues dateFormats defaultValue type regex sequence', + 'name key isRequired isUnique selectValues dateFormats defaultValue type regex sequence allowMultiSelect', { sort: 'sequence', } @@ -94,6 +98,7 @@ export class MakeUploadEntry { : Defaults.DATE_FORMATS, isUnique: schemaItem.isUnique || false, defaultValue: schemaItem.defaultValue, + allowMultiSelect: schemaItem.allowMultiSelect, sequence: Object.keys(formattedColumns).length, columnHeading: '', // used later during mapping diff --git a/apps/api/src/config/Excel Multi Select Template.xlsm b/apps/api/src/config/Excel Multi Select Template.xlsm new file mode 100644 index 000000000..a914c3e78 Binary files /dev/null and b/apps/api/src/config/Excel Multi Select Template.xlsm differ diff --git a/apps/api/src/config/Excel Template.xlsx b/apps/api/src/config/Excel Template.xlsx new file mode 100644 index 000000000..c0c252ab2 Binary files /dev/null and b/apps/api/src/config/Excel Template.xlsx differ diff --git a/apps/queue-manager/src/consumers/send-webhook-data.consumer.ts b/apps/queue-manager/src/consumers/send-webhook-data.consumer.ts index 07388fe3c..c193b5f9d 100644 --- a/apps/queue-manager/src/consumers/send-webhook-data.consumer.ts +++ b/apps/queue-manager/src/consumers/send-webhook-data.consumer.ts @@ -7,6 +7,7 @@ import { replaceVariablesInObject, FileNameService, ITemplateSchemaItem, + ColumnTypesEnum, } from '@impler/shared'; import { StorageService } from '@impler/shared/dist/services/storage'; import { @@ -62,6 +63,7 @@ export class SendWebhookDataConsumer extends BaseConsumer { recordFormat: cachedData.recordFormat, chunkFormat: cachedData.chunkFormat, totalRecords: allDataJson.length, + multiSelectHeadings: cachedData.multiSelectHeadings, }); const headers = @@ -113,12 +115,22 @@ export class SendWebhookDataConsumer extends BaseConsumer { chunkFormat, recordFormat, extra = '', + multiSelectHeadings, }: IBuildSendDataParameters): { sendData: Record; page: number } { const defaultValuesObj = JSON.parse(defaultValues); let slicedData = data.slice( Math.max((page - DEFAULT_PAGE) * chunkSize, MIN_LIMIT), Math.min(page * chunkSize, data.length) ); + if (Array.isArray(multiSelectHeadings) && multiSelectHeadings.length > 0) { + slicedData = slicedData.map((obj) => { + multiSelectHeadings.forEach((heading) => { + obj.record[heading] = obj.record[heading] ? obj.record[heading].split(',') : []; + }); + + return obj; + }); + } if (recordFormat) slicedData = slicedData.map((obj) => replaceVariablesInObject(JSON.parse(recordFormat), obj.record, defaultValuesObj) @@ -154,10 +166,12 @@ export class SendWebhookDataConsumer extends BaseConsumer { const webhookDestination = await this.webhookDestinationRepository.findOne({ _templateId: uploadata._templateId }); const defaultValueObj = {}; - const customSchema = JSON.parse(uploadata.customSchema) as ITemplateSchemaItem; + const multiSelectHeadings = []; + const customSchema = JSON.parse(uploadata.customSchema) as ITemplateSchemaItem[]; if (Array.isArray(customSchema)) { - customSchema.forEach((item) => { + customSchema.forEach((item: ITemplateSchemaItem) => { if (item.defaultValue) defaultValueObj[item.key] = item.defaultValue; + if (item.type === ColumnTypesEnum.SELECT && item.allowMultiSelect) multiSelectHeadings.push(item.key); }); } @@ -175,6 +189,7 @@ export class SendWebhookDataConsumer extends BaseConsumer { defaultValues: JSON.stringify(defaultValueObj), recordFormat: uploadata.customRecordFormat, chunkFormat: uploadata.customChunkFormat, + multiSelectHeadings, }; } diff --git a/apps/queue-manager/src/types/file-processing.types.ts b/apps/queue-manager/src/types/file-processing.types.ts index 11f7e391d..9216a0115 100644 --- a/apps/queue-manager/src/types/file-processing.types.ts +++ b/apps/queue-manager/src/types/file-processing.types.ts @@ -22,6 +22,7 @@ export interface IBuildSendDataParameters extends IBaseSendDataParameters { defaultValues: string; recordFormat?: string; chunkFormat?: string; + multiSelectHeadings?: string[]; } export interface ISendDataResponse { statusCode: number; diff --git a/apps/web/components/imports/forms/ColumnForm.tsx b/apps/web/components/imports/forms/ColumnForm.tsx index 0fa57f0e7..8eec96ef3 100644 --- a/apps/web/components/imports/forms/ColumnForm.tsx +++ b/apps/web/components/imports/forms/ColumnForm.tsx @@ -161,7 +161,11 @@ export function ColumnForm({ onSubmit, data, isLoading }: ColumnFormProps) { /> - {typeValue !== 'Select' && } + {typeValue !== 'Select' ? ( + + ) : ( + + )}