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
99 changes: 81 additions & 18 deletions src/__tests__/ssr-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const getMockContext = (
query: ParsedUrlQuery = {},
resolvedUrl = '/search',
referer?: string,
cookie?: string,
) =>
({
req: {
Expand All @@ -24,48 +25,40 @@ const getMockContext = (
save: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
},
headers: {
referer,
},
headers: { referer, cookie },
},
query,
resolvedUrl,
} as unknown as GetServerSidePropsContext);

describe('updateUserStateSSR', () => {
test('should set mode for legacy referrer with no persisted state', async () => {
// Mode is now set by middleware writing the scix_prefs cookie before redirect.
// updateUserStateSSR no longer reads the referer header to infer mode.
const context = getMockContext({}, {}, '/search', 'https://ui.adsabs.harvard.edu/search');
const result = await updateUserStateSSR(context, { props: {} });

if (!('props' in result)) {
throw new Error('Expected props in result');
}
const props = result.props as SSRPropsWithState;
expect(props.dehydratedAppState).toEqual(
expect.objectContaining({
mode: AppMode.ASTROPHYSICS,
}),
);
expect(props.dehydratedAppState).not.toHaveProperty('mode');
});

test('should respect persisted state even with legacy referrer', async () => {
const context = getMockContext({}, {}, '/search', 'https://ui.adsabs.harvard.edu/search');
const inputProps = {
props: {
dehydratedAppState: {
mode: AppMode.GENERAL,
} as Partial<AppState>,
},
};
const result = await updateUserStateSSR(context, inputProps as never);
// Cookie-based mode takes precedence; referer header is ignored.
const prefs = { mode: 'EARTH_SCIENCE' };
const cookie = `scix_prefs=${encodeURIComponent(JSON.stringify(prefs))}`;
const context = getMockContext({}, {}, '/search', 'https://ui.adsabs.harvard.edu/search', cookie);
const result = await updateUserStateSSR(context, { props: {} });

if (!('props' in result)) {
throw new Error('Expected props in result');
}
const resultProps = result.props as SSRPropsWithState;
expect(resultProps.dehydratedAppState).toEqual(
expect.objectContaining({
mode: AppMode.GENERAL,
mode: AppMode.EARTH_SCIENCE,
}),
);
});
Expand Down Expand Up @@ -218,4 +211,74 @@ describe('updateUserStateSSR', () => {
expect(props.dehydratedAppState).not.toHaveProperty('mode');
});
});

test('seeds mode from scix_prefs cookie when no URL param', async () => {
const prefs = { mode: 'ASTROPHYSICS' };
const cookie = `scix_prefs=${encodeURIComponent(JSON.stringify(prefs))}`;
const context = getMockContext({}, {}, '/search', undefined, cookie);
const result = await updateUserStateSSR(context, { props: {} });
if (!('props' in result)) {
throw new Error('Expected props');
}
const props = result.props as SSRPropsWithState;
expect(props.dehydratedAppState).toEqual(expect.objectContaining({ mode: AppMode.ASTROPHYSICS }));
});

test('seeds searchMode from scix_prefs cookie', async () => {
const prefs = { searchMode: 'ADS_COMPAT', mode: 'ASTROPHYSICS' };
const cookie = `scix_prefs=${encodeURIComponent(JSON.stringify(prefs))}`;
const context = getMockContext({}, {}, '/', undefined, cookie);
const result = await updateUserStateSSR(context, { props: {} });
if (!('props' in result)) {
throw new Error('Expected props');
}
const props = result.props as SSRPropsWithState;
expect(props.dehydratedAppState).toEqual(expect.objectContaining({ searchMode: 'ADS_COMPAT' }));
});

