diff --git a/apps/api/src/app/review/dtos/index.ts b/apps/api/src/app/review/dtos/index.ts index 854d28d0f..a9836f29c 100644 --- a/apps/api/src/app/review/dtos/index.ts +++ b/apps/api/src/app/review/dtos/index.ts @@ -1,2 +1,3 @@ export * from './delete-records.dto'; export * from './update-cell.dto'; +export * from './replace.dto'; diff --git a/apps/api/src/app/review/dtos/replace.dto.ts b/apps/api/src/app/review/dtos/replace.dto.ts new file mode 100644 index 000000000..124995e10 --- /dev/null +++ b/apps/api/src/app/review/dtos/replace.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsOptional } from 'class-validator'; + +export class ReplaceDto { + @ApiProperty({ + description: 'Find value', + }) + @IsOptional() + find: string; + + @ApiProperty({ + description: 'Replace value', + }) + @IsOptional() + replace: string; + + @ApiProperty({ + description: 'Column name', + }) + @IsDefined() + column: string; + + @ApiProperty({ + description: 'Case sensitive', + }) + @IsOptional() + caseSensitive: boolean; + + @ApiProperty({ + description: 'Match entire cell', + }) + @IsOptional() + matchEntireCell: boolean; +} diff --git a/apps/api/src/app/review/review.controller.ts b/apps/api/src/app/review/review.controller.ts index 79a66e7a7..377af6394 100644 --- a/apps/api/src/app/review/review.controller.ts +++ b/apps/api/src/app/review/review.controller.ts @@ -6,9 +6,18 @@ import { JwtAuthGuard } from '@shared/framework/auth.gaurd'; import { validateUploadStatus } from '@shared/helpers/upload.helpers'; import { Defaults, ACCESS_KEY_NAME, UploadStatusEnum, ReviewDataTypesEnum } from '@impler/shared'; -import { DoReview, GetUpload, DoReReview, UpdateRecord, StartProcess, DeleteRecord, GetUploadData } from './usecases'; +import { + Replace, + DoReview, + GetUpload, + DoReReview, + UpdateRecord, + StartProcess, + DeleteRecord, + GetUploadData, +} from './usecases'; -import { DeleteRecordsDto, UpdateCellDto } from './dtos'; +import { DeleteRecordsDto, UpdateCellDto, ReplaceDto } from './dtos'; import { validateNotFound } from '@shared/helpers/common.helper'; import { PaginationResponseDto } from '@shared/dtos/pagination-response.dto'; import { ValidateMongoId } from '@shared/validations/valid-mongo-id.validation'; @@ -18,6 +27,7 @@ import { ValidateMongoId } from '@shared/validations/valid-mongo-id.validation'; @ApiSecurity(ACCESS_KEY_NAME) export class ReviewController { constructor( + private replace: Replace, private doReview: DoReview, private getUpload: GetUpload, private doReReview: DoReReview, @@ -135,4 +145,13 @@ export class ReviewController { ) { await this.deleteRecord.execute(_uploadId, indexes, valid, invalid); } + + @Put(':uploadId/replace') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Replace review data for ongoing import', + }) + async replaceReviewData(@Param('uploadId', ValidateMongoId) _uploadId: string, @Body() body: ReplaceDto) { + return await this.replace.execute(_uploadId, body); + } } diff --git a/apps/api/src/app/review/usecases/index.ts b/apps/api/src/app/review/usecases/index.ts index fcae5a801..d9b775659 100644 --- a/apps/api/src/app/review/usecases/index.ts +++ b/apps/api/src/app/review/usecases/index.ts @@ -1,4 +1,5 @@ import { PaymentAPIService } from '@impler/services'; +import { Replace } from './replace/replace.usecase'; import { DoReview } from './do-review/do-review.usecase'; import { UpdateRecord } from './update-cell/update-cell.usecase'; import { DeleteRecord } from './delete-record/delete-record.usecase'; @@ -8,6 +9,7 @@ import { GetUploadData } from './get-upload-data/get-upload-data.usecase'; import { GetUpload } from '@shared/usecases/get-upload/get-upload.usecase'; export const USE_CASES = [ + Replace, DoReview, GetUpload, DoReReview, @@ -18,4 +20,14 @@ export const USE_CASES = [ PaymentAPIService, ]; -export { DoReview, GetUpload, DoReReview, DeleteRecord, UpdateRecord, StartProcess, GetUploadData, PaymentAPIService }; +export { + Replace, + DoReview, + GetUpload, + DoReReview, + DeleteRecord, + UpdateRecord, + StartProcess, + GetUploadData, + PaymentAPIService, +}; diff --git a/apps/api/src/app/review/usecases/replace/replace.command.ts b/apps/api/src/app/review/usecases/replace/replace.command.ts new file mode 100644 index 000000000..0b550c9e8 --- /dev/null +++ b/apps/api/src/app/review/usecases/replace/replace.command.ts @@ -0,0 +1,7 @@ +export class ReplaceCommand { + find?: string; + replace?: string; + column: string; + caseSensitive?: boolean; + matchEntireCell?: boolean; +} diff --git a/apps/api/src/app/review/usecases/replace/replace.usecase.ts b/apps/api/src/app/review/usecases/replace/replace.usecase.ts new file mode 100644 index 000000000..befcf55de --- /dev/null +++ b/apps/api/src/app/review/usecases/replace/replace.usecase.ts @@ -0,0 +1,125 @@ +import { Injectable } from '@nestjs/common'; +import { ReplaceCommand } from './replace.command'; +import { DalService, UploadRepository } from '@impler/dal'; +import { ColumnTypesEnum, ITemplateSchemaItem } from '@impler/shared'; + +@Injectable() +export class Replace { + constructor( + private dalService: DalService, + private uploadRepository: UploadRepository + ) {} + + escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + isNumeric(value: string): boolean { + return !isNaN(parseFloat(value)) && isFinite(Number(value)); + } + + async execute(_uploadId: string, { column, caseSensitive, find, replace, matchEntireCell }: ReplaceCommand) { + const uploadInfo = await this.uploadRepository.findById(_uploadId); + const recordCollectionModal = this.dalService.getRecordCollection(_uploadId); + const numberColumnHeadings = new Set(); + const columns = JSON.parse(uploadInfo.customSchema); + (columns as ITemplateSchemaItem[]).forEach((columnItem) => { + if (columnItem.type === ColumnTypesEnum.NUMBER || columnItem.type === ColumnTypesEnum.DOUBLE) + numberColumnHeadings.add(columnItem.key); + }); + if (find === '') matchEntireCell = true; + + const updateStages = []; + const fieldsToProcess = column ? [column] : uploadInfo.headings; + + fieldsToProcess.forEach((fieldName) => { + const path = `record.${fieldName}`; + const isNumberColumn = numberColumnHeadings.has(fieldName); + + let formattedReplace: string | number = replace; + if (isNumberColumn && this.isNumeric(replace)) { + formattedReplace = parseFloat(replace); + } + + let matchCondition; + let replaceOperation; + + if (find === '') { + matchCondition = { + $or: [ + { $eq: ['$' + path, ''] }, + { $regexMatch: { input: { $toString: '$' + path }, regex: /^\s*$/ } }, + { $eq: ['$' + path, null] }, + ], + }; + replaceOperation = { $literal: formattedReplace }; + } else if (isNumberColumn) { + // For number columns, we'll use string operations and then convert back to number + const escapedFind = this.escapeRegExp(find); + matchCondition = { + $regexMatch: { + input: { $toString: '$' + path }, + regex: escapedFind, + options: caseSensitive ? '' : 'i', + }, + }; + replaceOperation = { + $toDouble: { + $replaceAll: { + input: { $toString: '$' + path }, + find: find, + replacement: replace, + }, + }, + }; + } else { + const regex = new RegExp( + matchEntireCell ? `^${this.escapeRegExp(find)}$` : this.escapeRegExp(find), + caseSensitive ? '' : 'i' + ); + matchCondition = { $regexMatch: { input: { $toString: '$' + path }, regex } }; + replaceOperation = { + $replaceAll: { + input: { $toString: '$' + path }, + find: find, + replacement: formattedReplace, + }, + }; + } + + updateStages.push({ + $set: { + [`record.${fieldName}`]: { + $cond: { + if: matchCondition, + then: replaceOperation, + else: `$record.${fieldName}`, + }, + }, + }, + }); + + updateStages.push({ + $set: { + [`updated.${fieldName}`]: { + $cond: { + if: { $ne: [`$record.${fieldName}`, `$_oldRecord.${fieldName}`] }, + then: true, + else: { $ifNull: [`$updated.${fieldName}`, false] }, + }, + }, + }, + }); + }); + + // Add a stage to store the original record state + updateStages.unshift({ $set: { _oldRecord: '$record' } }); + + // Add a final stage to remove the temporary _oldRecord field + updateStages.push({ $unset: '_oldRecord' }); + + const result = await recordCollectionModal.updateMany({}, updateStages, { multi: true }); + + return result; + } +} diff --git a/apps/widget/src/components/widget/Phases/Phase3/Phase3.tsx b/apps/widget/src/components/widget/Phases/Phase3/Phase3.tsx index e00639067..105b44b2a 100644 --- a/apps/widget/src/components/widget/Phases/Phase3/Phase3.tsx +++ b/apps/widget/src/components/widget/Phases/Phase3/Phase3.tsx @@ -1,4 +1,4 @@ -import { Badge, Flex, Stack } from '@mantine/core'; +import { Badge, Flex, Group, Stack } from '@mantine/core'; import { useRef, useState, useEffect } from 'react'; import HotTableClass from '@handsontable/react/hotTableClass'; @@ -16,6 +16,7 @@ import { Pagination } from '@ui/Pagination'; import { LoadingOverlay } from '@ui/LoadingOverlay'; import { SegmentedControl } from '@ui/SegmentedControl'; import { ConfirmModal } from 'components/widget/modals/ConfirmModal'; +import { FindReplaceModal } from 'components/widget/modals/FindReplace'; interface IPhase3Props { onNextClick: (uploadData: IUpload, importedData?: Record[]) => void; @@ -29,11 +30,13 @@ export function Phase3(props: IPhase3Props) { const { page, type, + columns, headings, columnDefs, totalPages, reviewData, allChecked, + replaceData, totalRecords, onTypeChange, reReviewData, @@ -48,11 +51,14 @@ export function Phase3(props: IPhase3Props) { selectedRowsRef, isDoReviewLoading, isReviewDataLoading, + isReplaceDataLoading, selectedRowsCountRef, + showFindReplaceModal, showAllDataValidModal, isDeleteRecordLoading, isConfirmReviewLoading, showDeleteConfirmModal, + setShowFindReplaceModal, setShowAllDataValidModal, setShowDeleteConfirmModal, } = usePhase3({ onNext: onNextClick }); @@ -108,12 +114,19 @@ export function Phase3(props: IPhase3Props) { }, ]} /> - + + + + + setShowFindReplaceModal(false)} + /> ); } diff --git a/apps/widget/src/components/widget/modals/FindReplace/FindReplaceModal.tsx b/apps/widget/src/components/widget/modals/FindReplace/FindReplaceModal.tsx new file mode 100644 index 000000000..33cf416b2 --- /dev/null +++ b/apps/widget/src/components/widget/modals/FindReplace/FindReplaceModal.tsx @@ -0,0 +1,84 @@ +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { IReplaceData, WIDGET_TEXTS } from '@impler/shared'; +import { Checkbox, Flex, FocusTrap, Modal as MantineModal, Stack, TextInput } from '@mantine/core'; + +import { Button } from '@ui/Button'; +import { Select } from '@ui/Select'; + +interface IFindReplaceModalProps { + opened: boolean; + columns: IOption[]; + cancelLabel: string; + onClose: () => void; + replaceLabel: string; + isReplaceLoading: boolean; + texts: typeof WIDGET_TEXTS; + onReplace: (data: IReplaceData) => void; +} + +export function FindReplaceModal(props: IFindReplaceModalProps) { + const { opened, onClose, replaceLabel, onReplace, cancelLabel, columns, texts, isReplaceLoading } = props; + const { register, handleSubmit, control, reset } = useForm({ + defaultValues: { + column: '', + }, + }); + + useEffect(() => { + if (!opened) + reset({ + column: '', + }); + }, [opened]); + + return ( + + +
+ + + + ( +