From 1ebdb16065e47cf482027181823d9c373da3fc1a Mon Sep 17 00:00:00 2001 From: Mayur Date: Tue, 22 Jul 2025 15:11:00 +0530 Subject: [PATCH 1/3] feat: Added maxRecords property from the useImpler --- .../create-userjob/create-userjob.usecase.ts | 1 - apps/api/src/app/review/review.controller.ts | 4 +- .../start-process/start-process.usecase.ts | 22 +++++++-- .../exceptions/max-records.exception.ts | 21 ++++++++ .../usecases/get-upload/get-upload.command.ts | 2 +- .../src/app/upload/dtos/upload-request.dto.ts | 10 +++- apps/api/src/app/upload/upload.controller.ts | 4 +- .../make-upload-entry.command.ts | 1 + .../make-upload-entry.usecase.ts | 48 +++++++++++++++++-- .../components/Common/Container/Container.tsx | 2 + .../components/Common/Provider/Provider.tsx | 3 ++ apps/widget/src/hooks/Phase1/usePhase1.ts | 18 +++++-- apps/widget/src/hooks/Phase3/usePhase3.tsx | 5 +- apps/widget/src/hooks/useCompleteImport.ts | 8 ++-- apps/widget/src/store/app.context.tsx | 2 + apps/widget/src/types/component.types.ts | 1 + apps/widget/src/types/store.types.ts | 1 + apps/widget/src/util/api/api.service.ts | 6 ++- libs/shared/src/types/widget/widget.types.ts | 1 + packages/client/src/config/texts.config.ts | 1 + packages/client/src/types.ts | 2 + packages/react/src/hooks/useImpler.ts | 2 + 22 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 apps/api/src/app/shared/exceptions/max-records.exception.ts diff --git a/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.usecase.ts b/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.usecase.ts index 2094bcd2b..d49002f9d 100644 --- a/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.usecase.ts +++ b/apps/api/src/app/import-jobs/usecase/create-userjob/create-userjob.usecase.ts @@ -25,7 +25,6 @@ export class CreateUserJob { try { const mimeType = await getMimeType(url); - console.log('mime type is >>', mimeType); if (isValidXMLMimeType(mimeType)) { const abortController = new AbortController(); diff --git a/apps/api/src/app/review/review.controller.ts b/apps/api/src/app/review/review.controller.ts index 03758aac1..f92285031 100644 --- a/apps/api/src/app/review/review.controller.ts +++ b/apps/api/src/app/review/review.controller.ts @@ -123,7 +123,7 @@ export class ReviewController { @ApiOperation({ summary: 'Confirm review data for uploaded file', }) - async doConfirmReview(@Param('uploadId', ValidateMongoId) _uploadId: string) { + async doConfirmReview(@Param('uploadId', ValidateMongoId) _uploadId: string, @Body() body: { maxRecords?: number }) { const uploadInformation = await this.getUpload.execute({ uploadId: _uploadId, select: 'status _validDataFileId _invalidDataFileId totalRecords invalidRecords _templateId', @@ -135,7 +135,7 @@ export class ReviewController { // upload files with status reviewing can only be confirmed validateUploadStatus(uploadInformation.status as UploadStatusEnum, [UploadStatusEnum.REVIEWING]); - return this.startProcess.execute(_uploadId); + return this.startProcess.execute(_uploadId, body.maxRecords); } @Put(':uploadId/record') diff --git a/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts b/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts index e14f3dff0..2386699db 100644 --- a/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts +++ b/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts @@ -12,6 +12,7 @@ import { PaymentAPIService } from '@impler/services'; import { QueueService } from '@shared/services/queue.service'; import { AmplitudeService } from '@shared/services/amplitude.service'; import { DalService, TemplateEntity, TemplateRepository, UploadEntity, UploadRepository } from '@impler/dal'; +import { MaxRecordsExceededException } from '@shared/exceptions/max-records.exception'; @Injectable() export class StartProcess { @@ -24,7 +25,7 @@ export class StartProcess { private templateRepository: TemplateRepository ) {} - async execute(_uploadId: string) { + async execute(_uploadId: string, maxRecords?: number) { let uploadInfo = await this.uploadRepository.getUploadWithTemplate(_uploadId, ['destination']); let importedData; const destination = (uploadInfo._templateId as unknown as TemplateEntity)?.destination; @@ -44,7 +45,7 @@ export class StartProcess { }); } - await this.updateTemplateStatistics({ uploadInfo, userEmail }); + await this.updateTemplateStatistics({ uploadInfo, userEmail, maxRecords }); // if destination is frontend or not defined then complete the upload process if ( @@ -117,7 +118,15 @@ export class StartProcess { return importedData; } - private async updateTemplateStatistics({ uploadInfo, userEmail }: { uploadInfo: UploadEntity; userEmail: string }) { + private async updateTemplateStatistics({ + uploadInfo, + userEmail, + maxRecords, + }: { + uploadInfo: UploadEntity; + userEmail: string; + maxRecords?: number; + }) { //if its a file based import do-review will handle the further process if (uploadInfo._uploadedFileId || uploadInfo.originalFileName) { return; @@ -134,7 +143,12 @@ export class StartProcess { }, } ); - + if (maxRecords && uploadInfo.totalRecords > maxRecords) { + throw new MaxRecordsExceededException({ + actualRecords: uploadInfo.totalRecords, + maxAllowed: maxRecords, + }); + } await this.paymentAPIService.createEvent( { uploadId: uploadInfo._id, diff --git a/apps/api/src/app/shared/exceptions/max-records.exception.ts b/apps/api/src/app/shared/exceptions/max-records.exception.ts new file mode 100644 index 000000000..62bfca29c --- /dev/null +++ b/apps/api/src/app/shared/exceptions/max-records.exception.ts @@ -0,0 +1,21 @@ +import { BadRequestException } from '@nestjs/common'; +import { numberFormatter } from '@impler/shared'; + +export class MaxRecordsExceededException extends BadRequestException { + constructor({ + actualRecords, + maxAllowed, + customMessage = null, + }: { + actualRecords: number; + maxAllowed: number; + customMessage?: string; + }) { + super( + customMessage || + `File exceeds maximum record limit: ${numberFormatter(actualRecords)} records detected, + but maximum allowed is ${numberFormatter(maxAllowed)} records. + Please reduce your data to ${numberFormatter(maxAllowed)} rows or less!` + ); + } +} diff --git a/apps/api/src/app/shared/usecases/get-upload/get-upload.command.ts b/apps/api/src/app/shared/usecases/get-upload/get-upload.command.ts index 4e38822ef..c2c879eec 100644 --- a/apps/api/src/app/shared/usecases/get-upload/get-upload.command.ts +++ b/apps/api/src/app/shared/usecases/get-upload/get-upload.command.ts @@ -1,5 +1,5 @@ export class GetUploadCommand { uploadId: string; - + maxRecords?: number; select?: string; } 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 098cb3c5f..fbd25931a 100644 --- a/apps/api/src/app/upload/dtos/upload-request.dto.ts +++ b/apps/api/src/app/upload/dtos/upload-request.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsJSON, IsMongoId, IsOptional, IsString } from 'class-validator'; +import { IsJSON, IsMongoId, IsNumberString, IsOptional, IsString } from 'class-validator'; export class UploadRequestDto { @ApiProperty({ @@ -61,4 +61,12 @@ export class UploadRequestDto { @IsOptional() @IsJSON() imageSchema?: string; + + @ApiProperty({ + description: 'Max Number of records to import', + required: false, + }) + @IsOptional() + @IsNumberString() + maxRecords?: string; } diff --git a/apps/api/src/app/upload/upload.controller.ts b/apps/api/src/app/upload/upload.controller.ts index ec46a3b7a..61037f35c 100644 --- a/apps/api/src/app/upload/upload.controller.ts +++ b/apps/api/src/app/upload/upload.controller.ts @@ -78,14 +78,14 @@ export class UploadController { @UploadedFile('file', ValidImportFile) file: Express.Multer.File ) { return this.makeUploadEntry.execute({ - file: file, + file, templateId, extra: body.extra, schema: body.schema, output: body.output, importId: body.importId, imageSchema: body.imageSchema, - + maxRecords: parseInt(body.maxRecords), authHeaderValue: body.authHeaderValue, selectedSheetName: body.selectedSheetName, }); 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 4984d5c9f..1888de8d5 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 @@ -8,4 +8,5 @@ export class MakeUploadEntryCommand { imageSchema?: string; authHeaderValue?: string; selectedSheetName?: string; + maxRecords?: number; } 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 9edcf741b..fc958348f 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 @@ -27,6 +27,7 @@ import { MakeUploadEntryCommand } from './make-upload-entry.command'; import { FileParseException } from '@shared/exceptions/file-parse-issue.exception'; import { CSVFileService2, ExcelFileService } from '@shared/services/file'; import { FileSizeException } from '@shared/exceptions/file-size-limit.exception'; +import { MaxRecordsExceededException } from '@shared/exceptions/max-records.exception'; @Injectable() export class MakeUploadEntry { @@ -52,6 +53,7 @@ export class MakeUploadEntry { importId, imageSchema, selectedSheetName, + maxRecords, }: MakeUploadEntryCommand) { const csvFileService = new CSVFileService2(); let fileOriginalName: string | undefined, csvFile: string | Express.Multer.File | undefined; @@ -66,17 +68,35 @@ export class MakeUploadEntry { try { const fileService = new ExcelFileService(); const opts = await fileService.getExcelRowsColumnsCount(file, selectedSheetName); - this.analyzeLargeFile(opts, true); + + // Check maxRecords restriction for Excel files + if (maxRecords !== undefined && maxRecords !== null && opts.rows > maxRecords) { + throw new MaxRecordsExceededException({ + actualRecords: opts.rows, + maxAllowed: maxRecords, + }); + } + + this.analyzeLargeFile(opts, true, maxRecords); csvFile = await fileService.convertToCsv(file, selectedSheetName); } catch (error) { - if (error instanceof FileSizeException) { + if (error instanceof FileSizeException || error instanceof MaxRecordsExceededException) { throw error; } throw new FileParseException(); } } else if (file.mimetype === FileMimeTypesEnum.CSV) { const opts = await csvFileService.getCSVMetaInfo(file); - this.analyzeLargeFile(opts, false); + + // Check maxRecords restriction for CSV files + if (maxRecords !== undefined && maxRecords !== null && opts.rows > maxRecords) { + throw new MaxRecordsExceededException({ + actualRecords: opts.rows, + maxAllowed: maxRecords, + }); + } + + this.analyzeLargeFile(opts, false, maxRecords); csvFile = file; } else { throw new Error('Invalid file type'); @@ -188,15 +208,35 @@ export class MakeUploadEntry { originalFileType: file?.mimetype, }); } + roundToNiceNumber(num: number) { const niceNumbers = [500, 1000, 5000, 10000, 50000, 100000, 500000, 1000000]; return niceNumbers.reduce((prev, curr) => (Math.abs(curr - num) < Math.abs(prev - num) ? curr : prev)); } - analyzeLargeFile(fileInfo: { rows: number; columns: number }, isExcel?: boolean, maxDataPoints = 5000000) { + + analyzeLargeFile( + fileInfo: { rows: number; columns: number }, + isExcel?: boolean, + maxRecords?: number, + maxDataPoints = 5000000 + ) { const { columns, rows } = fileInfo; const dataPoints = columns * rows; + // If maxRecords is specified, use it as the limit instead of maxDataPoints calculation + if (maxRecords !== undefined && maxRecords !== null) { + if (rows > maxRecords) { + throw new MaxRecordsExceededException({ + actualRecords: rows, + maxAllowed: maxRecords, + }); + } + + // If maxRecords is specified and file is within limit, skip the dataPoints check + return; + } + if (dataPoints > maxDataPoints) { let suggestedChunkSize = Math.floor(maxDataPoints / columns); suggestedChunkSize = this.roundToNiceNumber(suggestedChunkSize); diff --git a/apps/widget/src/components/Common/Container/Container.tsx b/apps/widget/src/components/Common/Container/Container.tsx index 49ae3f2b9..378bd97c5 100644 --- a/apps/widget/src/components/Common/Container/Container.tsx +++ b/apps/widget/src/components/Common/Container/Container.tsx @@ -64,6 +64,7 @@ export function Container({ children }: PropsWithChildren<{}>) { texts: deepMerge(WIDGET_TEXTS, data.value.texts), config: data.value.config, appearance: data.value.appearance, + maxRecords: data.value.maxRecords, schema: typeof data.value.schema === 'string' ? data.value.schema @@ -842,6 +843,7 @@ export function Container({ children }: PropsWithChildren<{}>) { templateId={secondaryPayload.templateId} authHeaderValue={secondaryPayload?.authHeaderValue} sampleFile={secondaryPayload?.sampleFile} + maxRecords={secondaryPayload.maxRecords} primaryColor={secondaryPayload.primaryColor ?? secondaryPayload.appearance?.primaryColor ?? primaryColor} > {children} diff --git a/apps/widget/src/components/Common/Provider/Provider.tsx b/apps/widget/src/components/Common/Provider/Provider.tsx index 3b9425b60..6bda25e40 100644 --- a/apps/widget/src/components/Common/Provider/Provider.tsx +++ b/apps/widget/src/components/Common/Provider/Provider.tsx @@ -19,6 +19,7 @@ interface IProviderProps { data?: string; host: string; showWidget: boolean; + maxRecords?: number; setShowWidget: (status: boolean) => void; // api-context api: ApiService; @@ -42,6 +43,7 @@ export function Provider(props: PropsWithChildren) { projectId, templateId, extra, + maxRecords, showWidget, setShowWidget, authHeaderValue, @@ -69,6 +71,7 @@ export function Provider(props: PropsWithChildren) { appearance={appearance} texts={texts} output={output} + maxRecords={maxRecords} schema={schema} showWidget={showWidget} primaryColor={primaryColor} diff --git a/apps/widget/src/hooks/Phase1/usePhase1.ts b/apps/widget/src/hooks/Phase1/usePhase1.ts index 1e52fb496..09b568f00 100644 --- a/apps/widget/src/hooks/Phase1/usePhase1.ts +++ b/apps/widget/src/hooks/Phase1/usePhase1.ts @@ -40,8 +40,19 @@ export function usePhase1({ goNext, texts, onManuallyEnterData }: IUsePhase1Prop const { templateId, authHeaderValue, extra } = useImplerState(); const [excelSheetNames, setExcelSheetNames] = useState([]); const [isDownloadInProgress, setIsDownloadInProgress] = useState(false); - const { setUploadInfo, setTemplateInfo, output, schema, data, importId, imageSchema, sampleFile, config } = - useAppState(); + + const { + setUploadInfo, + setTemplateInfo, + output, + schema, + data, + importId, + imageSchema, + sampleFile, + config, + maxRecords, + } = useAppState(); const selectedTemplateId = watch('templateId'); @@ -65,7 +76,7 @@ export function usePhase1({ goNext, texts, onManuallyEnterData }: IUsePhase1Prop }, onError(error: IErrorObject) { resetField('file'); - setError('file', { type: 'file', message: error.message }); + setError('file', { type: 'file', message: texts.PHASE3.MAX_RECORD_LIMIT_ERROR ?? error.message }); }, } ); @@ -153,6 +164,7 @@ export function usePhase1({ goNext, texts, onManuallyEnterData }: IUsePhase1Prop output, importId, imageSchema, + maxRecords, }); } }; diff --git a/apps/widget/src/hooks/Phase3/usePhase3.tsx b/apps/widget/src/hooks/Phase3/usePhase3.tsx index 51dc0a8e9..301f1fd3a 100644 --- a/apps/widget/src/hooks/Phase3/usePhase3.tsx +++ b/apps/widget/src/hooks/Phase3/usePhase3.tsx @@ -44,7 +44,7 @@ export function usePhase3({ onNext }: IUsePhase3Props) { valid: new Set(), invalid: new Set(), }); - const { uploadInfo, setUploadInfo, config } = useAppState(); + const { uploadInfo, setUploadInfo, config, maxRecords } = useAppState(); const [allChecked, setAllChecked] = useState(false); const [reviewData, setReviewData] = useState([]); const [columnDefs, setColumnDefs] = useState([]); @@ -196,7 +196,7 @@ export function usePhase3({ onNext }: IUsePhase3Props) { setTotalPages(reviewDataResponse.totalPages); }, onError(error: IErrorObject) { - notifier.showError({ message: error.message, title: error.error }); + notifier.showError({ message: 'Hellow World', title: error.error }); }, } ); @@ -277,6 +277,7 @@ export function usePhase3({ onNext }: IUsePhase3Props) { columns, headings, totalPages, + maxRecords, columnDefs, allChecked, reReviewData, diff --git a/apps/widget/src/hooks/useCompleteImport.ts b/apps/widget/src/hooks/useCompleteImport.ts index 63b606ca6..a501e2e0c 100644 --- a/apps/widget/src/hooks/useCompleteImport.ts +++ b/apps/widget/src/hooks/useCompleteImport.ts @@ -13,17 +13,19 @@ interface IUseCompleteImportProps { export const useCompleteImport = ({ onNext }: IUseCompleteImportProps) => { const { api } = useAPIState(); - const { uploadInfo, setUploadInfo, host } = useAppState(); + const { uploadInfo, setUploadInfo, host, maxRecords, texts } = useAppState(); const { isLoading: isCompleteImportLoading, mutate: completeImport } = useMutation< { email: string; uploadInfo: IUpload; importedData: Record[]; + maxRecords?: number; }, IErrorObject, void, [string] - >([`confirm:${uploadInfo._id}`], () => api.confirmReview(uploadInfo._id), { + // eslint-disable-next-line prettier/prettier + >([`confirm:${uploadInfo._id}`], () => api.confirmReview(uploadInfo._id, maxRecords), { onSuccess(uploadData) { logAmplitudeEvent('RECORDS', { type: 'invalid', @@ -41,7 +43,7 @@ export const useCompleteImport = ({ onNext }: IUseCompleteImportProps) => { onNext?.(uploadData.uploadInfo, uploadData.importedData); }, onError(error: IErrorObject) { - notifier.showError({ message: error.message, title: error.error }); + notifier.showError({ message: texts.PHASE3.MAX_RECORD_LIMIT_ERROR ?? error.message, title: error.error }); }, }); diff --git a/apps/widget/src/store/app.context.tsx b/apps/widget/src/store/app.context.tsx index 7471f8aa2..4b03a1c78 100644 --- a/apps/widget/src/store/app.context.tsx +++ b/apps/widget/src/store/app.context.tsx @@ -31,6 +31,7 @@ const AppContextProvider = ({ texts, config, appearance, + maxRecords, data, sampleFile, schema, @@ -59,6 +60,7 @@ const AppContextProvider = ({ texts, config, appearance, + maxRecords, host, reset, data, diff --git a/apps/widget/src/types/component.types.ts b/apps/widget/src/types/component.types.ts index f124e133f..a2221595f 100644 --- a/apps/widget/src/types/component.types.ts +++ b/apps/widget/src/types/component.types.ts @@ -51,6 +51,7 @@ export interface IUploadValues extends IFormvalues { output?: string; importId?: string; imageSchema?: string; + maxRecords?: number; } export interface IAutoImportValues { diff --git a/apps/widget/src/types/store.types.ts b/apps/widget/src/types/store.types.ts index 5a4cc358e..b99680c44 100644 --- a/apps/widget/src/types/store.types.ts +++ b/apps/widget/src/types/store.types.ts @@ -31,6 +31,7 @@ export interface IAppStore { texts: typeof WIDGET_TEXTS; config?: WidgetConfig; appearance?: AppearanceConfig; + maxRecords?: number; importId?: string; imageSchema?: string; data?: string; diff --git a/apps/widget/src/util/api/api.service.ts b/apps/widget/src/util/api/api.service.ts index 94569a524..fc4e53e32 100644 --- a/apps/widget/src/util/api/api.service.ts +++ b/apps/widget/src/util/api/api.service.ts @@ -72,6 +72,7 @@ export class ApiService { importId?: string; imageSchema?: string; selectedSheetName?: string; + maxRecords?: number; }) { const formData = new FormData(); if (data.file) formData.append('file', data.file); @@ -82,6 +83,7 @@ export class ApiService { if (data.selectedSheetName) formData.append('selectedSheetName', data.selectedSheetName); if (data.importId) formData.append('importId', data.importId); if (data.imageSchema) formData.append('imageSchema', data.imageSchema); + if (data.maxRecords) formData.append('maxRecords', data.maxRecords.toString()); return this.httpClient.post( `/upload/${data.templateId}`, @@ -126,8 +128,8 @@ export class ApiService { return this.httpClient.get(`/review/${uploadId}${queryString}`) as Promise; } - async confirmReview(uploadId: string) { - return this.httpClient.post(`/review/${uploadId}/confirm`) as Promise<{ + async confirmReview(uploadId: string, maxRecords?: number) { + return this.httpClient.post(`/review/${uploadId}/confirm`, { maxRecords }) as Promise<{ email: string; uploadInfo: IUpload; importedData: Record[]; diff --git a/libs/shared/src/types/widget/widget.types.ts b/libs/shared/src/types/widget/widget.types.ts index 8f97189cd..61651a436 100644 --- a/libs/shared/src/types/widget/widget.types.ts +++ b/libs/shared/src/types/widget/widget.types.ts @@ -7,6 +7,7 @@ export interface ICommonShowPayload { templateId?: string; authHeaderValue?: string; primaryColor?: string; + maxRecords?: number; appearance?: AppearanceConfig; colorScheme?: string; title?: string; diff --git a/packages/client/src/config/texts.config.ts b/packages/client/src/config/texts.config.ts index a63750268..064b598ac 100644 --- a/packages/client/src/config/texts.config.ts +++ b/packages/client/src/config/texts.config.ts @@ -109,6 +109,7 @@ export const WIDGET_TEXTS = { IN_COLUMN_LABEL: 'In Column', CASE_SENSITIVE_LABEL: 'Case Sensitive', MATCH_ENTIRE_LABEL: 'Match Entire Cell', + MAX_RECORD_LIMIT_ERROR: 'Max Record Limit Exceeded', }, PHASE4: { TITLE: 'Bravo! {count} rows have been uploaded', diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 8082ce7ec..35d34cfd2 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -168,6 +168,7 @@ export interface IShowWidgetProps { texts?: CustomTexts; config?: WidgetConfig; appearance?: AppearanceConfig; + maxRecords?: number; title?: string; primaryColor?: string; extra?: string | Record; @@ -217,6 +218,7 @@ export interface IUseImplerProps { extra?: string | Record; config?: WidgetConfig; appearance?: AppearanceConfig; + maxRecords?: number; authHeaderValue?: string | (() => string) | (() => Promise); onUploadStart?: (value: UploadTemplateData) => void; onUploadTerminate?: (value: UploadData) => void; diff --git a/packages/react/src/hooks/useImpler.ts b/packages/react/src/hooks/useImpler.ts index 58a9f9bc1..52f0bb1fc 100644 --- a/packages/react/src/hooks/useImpler.ts +++ b/packages/react/src/hooks/useImpler.ts @@ -11,6 +11,7 @@ export function useImpler({ texts, extra, config, + maxRecords, appearance, onUploadComplete, onWidgetClose, @@ -100,6 +101,7 @@ export function useImpler({ extra, config, appearance, + maxRecords, colorScheme, primaryColor, }; From 0ff0c16f03295cf0c6e2173383c9b1cb626ce7a9 Mon Sep 17 00:00:00 2001 From: Mayur Date: Wed, 23 Jul 2025 15:35:08 +0530 Subject: [PATCH 2/3] feat: Added the Deletion of Rows in manual entry of data --- .../start-process/start-process.usecase.ts | 61 +++-- .../Phases/DirectEntryImport/DataGrid.tsx | 212 +++++++++++++++--- apps/widget/src/hooks/DataGrid/useDataGrid.ts | 208 +++++++++++++++-- 3 files changed, 406 insertions(+), 75 deletions(-) diff --git a/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts b/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts index 2386699db..bd638a7b9 100644 --- a/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts +++ b/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts @@ -127,36 +127,51 @@ export class StartProcess { userEmail: string; maxRecords?: number; }) { - //if its a file based import do-review will handle the further process + // If it's a file based import do-review will handle the further process if (uploadInfo._uploadedFileId || uploadInfo.originalFileName) { return; } - await this.templateRepository.findOneAndUpdate( - { - _id: uploadInfo._templateId, - }, - { - $inc: { - totalUploads: uploadInfo.totalRecords, - totalRecords: uploadInfo.totalRecords, - totalInvalidRecords: uploadInfo.invalidRecords, - }, - } - ); - if (maxRecords && uploadInfo.totalRecords > maxRecords) { + + // Check max records limit BEFORE updating statistics + if (maxRecords && uploadInfo.validRecords > maxRecords) { throw new MaxRecordsExceededException({ actualRecords: uploadInfo.totalRecords, maxAllowed: maxRecords, }); } - await this.paymentAPIService.createEvent( - { - uploadId: uploadInfo._id, - totalRecords: uploadInfo.totalRecords, - validRecords: uploadInfo.validRecords, - invalidRecords: uploadInfo.invalidRecords, - }, - userEmail - ); + + // Validate that we're not updating with negative values + const recordsToAdd = Math.max(0, uploadInfo.totalRecords || 0); + const invalidRecordsToAdd = Math.max(0, uploadInfo.invalidRecords || 0); + const validRecordsToAdd = Math.max(0, uploadInfo.validRecords || 0); + + // Only update if we have positive values to add + if (validRecordsToAdd > 0) { + await this.templateRepository.findOneAndUpdate( + { + _id: uploadInfo._templateId, + }, + { + $inc: { + totalUploads: uploadInfo.totalRecords, + totalRecords: uploadInfo.totalRecords, + totalInvalidRecords: uploadInfo.invalidRecords, + }, + } + ); + } + + // Only create payment event if we have valid records + if (validRecordsToAdd > 0 || recordsToAdd > 0) { + await this.paymentAPIService.createEvent( + { + uploadId: uploadInfo._id, + totalRecords: recordsToAdd, + validRecords: validRecordsToAdd, + invalidRecords: invalidRecordsToAdd, + }, + userEmail + ); + } } } diff --git a/apps/widget/src/components/widget/Phases/DirectEntryImport/DataGrid.tsx b/apps/widget/src/components/widget/Phases/DirectEntryImport/DataGrid.tsx index b705a3691..ef61033bb 100644 --- a/apps/widget/src/components/widget/Phases/DirectEntryImport/DataGrid.tsx +++ b/apps/widget/src/components/widget/Phases/DirectEntryImport/DataGrid.tsx @@ -1,4 +1,4 @@ -import { Flex, Group, Stack } from '@mantine/core'; +import { Flex, Group, Stack, Badge } from '@mantine/core'; import { HotTableClass } from '@handsontable/react'; import { useEffect, useRef, useState } from 'react'; @@ -13,6 +13,7 @@ import { SegmentedControl } from '@ui/SegmentedControl'; import { useDataGrid } from '@hooks/DataGrid/useDataGrid'; import { useCompleteImport } from '@hooks/useCompleteImport'; import { FindReplaceModal } from 'components/widget/modals/FindReplace'; +import { ConfirmModal } from 'components/widget/modals/ConfirmModal'; import { numberFormatter, replaceVariablesInString } from '@impler/shared'; import { useBatchedUpdateRecord } from '@hooks/DataGrid/useBatchUpdateRecords'; @@ -30,31 +31,55 @@ export function DataGrid({ onNextClick, onPrevClick, texts }: IPhase12Props) { headings, reviewData, columnDefs, + allChecked, onTypeChange, reReviewData, totalRecords, setReviewData, + setAllChecked, frozenColumns, + deleteRecords, invalidRecords, + selectedRowsRef, isDoReviewLoading, isReviewDataLoading, + selectedRowsCountRef, showFindReplaceModal, + showDeleteConfirmModal, + isDeleteRecordLoading, setShowFindReplaceModal, + setShowDeleteConfirmModal, isTemplateColumnsLoading, + hideFindAndReplaceButton, + hideDeleteButton, + hideCheckBox, + getNextRecordIndex, // Add this method to get proper index } = useDataGrid({ limit: MANUAL_ENTRY_LIMIT }); + + const validRecordsCount = totalRecords - invalidRecords; + const hasValidRecords = validRecordsCount > 0; const [isPasteLoading, setIsPasteLoading] = useState(false); - const { updateRecord } = useBatchedUpdateRecord({ + + // Use a ref to track pending updates to avoid duplicate calls + const pendingUpdatesRef = useRef>(new Set()); + + const { updateRecord: batchUpdateRecord } = useBatchedUpdateRecord({ onUpdate: () => { reReviewData(); setIsPasteLoading(false); }, }); + const tableWrapperRef = useRef() as React.MutableRefObject; const { completeImport, isCompleteImportLoading } = useCompleteImport({ onNext: onNextClick }); const [tableWrapperDimensions, setTableWrapperDimentions] = useState({ height: 200, width: 500, }); + + // Generate column descriptions for the table + const columnDescriptions = columnDefs.map((column) => column.description || ''); + useEffect(() => { // setting wrapper height setTableWrapperDimentions({ @@ -63,9 +88,64 @@ export function DataGrid({ onNextClick, onPrevClick, texts }: IPhase12Props) { }); }, []); + // Fixed onValueChange handler + const handleValueChange = (row: number, prop: string, oldVal: any, newVal: any) => { + const name = String(prop).replace('record.', ''); + const currentData = [...reviewData]; + + // Skip if no actual change + if ( + oldVal === newVal || + (oldVal === '' && newVal === undefined) || + (newVal === '' && (oldVal === undefined || oldVal === null)) + ) { + return; + } + + if (currentData && currentData[row]) { + // Ensure proper index assignment for new records + if (!currentData[row].index) { + currentData[row].index = getNextRecordIndex(); + } + + // Create update key to prevent duplicates + const updateKey = `${currentData[row].index}-${name}-${Date.now()}`; + + // Check if this update is already pending + if (pendingUpdatesRef.current.has(updateKey)) { + return; + } + + pendingUpdatesRef.current.add(updateKey); + + if (!currentData[row].updated) { + currentData[row].updated = {}; + } + + currentData[row].record[name] = newVal === '' ? null : newVal; + currentData[row].updated[name] = true; + setReviewData(currentData); + + // Use only one update method to avoid double counting + const recordUpdate = { + index: currentData[row].index, + record: currentData[row].record, + updated: currentData[row].updated, + }; + + // Use batch update for better performance and consistency + batchUpdateRecord(recordUpdate); + + // Clean up pending update after a delay + setTimeout(() => { + pendingUpdatesRef.current.delete(updateKey); + }, 1000); + } + }; + return ( <> - + @@ -82,56 +162,93 @@ export function DataGrid({ onNextClick, onPrevClick, texts }: IPhase12Props) { { value: 'valid', label: replaceVariablesInString(texts.PHASE3.LABEL_VALID_RECORDS, { - records: numberFormatter(totalRecords - invalidRecords), + records: numberFormatter(Math.max(0, totalRecords - invalidRecords)), }), }, { value: 'invalid', label: replaceVariablesInString(texts.PHASE3.LABEL_INVALID_RECORDS, { - records: numberFormatter(invalidRecords), + records: numberFormatter(Math.max(0, invalidRecords)), }), }, ]} /> - + {!hideFindAndReplaceButton && ( + + )} + {!hideDeleteButton && ( + + )} setIsPasteLoading(true)} - onValueChange={(row, prop, oldVal, newVal) => { - const name = String(prop).replace('record.', ''); + onValueChange={handleValueChange} + // Add row selection handlers from Phase3 + onRowCheck={(rowIndex, recordIndex, checked) => { + const currentData = [...reviewData]; + currentData[rowIndex].checked = checked; + const newSelectedRowsCountRef = { ...selectedRowsCountRef.current }; + if (checked) { + selectedRowsRef.current.add(recordIndex); + if (currentData[rowIndex].isValid) newSelectedRowsCountRef.valid.add(recordIndex); + else newSelectedRowsCountRef.invalid.add(recordIndex); + } else { + selectedRowsRef.current.delete(recordIndex); + if (currentData[rowIndex].isValid) newSelectedRowsCountRef.valid.delete(recordIndex); + else newSelectedRowsCountRef.invalid.delete(recordIndex); + } + + setReviewData(currentData); + selectedRowsCountRef.current = newSelectedRowsCountRef; + setAllChecked(selectedRowsRef.current.size === currentData.length); + }} + onCheckAll={(checked) => { + setAllChecked(checked); const currentData = [...reviewData]; - if ( - currentData && - currentData[row] && - oldVal != newVal && - !(oldVal === '' && newVal === undefined) && - !(newVal === '' && (oldVal === undefined || oldVal === null)) - ) { - if (!currentData[row].updated) { - currentData[row].updated = {}; - } - if (!currentData[row].index) { - currentData[row].index = row + 1; + currentData.forEach((record) => { + record.checked = checked; + }); + + const newSelectedRows = selectedRowsRef.current; + const newSelectedRowsCountRef = { ...selectedRowsCountRef.current }; + + currentData.forEach((record) => { + if (checked) { + newSelectedRows.add(record.index); + if (record.isValid) newSelectedRowsCountRef.valid.add(record.index); + else newSelectedRowsCountRef.invalid.add(record.index); + } else { + newSelectedRows.delete(record.index); + if (record.isValid) newSelectedRowsCountRef.valid.delete(record.index); + else newSelectedRowsCountRef.invalid.delete(record.index); } - currentData[row].record[name] = newVal === '' ? null : newVal; - currentData[row].updated[name] = true; - setReviewData(currentData); - updateRecord({ - index: currentData[row].index, - record: currentData[row].record, - updated: currentData[row].updated, - }); - } + }); + + selectedRowsRef.current = newSelectedRows; + selectedRowsCountRef.current = newSelectedRowsCountRef; + setReviewData(currentData); }} frozenColumns={frozenColumns} width={tableWrapperDimensions.width} @@ -144,8 +261,37 @@ export function DataGrid({ onNextClick, onPrevClick, texts }: IPhase12Props) { onNextClick={completeImport} active={PhasesEnum.MANUAL_ENTRY} primaryButtonLoading={isDoReviewLoading || isReviewDataLoading || isCompleteImportLoading} - primaryButtonTooltip={invalidRecords > 0 ? texts['PHASE1-2'].FIX_INVALID_DATA : undefined} - primaryButtonDisabled={invalidRecords > 0 || totalRecords === 0} + primaryButtonTooltip={ + invalidRecords > 0 + ? texts['PHASE1-2'].FIX_INVALID_DATA + : !hasValidRecords + ? 'No valid records to import' + : undefined + } + primaryButtonDisabled={ + // Only disable if there are invalid records OR no valid records at all + invalidRecords > 0 || !hasValidRecords + } + /> + + {/* Delete Confirmation Modal */} + setShowDeleteConfirmModal(false)} + title={replaceVariablesInString(texts.DELETE_RECORDS_CONFIRMATION.TITLE, { + total: numberFormatter(selectedRowsRef.current.size), + })} + onConfirm={() => { + setShowDeleteConfirmModal(false); + deleteRecords([ + Array.from(selectedRowsRef.current), + selectedRowsCountRef.current.valid.size, + selectedRowsCountRef.current.invalid.size, + ]); + }} + cancelLabel={texts.DELETE_RECORDS_CONFIRMATION.CANCEL_DELETE} + confirmLabel={texts.DELETE_RECORDS_CONFIRMATION.CONFIRM_DELETE} + opened={!!showDeleteConfirmModal} + subTitle={texts.DELETE_RECORDS_CONFIRMATION.DETAILS} /> ([]); + const selectedRowsRef = useRef>(new Set()); + const selectedRowsCountRef = useRef<{ valid: Set; invalid: Set }>({ + valid: new Set(), + invalid: new Set(), + }); + const [allChecked, setAllChecked] = useState(false); const [headings, setHeadings] = useState([]); const [frozenColumns, setFrozenColumns] = useState(2); const [columnDefs, setColumnDefs] = useState([]); const [type, setType] = useState(ReviewDataTypesEnum.ALL); const [showFindReplaceModal, setShowFindReplaceModal] = useState(undefined); - const [reviewData, setReviewData] = useState( - data - ? JSON.parse(data).map((record: Record, index: number) => ({ - index, + const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(undefined); + + // Track the highest index to ensure proper indexing for new records + const maxIndexRef = useRef(0); + + // Enhanced reviewData with selection state and proper indexing + const [reviewData, setReviewData] = useState(() => { + if (data) { + const parsedData = JSON.parse(data); + const initialData = parsedData.map((record: Record, arrayIndex: number) => { + // Use a proper index system - start from existing max or 1 + const recordIndex = record.index || arrayIndex + 1; + maxIndexRef.current = Math.max(maxIndexRef.current, recordIndex); + + return { + index: recordIndex, record, updated: {}, isValid: false, - })) - : [ - { - index: 1, - isValid: false, - record: {}, - updated: {}, - }, - ] - ); + checked: false, + }; + }); + + return initialData; + } else { + // For manual entry, start with index 1 + maxIndexRef.current = 1; + + return [ + { + index: 1, + isValid: false, + record: {}, + updated: {}, + checked: false, + }, + ]; + } + }); + + // Method to get next available index for new records + const getNextRecordIndex = () => { + maxIndexRef.current += 1; + + return maxIndexRef.current; + }; const { data: templateColumns, isLoading: isTemplateColumnsLoading } = useQuery< unknown, @@ -71,7 +115,18 @@ export function useDataGrid({ limit }: IDataGridProps) { { cacheTime: 0, onSuccess(reviewDataResponse) { - setReviewData(reviewDataResponse.data); + // Update max index based on fetched data + const maxFetchedIndex = Math.max(...reviewDataResponse.data.map((record) => record.index), maxIndexRef.current); + maxIndexRef.current = maxFetchedIndex; + + // Enhanced to include selection state + setReviewData( + reviewDataResponse.data.map((record) => ({ + ...record, + checked: selectedRowsRef.current.has(record.index), + })) + ); + setAllChecked(reviewDataResponse.data.every((record) => selectedRowsRef.current.has(record.index))); logAmplitudeEvent('VALIDATE', { invalidRecords: reviewDataResponse.totalRecords, }); @@ -94,12 +149,82 @@ export function useDataGrid({ limit }: IDataGridProps) { onSuccess() { fetchUploadInfo(); refetchReviewData(type); + // Clear selections on re-review + selectedRowsRef.current = new Set(); + selectedRowsCountRef.current = { valid: new Set(), invalid: new Set() }; }, onError(error: IErrorObject) { notifier.showError({ message: error.message, title: error.error }); }, }); + // Add mutation for updating records with better error handling + const { mutate: updateRecord } = useMutation, [string]>( + [`update`], + (record) => api.updateRecord(uploadInfo._id, record), + { + onError(error: IErrorObject) { + console.error('Update record error:', error); + notifier.showError({ + message: error.message || 'Failed to update record', + title: 'Update Error', + }); + }, + } + ); + + // Add mutation for deleting records with improved statistics handling + const { mutate: deleteRecords, isLoading: isDeleteRecordLoading } = useMutation< + unknown, + IErrorObject, + [number[], number, number], + [string] + >([`delete`], ([indexes, valid, invalid]) => api.deleteRecord(uploadInfo._id, indexes, valid, invalid), { + onSuccess(apiResponseData, vars) { + selectedRowsRef.current.clear(); + selectedRowsCountRef.current = { valid: new Set(), invalid: new Set() }; + + const [deletedIndexes, validDeleted, invalidDeleted] = vars; + + // Update upload info with proper bounds checking + const newUploadInfo = { ...uploadInfo }; + newUploadInfo.totalRecords = Math.max(0, (newUploadInfo.totalRecords || 0) - deletedIndexes.length); + + if (validDeleted > 0) { + newUploadInfo.validRecords = Math.max(0, (newUploadInfo.validRecords || 0) - validDeleted); + } + if (invalidDeleted > 0) { + newUploadInfo.invalidRecords = Math.max(0, (newUploadInfo.invalidRecords || 0) - invalidDeleted); + } + + // Filter out deleted records from review data + const newReviewData = reviewData.filter((record) => !deletedIndexes.includes(record.index)); + + setUploadInfo(newUploadInfo); + setReviewData(newReviewData); + setShowDeleteConfirmModal(false); + + logAmplitudeEvent('RECORDS_DELETED', { + valid: validDeleted, + invalid: invalidDeleted, + }); + + notifier.showError({ + message: `Successfully deleted ${numberFormatter(validDeleted)} valid and ${numberFormatter( + invalidDeleted + )} invalid records. Total ${numberFormatter(validDeleted + invalidDeleted)} records deleted.`, + title: `${numberFormatter(validDeleted + invalidDeleted)} records deleted.`, + }); + }, + onError(error: IErrorObject) { + console.error('Delete records error:', error); + notifier.showError({ + message: error.message || 'Failed to delete records', + title: 'Delete Error', + }); + }, + }); + const onTypeChange = (newType: ReviewDataTypesEnum) => { setType(newType); refetchReviewData(newType); @@ -111,6 +236,36 @@ export function useDataGrid({ limit }: IDataGridProps) { const dataColumns: IOption[] = [{ value: '', label: 'All columns' }]; const newColumnDefs: HotItemSchema[] = []; const newHeadings: string[] = []; + + // Add checkbox column if not hidden + if (!config?.hideCheckBox) { + newHeadings.push(''); + updatedFrozenColumns++; + newColumnDefs.push({ + type: 'text', + data: 'record.index', + readOnly: true, + editor: false, + renderer: 'check', + className: 'check-cell', + disableVisualSelection: true, + }); + } + + // Add Sr No column if not hidden + if (!config?.hideSrNo) { + newHeadings.push('Sr. No.'); + updatedFrozenColumns++; + newColumnDefs.push({ + type: 'text', + data: 'index', + readOnly: true, + className: 'index-cell', + disableVisualSelection: true, + }); + } + + // Add template columns templateColumns.forEach((column: IColumn) => { if (column.isFrozen) updatedFrozenColumns++; newHeadings.push(column.name); @@ -177,16 +332,31 @@ export function useDataGrid({ limit }: IDataGridProps) { headings, reviewData, columnDefs, + allChecked, onTypeChange, reReviewData, frozenColumns, setReviewData, + setAllChecked, + updateRecord, + deleteRecords, + selectedRowsRef, isDoReviewLoading, isReviewDataLoading, + selectedRowsCountRef, showFindReplaceModal, + showDeleteConfirmModal, + isDeleteRecordLoading, setShowFindReplaceModal, + setShowDeleteConfirmModal, isTemplateColumnsLoading, - totalRecords: uploadInfo.totalRecords ?? undefined, - invalidRecords: uploadInfo.invalidRecords ?? undefined, + getNextRecordIndex, // Export this method + // Ensure non-negative values + totalRecords: Math.max(0, uploadInfo.totalRecords ?? 0), + invalidRecords: Math.max(0, uploadInfo.invalidRecords ?? 0), + // Add config flags from usePhase3 + hideFindAndReplaceButton: config?.hideFindAndReplaceButton, + hideDeleteButton: config?.hideDeleteButton, + hideCheckBox: config?.hideCheckBox, }; } From 832f49beb148a2ae882ead553c1d0f6999d64502 Mon Sep 17 00:00:00 2001 From: Mayur Date: Thu, 24 Jul 2025 17:55:47 +0530 Subject: [PATCH 3/3] feat: Added the limit on manual entry of records --- apps/api/src/app/review/review.controller.ts | 3 +- .../delete-record/delete-record.usecase.ts | 66 +++++++++++++---- .../get-upload-data.usecase.ts | 2 +- .../start-process/start-process.usecase.ts | 3 +- .../exceptions/max-records.exception.ts | 17 +---- .../make-upload-entry.usecase.ts | 3 - .../Common/Table/HandsonTable.styles.min.css | 13 ++++ .../Phases/DirectEntryImport/DataGrid.tsx | 71 ++++++++++++++----- apps/widget/src/hooks/Phase1/usePhase1.ts | 7 +- packages/client/src/config/texts.config.ts | 2 +- 10 files changed, 134 insertions(+), 53 deletions(-) diff --git a/apps/api/src/app/review/review.controller.ts b/apps/api/src/app/review/review.controller.ts index f92285031..272c811b8 100644 --- a/apps/api/src/app/review/review.controller.ts +++ b/apps/api/src/app/review/review.controller.ts @@ -86,6 +86,7 @@ export class ReviewController { const uploadData = await this.getUpload.execute({ uploadId: _uploadId, }); + if (!uploadData) throw new BadRequestException(APIMessages.UPLOAD_NOT_FOUND); return await this.getFileInvalidData.execute( @@ -168,7 +169,7 @@ export class ReviewController { @Body() { indexes, valid, invalid }: DeleteRecordsDto, @Param('uploadId', ValidateMongoId) _uploadId: string ) { - await this.deleteRecord.execute(_uploadId, indexes, valid, invalid); + return await this.deleteRecord.execute(_uploadId, indexes, valid, invalid); } @Put(':uploadId/replace') diff --git a/apps/api/src/app/review/usecases/delete-record/delete-record.usecase.ts b/apps/api/src/app/review/usecases/delete-record/delete-record.usecase.ts index 728b4c596..7d6ce260a 100644 --- a/apps/api/src/app/review/usecases/delete-record/delete-record.usecase.ts +++ b/apps/api/src/app/review/usecases/delete-record/delete-record.usecase.ts @@ -8,21 +8,61 @@ export class DeleteRecord { private uploadRepository: UploadRepository ) {} - async execute(_uploadId: string, indexes: number[], valid: number, invalid: number) { - await this.dalService.deleteRecords(_uploadId, indexes); - if (typeof valid !== 'undefined' && typeof invalid !== 'undefined') { - await this.uploadRepository.update( - { - _id: _uploadId, - }, - { - $inc: { - totalRecords: -indexes.length, - validRecords: -valid, - invalidRecords: -invalid, + async execute( + uploadId: string, + recordIndexesToDelete: number[], + validRecordsToDelete: number, + invalidRecordsToDelete: number + ) { + await this.dalService.deleteRecords(uploadId, recordIndexesToDelete); + + if (typeof validRecordsToDelete !== 'undefined' && typeof invalidRecordsToDelete !== 'undefined') { + // Get current upload statistics to prevent negative values + const currentUploadData = await this.uploadRepository.findOne({ _id: uploadId }); + + if (currentUploadData) { + // Calculate safe decrement values to prevent database corruption + const safeValidRecordsDecrement = Math.min(validRecordsToDelete, currentUploadData.validRecords || 0); + const safeInvalidRecordsDecrement = Math.min(invalidRecordsToDelete, currentUploadData.invalidRecords || 0); + const safeTotalRecordsDecrement = Math.min(recordIndexesToDelete.length, currentUploadData.totalRecords || 0); + + await this.uploadRepository.update( + { + _id: uploadId, }, + { + $inc: { + totalRecords: -safeTotalRecordsDecrement, + validRecords: -safeValidRecordsDecrement, + invalidRecords: -safeInvalidRecordsDecrement, + }, + } + ); + + // Double-check and ensure no negative values exist in database + const updatedUploadData = await this.uploadRepository.findOne({ _id: uploadId }); + if (updatedUploadData) { + const fieldsToCorrect: any = {}; + let requiresCorrection = false; + + if ((updatedUploadData.totalRecords || 0) < 0) { + fieldsToCorrect.totalRecords = 0; + requiresCorrection = true; + } + if ((updatedUploadData.validRecords || 0) < 0) { + fieldsToCorrect.validRecords = 0; + requiresCorrection = true; + } + if ((updatedUploadData.invalidRecords || 0) < 0) { + fieldsToCorrect.invalidRecords = 0; + requiresCorrection = true; + } + + if (requiresCorrection) { + await this.uploadRepository.update({ _id: uploadId }, { $set: fieldsToCorrect }); + } } - ); + } } } } diff --git a/apps/api/src/app/review/usecases/get-upload-data/get-upload-data.usecase.ts b/apps/api/src/app/review/usecases/get-upload-data/get-upload-data.usecase.ts index b16880c86..524f83227 100644 --- a/apps/api/src/app/review/usecases/get-upload-data/get-upload-data.usecase.ts +++ b/apps/api/src/app/review/usecases/get-upload-data/get-upload-data.usecase.ts @@ -16,7 +16,7 @@ export class GetUploadData { const data = await this.dalService.getRecords(_uploadId, page, limit, type); return { - data, + data: data.sort((a, b) => a.index - b.index), limit, page, totalRecords, diff --git a/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts b/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts index bd638a7b9..016b833fd 100644 --- a/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts +++ b/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts @@ -135,7 +135,6 @@ export class StartProcess { // Check max records limit BEFORE updating statistics if (maxRecords && uploadInfo.validRecords > maxRecords) { throw new MaxRecordsExceededException({ - actualRecords: uploadInfo.totalRecords, maxAllowed: maxRecords, }); } @@ -155,7 +154,7 @@ export class StartProcess { $inc: { totalUploads: uploadInfo.totalRecords, totalRecords: uploadInfo.totalRecords, - totalInvalidRecords: uploadInfo.invalidRecords, + totalInvalidRecords: Math.max(0, uploadInfo.invalidRecords), }, } ); diff --git a/apps/api/src/app/shared/exceptions/max-records.exception.ts b/apps/api/src/app/shared/exceptions/max-records.exception.ts index 62bfca29c..0efd8e873 100644 --- a/apps/api/src/app/shared/exceptions/max-records.exception.ts +++ b/apps/api/src/app/shared/exceptions/max-records.exception.ts @@ -2,20 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { numberFormatter } from '@impler/shared'; export class MaxRecordsExceededException extends BadRequestException { - constructor({ - actualRecords, - maxAllowed, - customMessage = null, - }: { - actualRecords: number; - maxAllowed: number; - customMessage?: string; - }) { - super( - customMessage || - `File exceeds maximum record limit: ${numberFormatter(actualRecords)} records detected, - but maximum allowed is ${numberFormatter(maxAllowed)} records. - Please reduce your data to ${numberFormatter(maxAllowed)} rows or less!` - ); + constructor({ maxAllowed, customMessage = null }: { maxAllowed: number; customMessage?: string }) { + super(customMessage || `You can nor import records more than ${numberFormatter(maxAllowed)} records.`); } } 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 fc958348f..b243643e7 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 @@ -72,7 +72,6 @@ export class MakeUploadEntry { // Check maxRecords restriction for Excel files if (maxRecords !== undefined && maxRecords !== null && opts.rows > maxRecords) { throw new MaxRecordsExceededException({ - actualRecords: opts.rows, maxAllowed: maxRecords, }); } @@ -91,7 +90,6 @@ export class MakeUploadEntry { // Check maxRecords restriction for CSV files if (maxRecords !== undefined && maxRecords !== null && opts.rows > maxRecords) { throw new MaxRecordsExceededException({ - actualRecords: opts.rows, maxAllowed: maxRecords, }); } @@ -228,7 +226,6 @@ export class MakeUploadEntry { if (maxRecords !== undefined && maxRecords !== null) { if (rows > maxRecords) { throw new MaxRecordsExceededException({ - actualRecords: rows, maxAllowed: maxRecords, }); } diff --git a/apps/widget/src/components/Common/Table/HandsonTable.styles.min.css b/apps/widget/src/components/Common/Table/HandsonTable.styles.min.css index 153eb0fdd..45377d190 100644 --- a/apps/widget/src/components/Common/Table/HandsonTable.styles.min.css +++ b/apps/widget/src/components/Common/Table/HandsonTable.styles.min.css @@ -410,4 +410,17 @@ background-color: '#f1f3f5'; .htContextMenu tbody tr:hover td { background-color: var(--button-secondary-background-hover) !important; color: var(--text-color) !important; +} +/* Tooltip box */ +.tippy-box[data-theme~="custom"] { + background-color: var(--text-color) !important; + border: 1px solid #000000 !important; + border-radius: var(--border-radius) !important; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important; +} + +/* Tooltip content */ +.tippy-content { + padding: 8px 12px !important; + color: var(--background-color) !important; } \ No newline at end of file diff --git a/apps/widget/src/components/widget/Phases/DirectEntryImport/DataGrid.tsx b/apps/widget/src/components/widget/Phases/DirectEntryImport/DataGrid.tsx index ef61033bb..a75ddb73b 100644 --- a/apps/widget/src/components/widget/Phases/DirectEntryImport/DataGrid.tsx +++ b/apps/widget/src/components/widget/Phases/DirectEntryImport/DataGrid.tsx @@ -207,6 +207,15 @@ export function DataGrid({ onNextClick, onPrevClick, texts }: IPhase12Props) { // Add row selection handlers from Phase3 onRowCheck={(rowIndex, recordIndex, checked) => { const currentData = [...reviewData]; + + const isEmptyRow = Object.values(currentData[rowIndex].record || {}).every((value) => { + return value === '' || value === null || value === undefined; + }); + + if (isEmptyRow && checked) { + return; + } + currentData[rowIndex].checked = checked; const newSelectedRowsCountRef = { ...selectedRowsCountRef.current }; @@ -222,27 +231,39 @@ export function DataGrid({ onNextClick, onPrevClick, texts }: IPhase12Props) { setReviewData(currentData); selectedRowsCountRef.current = newSelectedRowsCountRef; - setAllChecked(selectedRowsRef.current.size === currentData.length); + setAllChecked( + selectedRowsRef.current.size === + currentData.filter( + (row) => + !Object.values(row.record || {}).every( + (value) => value === '' || value === null || value === undefined + ) + ).length + ); }} onCheckAll={(checked) => { setAllChecked(checked); const currentData = [...reviewData]; - currentData.forEach((record) => { - record.checked = checked; - }); + const newSelectedRows = new Set(); + const newSelectedRowsCountRef = { valid: new Set(), invalid: new Set() }; - const newSelectedRows = selectedRowsRef.current; - const newSelectedRowsCountRef = { ...selectedRowsCountRef.current }; + currentData.forEach((record, index) => { + // Fixed: Added return statement for empty row detection + const isEmptyRow = Object.values(record.record || {}).every((value) => { + return value === '' || value === null || value === undefined; + }); + + if (isEmptyRow) { + currentData[index].checked = false; - currentData.forEach((record) => { + return; // Skip empty rows + } + + currentData[index].checked = checked; if (checked) { newSelectedRows.add(record.index); if (record.isValid) newSelectedRowsCountRef.valid.add(record.index); else newSelectedRowsCountRef.invalid.add(record.index); - } else { - newSelectedRows.delete(record.index); - if (record.isValid) newSelectedRowsCountRef.valid.delete(record.index); - else newSelectedRowsCountRef.invalid.delete(record.index); } }); @@ -282,11 +303,29 @@ export function DataGrid({ onNextClick, onPrevClick, texts }: IPhase12Props) { })} onConfirm={() => { setShowDeleteConfirmModal(false); - deleteRecords([ - Array.from(selectedRowsRef.current), - selectedRowsCountRef.current.valid.size, - selectedRowsCountRef.current.invalid.size, - ]); + + // Filter out empty rows before deletion + const nonEmptySelectedRows = Array.from(selectedRowsRef.current).filter((index) => { + const record = reviewData.find((reviewDatas) => reviewDatas.index === index); + + return ( + record && + !Object.values(record.record || {}).every( + (value) => value === '' || value === null || value === undefined + ) + ); + }); + + // Update the selected rows ref to only include non-empty rows + selectedRowsRef.current = new Set(nonEmptySelectedRows); + + // Recalculate valid/invalid counts for non-empty rows only + const validCount = nonEmptySelectedRows.filter( + (index) => reviewData.find((reviewDataa) => reviewDataa.index === index)?.isValid + ).length; + const invalidCount = nonEmptySelectedRows.length - validCount; + + deleteRecords([nonEmptySelectedRows, validCount, invalidCount]); }} cancelLabel={texts.DELETE_RECORDS_CONFIRMATION.CANCEL_DELETE} confirmLabel={texts.DELETE_RECORDS_CONFIRMATION.CONFIRM_DELETE} diff --git a/apps/widget/src/hooks/Phase1/usePhase1.ts b/apps/widget/src/hooks/Phase1/usePhase1.ts index 09b568f00..bf27471ce 100644 --- a/apps/widget/src/hooks/Phase1/usePhase1.ts +++ b/apps/widget/src/hooks/Phase1/usePhase1.ts @@ -76,7 +76,12 @@ export function usePhase1({ goNext, texts, onManuallyEnterData }: IUsePhase1Prop }, onError(error: IErrorObject) { resetField('file'); - setError('file', { type: 'file', message: texts.PHASE3.MAX_RECORD_LIMIT_ERROR ?? error.message }); + setError('file', { + type: 'file', + message: maxRecords + ? `${texts.PHASE3.MAX_RECORD_LIMIT_ERROR} ${maxRecords}` + : texts.PHASE3.MAX_RECORD_LIMIT_ERROR ?? error.message, + }); }, } ); diff --git a/packages/client/src/config/texts.config.ts b/packages/client/src/config/texts.config.ts index 064b598ac..a96d4de99 100644 --- a/packages/client/src/config/texts.config.ts +++ b/packages/client/src/config/texts.config.ts @@ -109,7 +109,7 @@ export const WIDGET_TEXTS = { IN_COLUMN_LABEL: 'In Column', CASE_SENSITIVE_LABEL: 'Case Sensitive', MATCH_ENTIRE_LABEL: 'Match Entire Cell', - MAX_RECORD_LIMIT_ERROR: 'Max Record Limit Exceeded', + MAX_RECORD_LIMIT_ERROR: 'You can not import records more than', }, PHASE4: { TITLE: 'Bravo! {count} rows have been uploaded',