test('URL forceMode takes priority over prefs cookie mode', async () => {
const prefs = { mode: 'ASTROPHYSICS' };
const cookie = `scix_prefs=${encodeURIComponent(JSON.stringify(prefs))}`;
const context = getMockContext({}, { forceMode: 'heliophysics' }, '/', undefined, cookie);
const result = await updateUserStateSSR(context, { props: {} });
if (!('props' in result)) {
throw new Error('Expected props');
}
const props = result.props as SSRPropsWithState;
expect(props.dehydratedAppState).toEqual(expect.objectContaining({ mode: AppMode.HELIOPHYSICS }));
});

test('legacy referrer no longer sets mode in GSSP (handled by middleware+cookie)', async () => {
const context = getMockContext({}, {}, '/search', 'https://ui.adsabs.harvard.edu/search');
const result = await updateUserStateSSR(context, { props: {} });
if (!('props' in result)) {
throw new Error('Expected props');
}
const props = result.props as SSRPropsWithState;
expect(props.dehydratedAppState).not.toHaveProperty('mode');
});

test('rejects invalid mode value from prefs cookie', async () => {
const prefs = { mode: 'GARBAGE_MODE' };
const cookie = `scix_prefs=${encodeURIComponent(JSON.stringify(prefs))}`;
const context = getMockContext({}, {}, '/search', undefined, cookie);
const result = await updateUserStateSSR(context, { props: {} });
if (!('props' in result)) {
throw new Error('Expected props');
}
const props = result.props as SSRPropsWithState;
expect(props.dehydratedAppState).not.toHaveProperty('mode');
});

test('rejects invalid searchMode value from prefs cookie', async () => {
const prefs = { searchMode: 'INVALID_SEARCH_MODE' };
const cookie = `scix_prefs=${encodeURIComponent(JSON.stringify(prefs))}`;
const context = getMockContext({}, {}, '/', undefined, cookie);
const result = await updateUserStateSSR(context, { props: {} });
if (!('props' in result)) {
throw new Error('Expected props');
}
const props = result.props as SSRPropsWithState;
expect(props.dehydratedAppState).not.toHaveProperty('searchMode');
});
});
18 changes: 6 additions & 12 deletions src/components/AbstractSearchForm/AbstractSearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,16 @@ import router from 'next/router';
import { applyFiltersToQuery } from '../SearchFacet/helpers';
import { DatabaseEnum, IADSApiUserDataResponse } from '@/api/user/types';
import { SolrSort } from '@/api/models';
import { buildSearchOutgoing } from '@/utils/common/searchMode';
import { useSearchMode } from '@/lib/useSearchMode';

