Skip to content
Closed
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
93 changes: 93 additions & 0 deletions website/src/views/components/filters/DateTimeRangeFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { mount } from 'enzyme';
import { SearchkitManager } from 'searchkit';

import DateTimeRangeFilter from './DateTimeRangeFilter';

describe(DateTimeRangeFilter, () => {
let mockSearchkit: SearchkitManager;

beforeEach(() => {
mockSearchkit = SearchkitManager.mock();
});

it('renders start and end datetime inputs', () => {
const wrapper = mount(
<DateTimeRangeFilter
searchkit={mockSearchkit}
id="examDate"
title="Exam Date"
field="semesterData.examDate"
fieldOptions={{ type: 'nested', options: { path: 'semesterData' } }}
/>,
);

const inputs = wrapper.find('input[type="datetime-local"]');

expect(inputs).toHaveLength(2);
expect(inputs.at(0).prop('id')).toBe('examDate-start');
expect(inputs.at(1).prop('id')).toBe('examDate-end');
});

it('applies a nested exam date range filter for the selected bounds', () => {
const performSearchSpy = vi.spyOn(mockSearchkit, 'performSearch');
const wrapper = mount(
<DateTimeRangeFilter
searchkit={mockSearchkit}
id="examDate"
title="Exam Date"
field="semesterData.examDate"
fieldOptions={{ type: 'nested', options: { path: 'semesterData' } }}
/>,
);

wrapper.find('#examDate-start').simulate('change', {
target: { value: '2026-04-25T09:00' },
});
wrapper.find('#examDate-end').simulate('change', {
target: { value: '2026-04-27T18:30' },
});

const query = mockSearchkit.buildQuery().getJSON();

expect(performSearchSpy).toHaveBeenCalledTimes(2);
expect(query.post_filter).toEqual({
nested: {
path: 'semesterData',
query: {
range: {
'semesterData.examDate': {
gte: '2026-04-25T09:00:00.000Z',
lte: '2026-04-27T18:30:00.000Z',
},
},
},
},
});
expect(mockSearchkit.buildQuery().getSelectedFilters()[0]).toMatchObject({
name: 'Exam Date',
value: '2026-04-25T09:00 - 2026-04-27T18:30',
id: 'examDate',
});
});

it('clears the filter when both bounds are empty', () => {
const wrapper = mount(
<DateTimeRangeFilter
searchkit={mockSearchkit}
id="examDate"
title="Exam Date"
field="semesterData.examDate"
fieldOptions={{ type: 'nested', options: { path: 'semesterData' } }}
/>,
);

wrapper.find('#examDate-start').simulate('change', {
target: { value: '2026-04-25T09:00' },
});
wrapper.find('#examDate-start').simulate('change', {
target: { value: '' },
});

expect(mockSearchkit.buildQuery().getJSON().post_filter).toBeUndefined();
});
});
157 changes: 157 additions & 0 deletions website/src/views/components/filters/DateTimeRangeFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import * as React from 'react';
import {
FieldContextFactory,
FieldOptions,
FilterBasedAccessor,
ImmutableQuery,
ObjectState,
RangeQuery,
SearchkitComponent,
SearchkitComponentProps,
} from 'searchkit';

import FilterContainer from './FilterContainer';

type DateTimeRange = {
start?: string;
end?: string;
};

interface DateTimeRangeAccessorOptions {
id: string;
title: string;
field: string;
fieldOptions?: FieldOptions;
}

class DateTimeRangeAccessor extends FilterBasedAccessor<ObjectState> {
override state = new ObjectState({});

private readonly title: string;

private readonly field: string;

private readonly fieldContext;

constructor(key: string, { id, title, field, fieldOptions }: DateTimeRangeAccessorOptions) {
super(key, id);
this.title = title;
this.field = field;

const resolvedFieldOptions = fieldOptions || { type: 'embedded' };
resolvedFieldOptions.field = field;
this.fieldContext = FieldContextFactory(resolvedFieldOptions);
}

override buildSharedQuery(query: ImmutableQuery) {
if (!this.state.hasValue()) return query;

const value = this.state.getValue() as DateTimeRange;
const rangeQuery = getRangeQuery(value);
if (!rangeQuery) return query;

return query
.addFilter(this.uuid, this.fieldContext.wrapFilter(RangeQuery(this.field, rangeQuery)))
.addSelectedFilter({
name: this.title,
value: getSelectedValue(value),
id: this.key,
remove: () => {
this.state = this.state.clear();
},
});
}
}

