Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
67 changes: 67 additions & 0 deletions src/components/SearchFacet/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
20 changes: 19 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,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();
Expand Down
2 changes: 1 addition & 1 deletion src/lib/useDownloadFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
Expand Down
Loading