Skip to content
47 changes: 39 additions & 8 deletions src/components/ClassicForm/ClassicForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { useRouter } from 'next/router';
import PT from 'prop-types';
import { FormEventHandler, useMemo } from 'react';
import { Control, Controller, useForm, UseFormRegisterReturn, useWatch } from 'react-hook-form';
import { getSearchQuery } from './helpers';
import { getSearchQuery, getSearchQueryParams } from './helpers';
import { IClassicFormState, IRawClassicFormState } from './types';
import { useStore } from '@/store';

Expand Down Expand Up @@ -325,11 +325,23 @@ const LogicRadios = (props: { variant: 'andor' | 'all'; radioProps: UseFormRegis
const CurrentQuery = (props: { control: Control<IClassicFormState> }) => {
const { control } = props;
const values = useWatch<IClassicFormState>({ control });
const query = useMemo(() => {

const { query, filters } = useMemo((): { query: string | React.ReactNode; filters: string[] } => {
try {
return new URLSearchParams(getSearchQuery(values as IRawClassicFormState)).get('q');
const params = getSearchQueryParams(values as IRawClassicFormState);
const filterList: string[] = [];
if (params.fq_database) {
filterList.push(params.fq_database);
}
if (params.fq_property) {
filterList.push(params.fq_property);
}
return { query: params.q, filters: filterList };
} catch (e) {
return <Text color="red.500">{(e as Error)?.message}</Text>;
return {
query: <Text color="red.500">{(e as Error)?.message}</Text>,
filters: [],
};
}
}, [values]);

Expand All @@ -338,10 +350,29 @@ const CurrentQuery = (props: { control: Control<IClassicFormState> }) => {
title="Generated Query"
defaultOpen
description={
<HStack>
<Code>{query}</Code>
{typeof query === 'string' ? <SimpleCopyButton text={query} /> : null}
</HStack>
<Stack spacing={2}>
<HStack>
<Text fontWeight="semibold" fontSize="sm">
Query:
</Text>
<Code>{query}</Code>
{typeof query === 'string' ? <SimpleCopyButton text={query} /> : null}
</HStack>
{filters.length > 0 && (
<HStack alignItems="flex-start">
<Text fontWeight="semibold" fontSize="sm">
Filters:
</Text>
<Stack spacing={1}>
{filters.map((filter, i) => (
<Code key={i} fontSize="sm">
{filter}
</Code>
))}
</Stack>
</HStack>
)}
</Stack>
}
></Expandable>
);
Expand Down
85 changes: 84 additions & 1 deletion src/components/ClassicForm/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import {
getAbs,
getAuthor,
getBibstems,
getDatabaseFilter,
getLimit,
getObject,
getProperty,
getPropertyFilter,
getPubdate,
getSearchQuery,
getSearchQueryParams,
getTitle,
} from '../helpers';
import { CollectionChoice, IRawClassicFormState, LogicChoice, PropertyChoice } from '../types';
Expand All @@ -21,6 +24,26 @@ describe('Classic Form Query Handling', () => {
[['astronomy', 'physics', 'general'], 'collection:(astronomy OR physics OR general)'],
])('getLimit(%s) -> %s', (choice, expected) => expect(getLimit(choice)).toBe(expected));

// database filter
test.concurrent.each<[CollectionChoice[], string | undefined]>([
[[], undefined],
[['astronomy'], 'database: (astronomy)'],
[['astronomy', 'physics'], 'database: (astronomy OR physics)'],
[['astronomy', 'physics', 'general'], 'database: (astronomy OR physics OR general)'],
[
['astronomy', 'physics', 'general', 'earthscience'],
'database: (astronomy OR physics OR general OR earthscience)',
],
])('getDatabaseFilter(%s) -> %s', (choice, expected) => expect(getDatabaseFilter(choice)).toBe(expected));

// property filter
test.concurrent.each<[PropertyChoice[], string | undefined]>([
[[], undefined],
[['refereed-only'], 'property: (refereed)'],
[['articles-only'], 'property: (article)'],
[['refereed-only', 'articles-only'], 'property: (refereed AND article)'],
])('getPropertyFilter(%s) -> %s', (choices, expected) => expect(getPropertyFilter(choices)).toBe(expected));

// author
test.concurrent.each<[string, LogicChoice, string]>([
['', 'and', ''],
Expand Down Expand Up @@ -143,9 +166,69 @@ describe('Classic Form Query Handling', () => {
sort: ['score desc', 'date desc'],
};
const result = new URLSearchParams(getSearchQuery(state));

// q no longer contains collection or property
expect(result.get('q')).toBe(
`collection:(astronomy OR physics) pubdate:[2020-12 TO 2022-01] author:("Smith, A" "Jones, B" ="Jones, Bob") object:(IRAS HIP) property:(refereed article) title:("Black Hole" -"Milky Way" -star) abs:("Event Horizon" Singularity) bibstem:(PhRvL) -bibstem:(Apj)`,
`pubdate:[2020-12 TO 2022-01] author:("Smith, A" "Jones, B" ="Jones, Bob") object:(IRAS HIP) title:("Black Hole" -"Milky Way" -star) abs:("Event Horizon" Singularity) bibstem:(PhRvL) -bibstem:(Apj)`,
);
expect(result.getAll('sort')).toStrictEqual(['score desc', 'date desc']);

// Filters are now in fq params
expect(result.getAll('fq')).toContain('{!type=aqp v=$fq_database}');
expect(result.getAll('fq')).toContain('{!type=aqp v=$fq_property}');
expect(result.get('fq_database')).toBe('database: (astronomy OR physics)');
expect(result.get('fq_property')).toBe('property: (refereed AND article)');
});

test('getSearchQueryParams returns structured params with filters', () => {
const state: IRawClassicFormState = {
limit: ['astronomy', 'physics'],
author: 'Smith, A',
logic_author: 'and',
object: '',
logic_object: 'and',
pubdate_start: '',
pubdate_end: '',
title: '',
logic_title: 'and',
abstract_keywords: '',
logic_abstract_keywords: 'and',
property: ['refereed-only'],
bibstems: '',
sort: ['date desc'],
};
const result = getSearchQueryParams(state);

expect(result.q).toBe('author:("Smith, A")');
expect(result.fq).toContain('{!type=aqp v=$fq_database}');
expect(result.fq).toContain('{!type=aqp v=$fq_property}');
expect(result.fq_database).toBe('database: (astronomy OR physics)');
expect(result.fq_property).toBe('property: (refereed)');
});

test('getSearchQuery generates URL with fq params', () => {
const state: IRawClassicFormState = {
limit: ['astronomy', 'physics'],
author: 'Smith, A',
logic_author: 'and',
object: '',
logic_object: 'and',
pubdate_start: '',
pubdate_end: '',
title: '',
logic_title: 'and',
abstract_keywords: '',
logic_abstract_keywords: 'and',
property: ['refereed-only'],
bibstems: '',
sort: ['date desc'],
};
const result = new URLSearchParams(getSearchQuery(state));

expect(result.get('q')).toBe('author:("Smith, A")');
expect(result.getAll('fq')).toContain('{!type=aqp v=$fq_database}');
expect(result.getAll('fq')).toContain('{!type=aqp v=$fq_property}');
expect(result.get('fq_database')).toBe('database: (astronomy OR physics)');
expect(result.get('fq_property')).toBe('property: (refereed)');
});
});
104 changes: 96 additions & 8 deletions src/components/ClassicForm/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ import {
when,
} from 'ramda';
import { isNilOrEmpty, isNotEmpty } from 'ramda-adjunct';
import { CollectionChoice, IClassicFormState, IRawClassicFormState, LogicChoice, PropertyChoice } from './types';
import {
CollectionChoice,
IClassicFormQueryParams,
IClassicFormState,
IRawClassicFormState,
LogicChoice,
PropertyChoice,
} from './types';
import { getTerms } from '@/query';
import { APP_DEFAULTS } from '@/config';
import { makeSearchParams } from '@/utils/common/search';
Expand Down Expand Up @@ -299,13 +306,59 @@ export const getBibstems = (bibstems: string) => {
]);
};

const makeFQHeader = (name: string) => `{!type=aqp v=$fq_${name}}`;

/**
* Run classic form parameters through parsers and generate URL query string
* Generate database filter from collection choices
* @example
* getDatabaseFilter(['astronomy', 'physics']) // 'database: (astronomy OR physics)'
*/
export const getSearchQuery = (params: IRawClassicFormState, options: { mode?: AppMode } = {}): string => {
export const getDatabaseFilter = (limit: CollectionChoice[]): string | undefined => {
const limits = ['astronomy', 'physics', 'general', 'earthscience'];
const isLimit = (limit: string): limit is CollectionChoice => allPass([isString, includes(__, limits)])(limit);
const limitIsValid = both(isNotEmpty, all(isLimit));

if (!limitIsValid(limit)) {
return undefined;
}
return `database: (${limit.join(' OR ')})`;
};

/**
* Generate property filter from property choices
* @example
* getPropertyFilter(['refereed-only', 'articles-only']) // 'property: (refereed AND article)'
*/
export const getPropertyFilter = (property: PropertyChoice[]): string | undefined => {
const convertNames = map(
cond([
[equals('refereed-only'), always('refereed')],
[equals('articles-only'), always('article')],
[T, identity],
]),
);
const properties = ['refereed-only', 'articles-only'];
const isProperty = (property: string): property is PropertyChoice =>
allPass([isString, includes(__, properties)])(property);
const isValidListOfProperties = both(pipe(length, lt(0)), all(isProperty));

if (!isValidListOfProperties(property)) {
return undefined;
}
return `property: (${convertNames(property).join(' AND ')})`;
};

/**
* Run classic form parameters through parsers and generate structured query params
* Returns an object with q, fq array, and individual fq_* params for filters
*/
export const getSearchQueryParams = (
params: IRawClassicFormState,
options: { mode?: AppMode } = {},
): IClassicFormQueryParams => {
const d = options.mode;
if (isEmpty(params)) {
return makeSearchParams({ q: APP_DEFAULTS.EMPTY_QUERY, sort: ['date desc'], d });
return { q: APP_DEFAULTS.EMPTY_QUERY, sort: ['date desc'], d };
}

// sanitize strings
Expand All @@ -322,20 +375,55 @@ export const getSearchQuery = (params: IRawClassicFormState, options: { mode?: A
return param;
}, params) as IClassicFormState;

// gather all strings and join them with space (excepting sort)
// Build the q param (without collection and property)
const query = pipe(
reject(isEmpty),
join(' '),
)([
getLimit(cleanParams.limit),
getPubdate(cleanParams.pubdate_start, cleanParams.pubdate_end),
getAuthor(cleanParams.author, cleanParams.logic_author),
getObject(cleanParams.object, cleanParams.logic_object),
getProperty(cleanParams.property),
getTitle(cleanParams.title, cleanParams.logic_title),
getAbs(cleanParams.abstract_keywords, cleanParams.logic_abstract_keywords),
getBibstems(cleanParams.bibstems),
]);

return makeSearchParams({ q: query, sort: cleanParams.sort, d });
// Build filter params
const fq_database = getDatabaseFilter(cleanParams.limit);
const fq_property = getPropertyFilter(cleanParams.property);

// Build fq header array
const fq: string[] = [];
if (fq_database) {
fq.push(makeFQHeader('database'));
}
if (fq_property) {
fq.push(makeFQHeader('property'));
}

const result: IClassicFormQueryParams = {
q: query || APP_DEFAULTS.EMPTY_QUERY,
sort: cleanParams.sort,
d,
};

if (fq.length > 0) {
result.fq = fq;
}
if (fq_database) {
result.fq_database = fq_database;
}
if (fq_property) {
result.fq_property = fq_property;
}

return result;
};

/**
* Run classic form parameters through parsers and generate URL query string
*/
export const getSearchQuery = (params: IRawClassicFormState, options: { mode?: AppMode } = {}): string => {
const queryParams = getSearchQueryParams(params, options);
return makeSearchParams(queryParams);
};
6 changes: 3 additions & 3 deletions src/components/ClassicForm/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export * from './ClassicForm';
export * from './helpers';
export * from './types';
export { ClassicForm, defaultClassicFormState } from './ClassicForm';
export { getSearchQuery, getSearchQueryParams, getDatabaseFilter, getPropertyFilter } from './helpers';
export type { IClassicFormState, IRawClassicFormState, IClassicFormQueryParams } from './types';
10 changes: 10 additions & 0 deletions src/components/ClassicForm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,13 @@ export interface IRawClassicFormState {
bibstems: string;
sort: string[];
}

export interface IClassicFormQueryParams {
q: string;
sort: string[];
d?: string;
fq?: string[];
fq_database?: string;
fq_property?: string;
[key: string]: string | string[] | undefined;
}
Loading