interface Props extends SearchkitComponentProps {
id: string;
title: string;
field: string;
fieldOptions?: FieldOptions;
}

function toDateRange(nextRange: DateTimeRange): DateTimeRange | null {
const entries = Object.entries(nextRange).filter(([, value]) => Boolean(value));
return entries.length ? Object.fromEntries(entries) : null;
}

function normalizeDateTime(value: string): string {
const parsedDate = new Date(value);
return Number.isNaN(parsedDate.getTime()) ? value : parsedDate.toISOString();
}

function getRangeQuery({ start, end }: DateTimeRange): Record<string, string> | null {
const rangeQuery = {
...(start ? { gte: normalizeDateTime(start) } : {}),
...(end ? { lte: normalizeDateTime(end) } : {}),
};

return Object.keys(rangeQuery).length ? rangeQuery : null;
}

function getSelectedValue({ start, end }: DateTimeRange): string {
if (start && end) return `${start} - ${end}`;
return start || end || '';
}

export default class DateTimeRangeFilter extends SearchkitComponent<Props, never> {
declare accessor: DateTimeRangeAccessor;

override defineAccessor() {
const { id, title, field, fieldOptions } = this.props;
return new DateTimeRangeAccessor(id, { id, title, field, fieldOptions });
}

setRangeValue =
(field: keyof DateTimeRange): React.ChangeEventHandler<HTMLInputElement> =>
(evt) => {
const currentRange = (this.accessor.state.getValue() as DateTimeRange) || {};
const nextRange = toDateRange({
...currentRange,
[field]: evt.target.value,
});

this.accessor.state = nextRange
? this.accessor.state.setValue(nextRange)
: this.accessor.state.clear();
this.searchkit.performSearch();
};

override render() {
if (!this.accessor) return null;

const { id, title } = this.props;
const { start = '', end = '' } = (this.accessor.state.getValue() as DateTimeRange) || {};

return (
<FilterContainer title={title}>
<div className="mb-2">
<label className="small d-block" htmlFor={`${id}-start`}>
Start
</label>
<input
id={`${id}-start`}
type="datetime-local"
className="form-control form-control-sm"
value={start}
max={end || undefined}
onChange={this.setRangeValue('start')}
/>
</div>
<div>
<label className="small d-block" htmlFor={`${id}-end`}>
End
</label>
<input
id={`${id}-end`}
type="datetime-local"
className="form-control form-control-sm"
value={end}
min={start || undefined}
onChange={this.setRangeValue('end')}
/>
</div>
</FilterContainer>
);
}
}
11 changes: 11 additions & 0 deletions website/src/views/modules/ModuleFinderSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { RefinementItem } from 'types/views';
import SideMenu, { OPEN_MENU_LABEL } from 'views/components/SideMenu';
import FilterContainer from 'views/components/filters/FilterContainer';
import CheckboxItem from 'views/components/filters/CheckboxItem';
import DateTimeRangeFilter from 'views/components/filters/DateTimeRangeFilter';
import DropdownListFilters from 'views/components/filters/DropdownListFilters';
import RandomPicker from 'views/components/searchkit/RandomPicker';

Expand Down Expand Up @@ -144,6 +145,16 @@ const ModuleFinderSidebar: React.FC = () => {

<ChecklistFilter title="Exams" items={examFilters} />

<DateTimeRangeFilter
id="examDate"
title="Exam Date"
field="semesterData.examDate"
fieldOptions={{
type: 'nested',
options: { path: 'semesterData' },
}}
/>

<RefinementListFilter
id="level"
title="Level"
Expand Down