diff --git a/frontend/src/resources/utils/utils.ts b/frontend/src/resources/utils/utils.ts index 338f55939cd..bbdee7652a4 100644 --- a/frontend/src/resources/utils/utils.ts +++ b/frontend/src/resources/utils/utils.ts @@ -51,9 +51,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 6cb555cad62..e1b964bd6a8 100644 --- a/frontend/src/routes/Governance/common/util.test.tsx +++ b/frontend/src/routes/Governance/common/util.test.tsx @@ -8,6 +8,7 @@ import { parseStringMap, policyHasDeletePruneBehavior, } from './util' +import { returnCSVSafeString } from '../../../resources/utils' import { PolicyTableItem } from '../policies/Policies' import { Policy, PolicyTemplate, REMEDIATION_ACTION } from '../../../resources' import { cloneDeep } from 'lodash' @@ -1028,3 +1029,32 @@ describe('Test policyHasDeletePruneBehavior', () => { expect(policyHasDeletePruneBehavior(policy)).toBe(false) }) }) + +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 09af9165979..01fa1903801 100644 --- a/frontend/src/ui-components/AcmTable/AcmTable.test.tsx +++ b/frontend/src/ui-components/AcmTable/AcmTable.test.tsx @@ -1146,10 +1146,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', () => {