diff --git a/src/components/SearchFacet/SearchFacetModal/SearchFacetModal.tsx b/src/components/SearchFacet/SearchFacetModal/SearchFacetModal.tsx index 78345979f..258c5a4ed 100644 --- a/src/components/SearchFacet/SearchFacetModal/SearchFacetModal.tsx +++ b/src/components/SearchFacet/SearchFacetModal/SearchFacetModal.tsx @@ -31,9 +31,9 @@ import { SelectedList } from './SelectedList'; import { useDebounce } from '@/lib/useDebounce'; import { FACET_DEFAULT_PREFIX, useGetFacetData } from '../useGetFacetData'; import { useDownloadFile } from '@/lib/useDownloadFile'; -import { join, last, map, pipe, pluck, split } from 'ramda'; import { parseAPIError } from '@/utils/common/parseAPIError'; -import { FacetItem, FacetLogic } from '../types'; +import { formatFacetCSV } from '../helpers'; +import { FacetLogic } from '../types'; interface ISearchFacetModalProps extends Omit { children: (props: { searchTerm: string }) => ReactNode; @@ -199,16 +199,8 @@ const FacetDownloadButton = () => { enabled, }); - const formatData = useCallback( - () => - pipe<[FacetItem[]], string[], string[], string>( - pluck('val'), - map((s: string) => last(split('/', s)) ?? ''), - join('\n'), - )(treeData), - [treeData], - ); - const { onDownload } = useDownloadFile(formatData, { filename: 'fulllist.txt' }); + const formatData = useCallback(() => formatFacetCSV(treeData), [treeData]); + const { onDownload } = useDownloadFile(formatData, { filename: 'fulllist.csv', type: 'CSV' }); useEffect(() => { if (enabled && !isFetching && isSuccess) { diff --git a/src/components/SearchFacet/__tests__/helpers.test.ts b/src/components/SearchFacet/__tests__/helpers.test.ts new file mode 100644 index 000000000..9bc33cd23 --- /dev/null +++ b/src/components/SearchFacet/__tests__/helpers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { formatFacetCSV } from '../helpers'; +import { FacetItem } from '../types'; + +const item = (val: string, count: number): FacetItem => ({ + id: val, + val, + count, + parentId: null, + level: 0, +}); + +describe('formatFacetCSV', () => { + it('returns only the header for an empty list', () => { + expect(formatFacetCSV([])).toBe('Label,Count'); + }); + + it('strips the hierarchical prefix from the label', () => { + const result = formatFacetCSV([item('0/Smith, J', 42)]); + expect(result).toBe('Label,Count\n"Smith, J",42'); + }); + + it('includes the count column', () => { + const result = formatFacetCSV([item('0/Jones, B', 7)]); + expect(result).toContain(',7'); + }); + + it('handles deeper hierarchy levels', () => { + const result = formatFacetCSV([item('1/Smith, J/Smith, John', 5)]); + expect(result).toBe('Label,Count\n"Smith, John",5'); + }); + + it('escapes double-quotes in labels per RFC 4180', () => { + const result = formatFacetCSV([item('0/He said "hi"', 1)]); + expect(result).toBe('Label,Count\n"He said ""hi""",1'); + }); + + it('wraps labels containing commas in quotes', () => { + const result = formatFacetCSV([item('0/Doe, Jane', 10)]); + expect(result).toBe('Label,Count\n"Doe, Jane",10'); + }); + + it('handles non-hierarchical keys (no slash)', () => { + const result = formatFacetCSV([item('astronomy', 100)]); + expect(result).toBe('Label,Count\n"astronomy",100'); + }); + + it('handles a key with an empty last segment (e.g. "0/")', () => { + const result = formatFacetCSV([item('0/', 5)]); + expect(result).toBe('Label,Count\n"",5'); + }); + + it('handles an empty string key', () => { + const result = formatFacetCSV([item('', 0)]); + expect(result).toBe('Label,Count\n"",0'); + }); + + it('produces one row per item', () => { + const items = [item('0/Alpha', 3), item('0/Beta', 7), item('0/Gamma', 1)]; + const lines = formatFacetCSV(items).split('\n'); + expect(lines).toHaveLength(4); // header + 3 rows + expect(lines[0]).toBe('Label,Count'); + expect(lines[1]).toBe('"Alpha",3'); + expect(lines[2]).toBe('"Beta",7'); + expect(lines[3]).toBe('"Gamma",1'); + }); +}); diff --git a/src/components/SearchFacet/helpers.ts b/src/components/SearchFacet/helpers.ts index e56c83213..16e2eaa96 100644 --- a/src/components/SearchFacet/helpers.ts +++ b/src/components/SearchFacet/helpers.ts @@ -34,7 +34,7 @@ import { when, } from 'ramda'; import { isNonEmptyString } from 'ramda-adjunct'; -import { FacetLogic, OnFilterArgs } from './types'; +import { FacetItem, FacetLogic, OnFilterArgs } from './types'; import { isString } from '@/utils/common/guards'; import { FacetField, IADSApiSearchParams } from '@/api/search/types'; import { OBJECTS_API_KEYS } from '@/api/objects/objects'; @@ -249,6 +249,24 @@ export const getFilters = (query: IADSApiSearchParams): FilterTuple[] => ]), )(query); +/** + * Formats a list of facet items as a CSV string with Label and Count columns. + * Labels are always double-quoted; embedded double-quotes are doubled. + * Uses LF line endings (consistent with other CSV exports in this codebase). + * + * @example + * formatFacetCSV([{ val: '0/Smith, J', count: 42, ... }]) + * // => "Label,Count\n\"Smith, J\",42" + */ +export const formatFacetCSV = (items: FacetItem[]): string => { + const rows = items.map((item) => { + const label = parseTitleFromKey(item.val) as string; + const escaped = label.replace(/"/g, '""'); + return `"${escaped}",${item.count}`; + }); + return ['Label,Count', ...rows].join('\n'); +}; + // get object name from react query cache export const getObjectName = (id: string, queryClient: QueryClient) => { const queryCache = queryClient.getQueryCache(); diff --git a/src/lib/useDownloadFile.ts b/src/lib/useDownloadFile.ts index 0ca05f79a..b24ee301f 100644 --- a/src/lib/useDownloadFile.ts +++ b/src/lib/useDownloadFile.ts @@ -24,7 +24,7 @@ export const useDownloadFile = (value: string | (() => string), options: IUseDow const href = useMemo(() => { const content = typeof value === 'function' ? [value()] : [value]; if (typeof window !== 'undefined' && isNonEmptyString(content[0])) { - const blob = new window.Blob(content, { type }); + const blob = new window.Blob(content, { type: meta[0] }); return window.URL.createObjectURL(blob); } return '';