Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 4 additions & 12 deletions src/components/SearchFacet/SearchFacetModal/SearchFacetModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IFacetListProps, 'onError'> {
children: (props: { searchTerm: string }) => ReactNode;
Expand Down Expand Up @@ -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' });
Comment on lines +202 to +203

useEffect(() => {
if (enabled && !isFetching && isSuccess) {
Expand Down
57 changes: 57 additions & 0 deletions src/components/SearchFacet/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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('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');
});
});
19 changes: 18 additions & 1 deletion src/components/SearchFacet/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -249,6 +249,23 @@ export const getFilters = (query: IADSApiSearchParams): FilterTuple[] =>
]),
)(query);

/**
* Formats a list of facet items as a CSV string with Label and Count columns.
* Labels containing commas or quotes are properly escaped per RFC 4180.
*
* @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();
Expand Down
Loading