Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow customising search fields #1569

Merged
merged 10 commits into from
Apr 10, 2024
Merged
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
5 changes: 5 additions & 0 deletions kubernetes/loculus/templates/_common-metadata.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ fields:
type: string
generateIndex: true
autocomplete: true
displayName: Data use terms
initiallyVisible: true
customDisplay:
type: dataUseTerms
- name: versionStatus
Expand Down Expand Up @@ -91,6 +93,9 @@ fields:
{{- if .notSearchable }}
notSearchable: {{ .notSearchable }}
{{- end }}
{{- if .initiallyVisible }}
initiallyVisible: {{ .initiallyVisible }}
{{- end }}
{{- if .displayName }}
displayName: {{ .displayName }}
{{- end }}
Expand Down
8 changes: 8 additions & 0 deletions kubernetes/loculus/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,28 @@ defaultOrganisms:
metadata:
- name: date
type: date
initiallyVisible: true
- name: region
type: string
initiallyVisible: true
generateIndex: true
autocomplete: true
- name: country
initiallyVisible: true
type: string
generateIndex: true
autocomplete: true
- name: division
initiallyVisible: true
type: string
generateIndex: true
autocomplete: true
- name: host
initiallyVisible: true
type: string
autocomplete: true
- name: pango_lineage
initiallyVisible: true
type: pango_lineage
autocomplete: true
required: true
Expand Down Expand Up @@ -273,6 +279,7 @@ defaultOrganisms:
displayName: Collection date
type: date
required: true
initiallyVisible: true
- name: ncbi_release_date
displayName: NCBI release date
type: date
Expand All @@ -281,6 +288,7 @@ defaultOrganisms:
required: true
generateIndex: true
autocomplete: true
initiallyVisible: true
- name: isolate_name
displayName: Isolate name
type: string
Expand Down
97 changes: 97 additions & 0 deletions website/src/components/SearchPage/CustomizeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Dialog, Transition } from '@headlessui/react';

interface CheckboxFieldProps {
label: string;
checked: boolean;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
}

const CheckboxField: React.FC<CheckboxFieldProps> = ({ label, checked, onChange, disabled }) => (
<div className='mb-2'>
<label className='flex items-center cursor-pointer'>
<input
type='checkbox'
checked={checked}
onChange={onChange}
className={`form-checkbox h-5 w-5 ${disabled !== true ? 'text-blue-600' : 'text-gray-300'}`}
disabled={disabled}
/>
<span className='ml-2 text-gray-700'>{label}</span>
</label>
</div>
);

interface FieldValue {
name: string;
label: string;
isVisible?: boolean;
notSearchable?: boolean;
}

interface CustomizeModalProps {
isCustomizeModalOpen: boolean;
toggleCustomizeModal: () => void;
alwaysPresentFieldNames: string[];
fieldValues: FieldValue[];
handleFieldVisibilityChange: (fieldName: string, isVisible: boolean) => void;
}

export const CustomizeModal: React.FC<CustomizeModalProps> = ({
isCustomizeModalOpen,
toggleCustomizeModal,
alwaysPresentFieldNames,
fieldValues,
handleFieldVisibilityChange,
}) => {
return (
<Transition appear show={isCustomizeModalOpen}>
<Dialog as='div' className='fixed inset-0 z-10 overflow-y-auto' onClose={toggleCustomizeModal}>
<div className='min-h-screen px-4 text-center'>
<Dialog.Overlay className='fixed inset-0 bg-black opacity-30' />

<span className='inline-block h-screen align-middle' aria-hidden='true'>
&#8203;
</span>

<div className='inline-block w-full max-w-md p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl text-sm'>
<Dialog.Title as='h3' className='text-lg font-medium leading-6 text-gray-900'>
Customize Search Fields
</Dialog.Title>

<div className='mt-4 text-gray-700 text-sm'>Toggle the visibility of search fields</div>

<div className='mt-4'>
{alwaysPresentFieldNames.map((fieldName) => (
<CheckboxField key={fieldName} label={fieldName} checked disabled />
))}

{fieldValues
.filter((field) => field.notSearchable !== true)
.map((field) => (
<CheckboxField
key={field.name}
label={field.label}
checked={field.isVisible !== false}
onChange={(e) => {
handleFieldVisibilityChange(field.name, e.target.checked);
}}
/>
))}
</div>

<div className='mt-6'>
<button
type='button'
className='inline-flex justify-center px-4 py-2 text-sm font-medium text-blue-900 bg-blue-100 border border-transparent rounded-md hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500'
onClick={toggleCustomizeModal}
>
Close
</button>
</div>
</div>
</div>
</Dialog>
</Transition>
);
};
59 changes: 56 additions & 3 deletions website/src/components/SearchPage/SearchForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ import type { MetadataFilter } from '../../types/config.ts';
import type { ReferenceGenomesSequenceNames } from '../../types/referencesGenomes.ts';
import type { ClientConfig } from '../../types/runtimeConfig.ts';

