diff --git a/frontend/src/resources/utils/utils.ts b/frontend/src/resources/utils/utils.ts index d7e15df8cf0..9079206b334 100644 --- a/frontend/src/resources/utils/utils.ts +++ b/frontend/src/resources/utils/utils.ts @@ -53,9 +53,14 @@ export function exportObjectString(object: Record) { return keyValueMap.toString() } -export function returnCSVSafeString(exportValue: string | ReactNode) { - // extract newlines - return `"${typeof exportValue === 'string' ? exportValue.split('\n').join(' ').replace(/"/g, '""') : exportValue}"` +export function returnCSVSafeString(exportValue: string | number | ReactNode) { + if (typeof exportValue === 'number') { + return `"${exportValue}"` + } + if (typeof exportValue !== 'string') { + return '"-"' + } + return `"${exportValue.replaceAll('"', '""')}"` } export const getISOStringTimestamp = (timestamp: string) => { diff --git a/frontend/src/routes/Governance/common/util.test.tsx b/frontend/src/routes/Governance/common/util.test.tsx index 709cb8ab87a..ff290bb47f3 100644 --- a/frontend/src/routes/Governance/common/util.test.tsx +++ b/frontend/src/routes/Governance/common/util.test.tsx @@ -2,6 +2,7 @@ 'use strict' import { hasInformOnlyPolicies, getPolicyRemediation, resolveExternalStatus, parseStringMap } from './util' +import { returnCSVSafeString } from '../../../resources/utils' import { PolicyTableItem } from '../policies/Policies' import { Policy, PolicyTemplate, REMEDIATION_ACTION } from '../../../resources' import { cloneDeep } from 'lodash' @@ -901,3 +902,32 @@ describe('Test parseStringValue', () => { ) }) }) + +describe('ACM-32500: returnCSVSafeString preserves newlines in CSV export', () => { + const descriptionFromApi = + 'Policy is placed on hub or managed clusters with label acm-virt-config=acm-dr-virt-config-file-name.\nCreates a velero Schedule for all virtualmachines.kubevirt.io resources with a cluster.open-cluster-management.io/backup-vm label.' + + test('returnCSVSafeString preserves newlines', () => { + const csvRaw = returnCSVSafeString(descriptionFromApi) + const unquoted = csvRaw.slice(1, -1).replace(/""/g, '"') + + expect(unquoted).toContain('\n') + expect(unquoted).toEqual(descriptionFromApi) + }) + + test('returnCSVSafeString escapes double quotes', () => { + const stringWithQuotes = 'Description with "quoted" text' + const result = returnCSVSafeString(stringWithQuotes) + + expect(result).toBe('"Description with ""quoted"" text"') + }) + + test('returnCSVSafeString handles number input', () => { + expect(returnCSVSafeString(42)).toBe('"42"') + }) + + test('returnCSVSafeString handles non-string/non-number ReactNode', () => { + expect(returnCSVSafeString(null)).toBe('"-"') + expect(returnCSVSafeString(undefined)).toBe('"-"') + }) +}) diff --git a/frontend/src/ui-components/AcmTable/AcmTable.test.tsx b/frontend/src/ui-components/AcmTable/AcmTable.test.tsx index db828f6e544..f77b0596539 100644 --- a/frontend/src/ui-components/AcmTable/AcmTable.test.tsx +++ b/frontend/src/ui-components/AcmTable/AcmTable.test.tsx @@ -1150,10 +1150,10 @@ describe('AcmTable', () => { }) describe('returnCSVSafeString', () => { - test('returns a csv safe string replacing new line with space', () => { + test('returns a csv safe string preserving newlines', () => { const content = 'testing for multiline\ndescription' const exportContent = returnCSVSafeString(content) - expect(exportContent).toEqual('"testing for multiline description"') + expect(exportContent).toEqual('"testing for multiline\ndescription"') }) test('returns a csv safe string escaping double quotes', () => {