export const AbstractSearchForm = () => {
const { settings } = useSettings();
const submitQuery = useStore((state) => state.submitQuery);
const sort = [`${settings.preferredSearchSort} desc` as SolrSort];
const query = useStore((state) => state.query.q);
const [searchMode] = useSearchMode();

/**
* Take in a query object and apply any FQ filters
* These will either be any default ON filters or whatever has been set by the user in the preferences
*/
const applyDefaultFilters = useCallback(
(query: IADSApiSearchParams) => {
const defaultDatabases = getListOfAppliedDefaultDatabases(settings.defaultDatabase);
Expand All @@ -35,18 +34,12 @@ export const AbstractSearchForm = () => {
[settings.defaultDatabase],
);

/**
* Get a list of default databases that have been applied
* @param databases
*/
const getListOfAppliedDefaultDatabases = (databases: IADSApiUserDataResponse['defaultDatabase']): Array<string> => {
const defaultDatabases = [];
for (const db of databases) {
// if All is selected, exit early here and return an empty array (no filters to apply)
if (db.name === DatabaseEnum.All && db.value) {
return [];
}

if (db.value) {
defaultDatabases.push(db.name);
}
Expand All @@ -62,13 +55,14 @@ export const AbstractSearchForm = () => {
if (query && query.trim().length > 0) {
submitQuery();
const defaultedQuery = applyDefaultFilters({ q: query, sort, p: 1 }) as IADSApiSearchParams;
const outgoing = buildSearchOutgoing(defaultedQuery, searchMode);
void router.push({
pathname: '/search',
search: makeSearchParams(defaultedQuery),
search: makeSearchParams(outgoing),
});
}
},
[applyDefaultFilters, sort, submitQuery],
[applyDefaultFilters, searchMode, sort, submitQuery],
);

return (
Expand Down
5 changes: 4 additions & 1 deletion src/components/ClassicForm/ClassicForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { Sort } from '@/components/Sort';
import { Expandable } from '@/components/Expandable';
import { SimpleCopyButton } from '@/components/CopyButton';
import { normalizeSolrSort } from '@/utils/common/search';
import { ADS_COMPAT_URL_PARAM } from '@/utils/common/searchMode';
import { SolrSort, SolrSortField } from '@/api/models';

const propTypes = {
Expand Down Expand Up @@ -84,7 +85,9 @@ export const ClassicForm = (props: IClassicFormProps) => {
void handleSubmit((params) => {
try {
const search = getSearchQuery(params, { mode });
void router.push({ pathname: '/search', search });
const urlParams = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
urlParams.set(ADS_COMPAT_URL_PARAM, '1');
Comment thread
thostetler marked this conversation as resolved.
void router.push({ pathname: '/search', search: '?' + urlParams.toString() });
} catch (e) {
setQueryError((e as Error)?.message);
}
Expand Down
24 changes: 15 additions & 9 deletions src/components/SearchBar/__tests__/SearchBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render } from '@/test-utils';
import { fireEvent, render } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { SearchBar } from '../index';
Expand All @@ -21,7 +21,9 @@ vi.mock('@/lib/useLandingFormPreference', () => ({
}));

beforeEach(() => {
vi.useFakeTimers();
// Exclude requestAnimationFrame from fake timers to prevent Framer Motion's animation
// loop from cascading under vi.advanceTimersByTimeAsync and inflating test runtime.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
});

afterEach(() => {
Expand Down Expand Up @@ -104,10 +106,12 @@ test('Escape key closes typeahead', async () => {
await user.type(input, 'sim');
await vi.advanceTimersByTimeAsync(500);
expect(queryByTestId('search-autocomplete-menu')).toBeVisible();
// Escape dispatches KEYDOWN_ESCAPE synchronously; no timer advancement needed.
await user.keyboard('{Escape}');
await vi.advanceTimersByTimeAsync(500);
expect(queryByTestId('search-autocomplete-menu')).not.toBeVisible();
});
// 15s timeout: vi.advanceTimersByTimeAsync(500) triggers Chakra/React work that
// accumulates across the suite; this test passes easily in isolation but needs headroom.
}, 15000);

test('Clearing input closes typeahead menu', async () => {
const user = createUser();
Expand All @@ -116,8 +120,8 @@ test('Clearing input closes typeahead menu', async () => {
await user.type(input, 'sim');
await vi.advanceTimersByTimeAsync(500);
expect(queryByTestId('search-autocomplete-menu')).toBeVisible();
// HARD_RESET dispatches synchronously; no timer advancement needed.
await user.click(getByTestId('search-clearbtn'));
await vi.advanceTimersByTimeAsync(500);
expect(queryByTestId('search-autocomplete-menu')).not.toBeVisible();
});

Expand Down Expand Up @@ -184,10 +188,11 @@ test('Typing a non-matching term does not open typeahead', async () => {
});

test('Typing an exact match for a typeahead option closes the menu', async () => {
const user = createUser();
const { getByTestId, queryByTestId } = render(<SearchBar />);
const input = getByTestId('search-input');
await user.type(input, 'similar()');
// fireEvent.change simulates the handleInputChange path without triggering the
// React-scheduler timer cascade that makes long user.type() calls exceed the timeout.
fireEvent.change(input, { target: { value: 'similar()', selectionStart: 9 } });
await vi.advanceTimersByTimeAsync(500);
expect(queryByTestId('search-autocomplete-menu')).not.toBeVisible();
});
Expand Down Expand Up @@ -253,8 +258,9 @@ test('Arrow down does nothing when cursor is not at end of input', async () => {
const user = createUser();
const { getByTestId, queryByTestId } = render(<SearchBar />);
const input = getByTestId('search-input') as HTMLInputElement;
await user.type(input, 'test query');
input.setSelectionRange(4, 4);
await user.type(input, 'test');
// Place cursor before the end to disable ArrowDown menu-open behaviour
input.setSelectionRange(0, 0);
await user.keyboard('{ArrowDown}');
await vi.advanceTimersByTimeAsync(500);
expect(queryByTestId('search-autocomplete-menu')).not.toBeVisible();
Expand Down
76 changes: 76 additions & 0 deletions src/components/SearchModifierBar/SearchModifierBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Box, BoxProps, Button, Circle, Menu, MenuButton, MenuItem, MenuList, Text, VStack } from '@chakra-ui/react';
import { ChevronDownIcon } from '@chakra-ui/icons';
import { useRouter } from 'next/router';
import { omit } from 'ramda';
import { ADS_COMPAT_URL_PARAM, SEARCH_MODE_OPTIONS, SearchMode } from '@/utils/common/searchMode';
import { useSearchMode } from '@/lib/useSearchMode';

interface SearchModifierBarProps extends BoxProps {
onModeChange?: (mode: SearchMode) => void;
onNavigate?: (mode: SearchMode) => void;
isDisabled?: boolean;
}

export const SearchModifierBar = ({ onModeChange, onNavigate, isDisabled, ...boxProps }: SearchModifierBarProps) => {
const router = useRouter();
const [storedMode, setStoredMode] = useSearchMode();
const urlAdsCompat = router.query[ADS_COMPAT_URL_PARAM] === '1';
const currentMode =
urlAdsCompat || storedMode === SearchMode.ADS_COMPAT ? SearchMode.ADS_COMPAT : SearchMode.ALL_RELEVANT;
const currentOption = SEARCH_MODE_OPTIONS.find((o) => o.mode === currentMode) ?? SEARCH_MODE_OPTIONS[0];

const handleModeChange = (newMode: SearchMode) => {
setStoredMode(newMode);
onModeChange?.(newMode);
if (onNavigate) {
onNavigate(newMode);
} else {
const updatedQuery =
newMode === SearchMode.ADS_COMPAT
? { ...router.query, [ADS_COMPAT_URL_PARAM]: '1' }
: omit([ADS_COMPAT_URL_PARAM], router.query);
void router.push({ pathname: router.pathname, query: updatedQuery }, undefined, { shallow: true });
}
Comment thread
thostetler marked this conversation as resolved.
};

const modeColor = currentMode === SearchMode.ADS_COMPAT ? 'orange.400' : 'teal.400';

return (
<Box {...boxProps}>
<Menu>
<MenuButton
as={Button}
variant="ghost"
size="sm"
leftIcon={<Circle size="8px" bg={modeColor} />}
rightIcon={<ChevronDownIcon />}
aria-label={`Search mode: ${currentOption.label}`}
isDisabled={isDisabled}
>
<Text as="span" fontWeight="normal" mr={1} fontSize="sm">
Search mode:
</Text>
<Text as="span" fontWeight="semibold" fontSize="sm">
{currentOption.label}
</Text>
</MenuButton>
<MenuList>
{SEARCH_MODE_OPTIONS.map((option) => (
<MenuItem
key={option.mode}
onClick={() => handleModeChange(option.mode)}
fontWeight={option.mode === currentMode ? 'semibold' : 'normal'}
>
<VStack align="start" spacing={0}>
<Text fontSize="sm">{option.label}</Text>
<Text fontSize="xs" color="gray.500">
{option.helperText}
</Text>
</VStack>
</MenuItem>
))}
</MenuList>
</Menu>
</Box>
);
};
Loading
Loading