global.ResizeObserver = class FakeResizeObserver {
// This is needed or we get a test failure: https://github.com/tailwindlabs/headlessui/discussions/2414
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, @typescript-eslint/no-empty-function
observe() {}
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, @typescript-eslint/no-empty-function
disconnect() {}
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, @typescript-eslint/no-empty-function
unobserve() {}
};

const searchButtonText = 'Search sequences';

vi.mock('../../config', () => ({
Expand All @@ -20,9 +30,30 @@ vi.mock('../../config', () => ({
const queryClient = new QueryClient();

const defaultSearchFormFilters = [
{ name: 'field1', type: 'string' as const, label: 'Field 1', autocomplete: false, filterValue: '' },
{ name: 'field2', type: 'date' as const, autocomplete: false, filterValue: '', label: 'Field 2' },
{ name: 'field3', type: 'pango_lineage' as const, label: 'Field 3', autocomplete: true, filterValue: '' },
{
name: 'field1',
type: 'string' as const,
label: 'Field 1',
autocomplete: false,
filterValue: '',
initiallyVisible: true,
},
{
name: 'field2',
type: 'date' as const,
autocomplete: false,
filterValue: '',
label: 'Field 2',
initiallyVisible: true,
},
{
name: 'field3',
type: 'pango_lineage' as const,
label: 'Field 3',
autocomplete: true,
filterValue: '',
initiallyVisible: true,
},
];

const defaultReferenceGenomesSequenceNames = {
Expand Down Expand Up @@ -90,6 +121,7 @@ describe('SearchForm', () => {
autocomplete: false,
filterValue: '',
notSearchable: true,
initiallyVisible: true,
},
]);

Expand All @@ -104,6 +136,7 @@ describe('SearchForm', () => {
name: timestampFieldName,
type: 'timestamp' as const,
filterValue: '1706147200',
initiallyVisible: true,
},
]);

Expand All @@ -128,6 +161,7 @@ describe('SearchForm', () => {
name: dateFieldName,
type: 'date' as const,
filterValue: '2024-01-25',
initiallyVisible: true,
},
]);
const dateLabel = screen.getByText('Date field');
Expand All @@ -143,4 +177,23 @@ describe('SearchForm', () => {

expect(window.location.href).toContain(`${dateFieldName}=2025-01-25`);
});

test('toggle field visibility', async () => {
renderSearchForm();

expect(screen.getByLabelText('Field 1')).toBeVisible();

const customizeButton = screen.getByRole('button', { name: 'Customize fields' });
await userEvent.click(customizeButton);

const field1Checkbox = screen.getByRole('checkbox', { name: 'Field 1' });
expect(field1Checkbox).toBeChecked();

await userEvent.click(field1Checkbox);

const closeButton = screen.getByRole('button', { name: 'Close' });
await userEvent.click(closeButton);

expect(screen.queryByLabelText('Field 1')).not.toBeInTheDocument();
});
});
61 changes: 58 additions & 3 deletions website/src/components/SearchPage/SearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { sentenceCase } from 'change-case';
import { type FC, type FormEventHandler, useMemo, useState, useCallback } from 'react';

import { CustomizeModal } from './CustomizeModal.tsx';
import { AccessionField } from './fields/AccessionField.tsx';
import { AutoCompleteField, type AutoCompleteFieldProps } from './fields/AutoCompleteField';
import { DateField, TimestampField } from './fields/DateField.tsx';
Expand Down Expand Up @@ -50,12 +51,16 @@ export const SearchForm: FC<SearchFormProps> = ({
fieldList.map((filter) => ({
...filter,
label: filter.label ?? filter.displayName ?? sentenceCase(filter.name),
isVisible: filter.initiallyVisible ?? false,
})),
);

const alwaysPresentFieldNames = ['Accession', 'Mutation'];
const [accessionFilter, setAccessionFilter] = useState<AccessionFilter>(initialAccessionFilter);
const [mutationFilter, setMutationFilter] = useState<MutationFilter>(initialMutationFilter);
const [isLoading, setIsLoading] = useState(false);
const { isOpen: isMobileOpen, close: closeOnMobile, toggle: toggleMobileOpen } = useOffCanvas();
const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false);

const handleFieldChange = useCallback(
(metadataName: string, filter: string) => {
Expand Down Expand Up @@ -112,6 +117,9 @@ export const SearchForm: FC<SearchFormProps> = ({
const fields = useMemo(
() =>
fieldValues.map((field) => {
if (field.isVisible !== true) {
return null;
}
if (field.grouped === true) {
return (
<div key={field.name} className='flex flex-col border p-3 mb-3 rounded-md border-gray-300'>
Expand Down Expand Up @@ -145,6 +153,38 @@ export const SearchForm: FC<SearchFormProps> = ({
[fieldValues, handleFieldChange, isLoading, lapisUrl, flattenedFieldValues],
);

const toggleCustomizeModal = () => {
setIsCustomizeModalOpen(!isCustomizeModalOpen);
};

const clearValues = (possiblyGroupedFieldName: string) => {
const fieldInQuestion = fieldValues.find((field) => field.name === possiblyGroupedFieldName);
if (fieldInQuestion === undefined) {
return;
}

if (fieldInQuestion.grouped === true) {
for (const groupedField of fieldInQuestion.groupedFields) {
handleFieldChange(groupedField.name, '');
}
} else {
handleFieldChange(possiblyGroupedFieldName, '');
}
};
const handleFieldVisibilityChange = (fieldName: string, isVisible: boolean) => {
if (isVisible === false) {
clearValues(fieldName);
}
setFieldValues((prev) =>
prev.map((field) => {
if (field.name === fieldName) {
return { ...field, isVisible };
}
return field;
}),
);
};

return (
<QueryClientProvider client={queryClient}>
<div className='text-right -mb-10 md:hidden'>
Expand All @@ -162,13 +202,20 @@ export const SearchForm: FC<SearchFormProps> = ({
<div className='shadow-xl rounded-r-lg px-4 pt-4'>
<div className='flex'>
<h2 className='text-lg font-semibold flex-1 md:hidden'>Search query</h2>
<button className='underline' onClick={resetSearch}>
Reset
</button>
<div className='flex items-center justify-between w-full mb-2 text-primary-700'>
<button className='underline' onClick={toggleCustomizeModal}>
Customize fields
</button>

<button className='underline' onClick={resetSearch}>
Reset
</button>
</div>
<button className='ml-4 md:hidden' onClick={closeOnMobile}>
<SandwichIcon isOpen />
</button>
</div>

<form onSubmit={handleSearch}>
<div className='flex flex-col'>
<AccessionField initialValue={initialAccessionFilter} onChange={setAccessionFilter} />
Expand All @@ -191,6 +238,13 @@ export const SearchForm: FC<SearchFormProps> = ({
</form>
</div>
</div>
<CustomizeModal
isCustomizeModalOpen={isCustomizeModalOpen}
toggleCustomizeModal={toggleCustomizeModal}
alwaysPresentFieldNames={alwaysPresentFieldNames}
fieldValues={fieldValues}
handleFieldVisibilityChange={handleFieldVisibilityChange}
/>
</QueryClientProvider>
);
};
Expand Down Expand Up @@ -267,6 +321,7 @@ const consolidateGroupedFields = (filters: MetadataFilter[]): (MetadataFilter |
groupedFields: [],
type: filter.type,
grouped: true,
initiallyVisible: filter.initiallyVisible,
};
fieldList.push(fieldForGroup);
groupsMap.set(filter.fieldGroup, fieldForGroup);
Expand Down
2 changes: 1 addition & 1 deletion website/src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const { title, implicitOrganism } = Astro.props;
<body>
<div class='flex flex-col min-h-screen w-11/12 mx-auto'>
<ToastContainer client:load />
<header class='z-30 top-0 bg-white h-fit'>
<header class='bg-white h-fit'>
<nav class='flex justify-between items-center p-4'>
<div class='flex justify-start'>
<div class='flex flex-col'>
Expand Down
Loading
Loading