Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
129 changes: 107 additions & 22 deletions apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
import analyticsEnum from '$lib/analytics/analytics';
import { Grades } from '$lib/grades';
import { getLocalStorageRecruitmentDismissalTime, setLocalStorageRecruitmentDismissalTime } from '$lib/localStorage';
import { getMultiGeCourseKey, isMultiGeSelection, queryManualSearchCourses } from '$lib/multiGeSearch';
import { WebSOC } from '$lib/websoc';

Check warning on line 28 in apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

Identifier 'WebSOC' is imported but never used.
import { BLUE, PROJECTS_LINK } from '$src/globals';
import AppStore from '$stores/AppStore';
import { useCoursePaneStore } from '$stores/CoursePaneStore';
Expand Down Expand Up @@ -95,6 +96,44 @@
return allCourses;
}

const getLazyLoadHeight = (item: WebsocSchool | WebsocDepartment | AACourse) => {
if ('sections' in item) {
return item.sections.length * 60 + 20 + 40;
}
return 200;
};

const getAndResultCounts = (
flattenedCourseData: (WebsocSchool | WebsocDepartment | AACourse)[],
sharedCourseKeys: Set<string>
) => {
let andCourseCount = 0;
let currentSchoolName = '';
const andSchoolNames = new Set<string>();

for (const item of flattenedCourseData) {
if ('departments' in item) {
currentSchoolName = item.schoolName;
continue;
}

if (!('sections' in item)) {
continue;
}

if (!sharedCourseKeys.has(getMultiGeCourseKey(item.deptCode, item.courseNumber))) {
continue;
}

andCourseCount += 1;
if (currentSchoolName) {
andSchoolNames.add(currentSchoolName);
}
}

return { andCourseCount, andSchoolCount: andSchoolNames.size };
};

const RecruitmentBanner = () => {
const [bannerVisibility, setBannerVisibility] = useState(true);
const theme = useTheme();
Expand Down Expand Up @@ -263,6 +302,9 @@
export default function CourseRenderPane(props: { id?: number }) {
const [websocResp, setWebsocResp] = useState<WebsocAPIResponse>();
const [courseData, setCourseData] = useState<(WebsocSchool | WebsocDepartment | AACourse)[]>([]);
const [sharedCourseKeys, setSharedCourseKeys] = useState<Set<string>>(new Set<string>());
const [andCourseCount, setAndCourseCount] = useState(0);
const [andSchoolCount, setAndSchoolCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames());
Expand Down Expand Up @@ -294,17 +336,15 @@

const gradesQueryParams = {
department: formData.deptValue,
ge: formData.ge as GE,
ge: formData.ge !== 'ANY' && !formData.ge.includes(',') ? (formData.ge as GE) : ('ANY' as GE),
instructor: formData.instructor,
sectionCode: formData.sectionCode,
};

try {
// Query websoc for course information and populate gradescache
const [websocJsonResp, _] = await Promise.all([
websocQueryParams.units.includes(',')
? WebSOC.queryMultiple(websocQueryParams, 'units')
: WebSOC.query(websocQueryParams),
const [{ response: websocJsonResp, sharedCourseKeys: fetchedSharedCourseKeys }] = await Promise.all([
queryManualSearchCourses(websocQueryParams),
// Catch the error here so that the course pane still loads even if the grades cache fails to populate
Grades.populateGradesCache(gradesQueryParams).catch((error) => {
console.error(error);
Expand All @@ -314,8 +354,12 @@

setError(false);
setWebsocResp(websocJsonResp);
const allCourses = flattenSOCObject(websocJsonResp);
setCourseData(getFilteredCourses(allCourses));
const allCourses = getFilteredCourses(flattenSOCObject(websocJsonResp));
const andCounts = getAndResultCounts(allCourses, fetchedSharedCourseKeys);
Comment thread
vicksey marked this conversation as resolved.
Outdated
setCourseData(allCourses);
setSharedCourseKeys(fetchedSharedCourseKeys);
setAndSchoolCount(andCounts.andSchoolCount);
setAndCourseCount(andCounts.andCourseCount);
} catch (error) {
console.error(error);
setError(true);
Expand All @@ -335,15 +379,19 @@
return;
}
const flattened = flattenSOCObject(websocResp);
setCourseData(getFilteredCourses(flattened));
const filteredCourses = getFilteredCourses(flattened);
const andCounts = getAndResultCounts(filteredCourses, sharedCourseKeys);
setCourseData(filteredCourses);
setAndSchoolCount(andCounts.andSchoolCount);
setAndCourseCount(andCounts.andCourseCount);
};

AppStore.on('currentScheduleIndexChange', changeColors);

return () => {
AppStore.off('currentScheduleIndexChange', changeColors);
};
}, [websocResp]);
}, [sharedCourseKeys, websocResp]);

useEffect(() => {
loadCourses();
Expand All @@ -365,6 +413,18 @@
};
}, [setHoveredEvent]);

const currGeSelection = RightPaneStore.getFormData().ge;
const isMultiGeSearch = isMultiGeSelection(currGeSelection);
const showNoIntersection = isMultiGeSearch && andCourseCount === 0;
let orBannerIndex = -1;
if (isMultiGeSearch && !showNoIntersection) {
let schoolCount = 0;
orBannerIndex = courseData.findIndex((item) => {
if (!('departments' in item)) return false;
return schoolCount++ === andSchoolCount;
});
}

return (
<>
<Box sx={{ height: '56px' }} />
Expand All @@ -377,19 +437,44 @@
<>
<RecruitmentBanner />
<Box>
{courseData.map((_: WebsocSchool | WebsocDepartment | AACourse, index: number) => {
let heightEstimate = 200;
if ((courseData[index] as AACourse).sections !== undefined)
heightEstimate = (courseData[index] as AACourse).sections.length * 60 + 20 + 40;
return (
<LazyLoad once key={index} overflow height={heightEstimate} offset={1000}>
{SectionTableWrapped(index, {
courseData: courseData,
scheduleNames: scheduleNames,
})}
</LazyLoad>
);
})}
{showNoIntersection && (
<Alert
severity="warning"
sx={{
mb: 1,
fontSize: '1rem',
'& .MuiAlert-message': {
display: 'flex',
alignItems: 'center',
},
}}
>
No courses match all selected GEs. The results below match at least one selected GE.
</Alert>
)}
{courseData.map((item, index) => (
<LazyLoad once key={index} overflow height={getLazyLoadHeight(item)} offset={1000}>
{index === orBannerIndex && (
<Alert
severity="warning"
sx={{
mb: 1,
fontSize: '1rem',
'& .MuiAlert-message': {
display: 'flex',
alignItems: 'center',
},
}}
>
The courses below include at least ONE GE selected.
</Alert>
)}
{SectionTableWrapped(index, {
courseData: courseData,
scheduleNames: scheduleNames,
})}
</LazyLoad>
))}
</Box>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export function AdvancedSearchTextFields() {
}

if (name === 'online') {
const stateObj = { url: 'url' };
const url = new URL(window.location.href);
const urlParam = new URLSearchParams(url.search);
const checked = (event as ChangeEvent<HTMLInputElement>).target.value === 'true';
Expand All @@ -154,6 +155,9 @@ export function AdvancedSearchTextFields() {
urlParam.delete('building');
urlParam.delete('room');
}
const param = urlParam.toString();
const newUrl = `${param.trim() ? '?' : ''}${param}`;
history.replaceState(stateObj, 'url', '/' + newUrl);
Comment thread
vicksey marked this conversation as resolved.
Outdated
return;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { MenuItem, type SelectChangeEvent } from '@mui/material';
import { Checkbox, ListItemText, MenuItem, type SelectChangeEvent } from '@mui/material';
import { useEffect, useCallback, useState } from 'react';

import { LabeledSelect } from '$components/RightPane/CoursePane/SearchForm/LabeledInputs/LabeledSelect';
import RightPaneStore from '$components/RightPane/RightPaneStore';
import { getSelectedGEs, normalizeGeSelection } from '$lib/multiGeSearch';

const GE_LIST = [
{ value: 'ANY', label: "All: Don't filter for GE" },
Expand All @@ -18,23 +19,30 @@ const GE_LIST = [
{ value: 'GE-8', label: 'GE VIII (8): International/Global Issues' },
] as const;

const ANY_GE = GE_LIST[0].value;
const getLabel = (value: string) => GE_LIST.find((ge) => ge.value === value)?.label ?? value;

export function GeSelector() {
const [ge, setGe] = useState(() => RightPaneStore.getFormData().ge);
const selectedGEs = getSelectedGEs(ge);

const handleChange = (event: SelectChangeEvent<string>) => {
const handleChange = (event: SelectChangeEvent<string[]>) => {
const value = event.target.value;
const values = (typeof value === 'string' ? value.split(',') : value).filter(Boolean);
const selectedValues = values.includes(ANY_GE) ? [] : values.filter((currentValue) => currentValue !== ANY_GE);
const searchValue = normalizeGeSelection(selectedValues.join(','));

setGe(value);
RightPaneStore.updateFormValue('ge', value);
setGe(searchValue);
RightPaneStore.updateFormValue('ge', searchValue);

const stateObj = { url: 'url' };
const url = new URL(window.location.href);
const urlParam = new URLSearchParams(url.search);

urlParam.delete('ge');

if (value !== 'ANY') {
urlParam.append('ge', value);
if (searchValue !== ANY_GE) {
urlParam.append('ge', searchValue);
}

const param = urlParam.toString();
Expand All @@ -43,7 +51,7 @@ export function GeSelector() {
};

const resetField = useCallback(() => {
setGe(RightPaneStore.getFormData().ge);
setGe(normalizeGeSelection(RightPaneStore.getFormData().ge));
}, []);

useEffect(() => {
Expand All @@ -58,18 +66,30 @@ export function GeSelector() {
<LabeledSelect
label="General Education"
selectProps={{
value: ge,
multiple: true,
displayEmpty: true,
value: selectedGEs,
onChange: handleChange,
renderValue: (selected) => {
const values = selected as string[];
if (values.length === 0) return getLabel(ANY_GE);
if (values.length === 1) return getLabel(values[0]);
return values.map((value) => getLabel(value).split(':')[0].trim()).join(' + ');
},
sx: {
width: '100%',
},
}}
isAligned={true}
>
{GE_LIST.map((category) => {
const isChecked =
category.value === ANY_GE ? selectedGEs.length === 0 : selectedGEs.includes(category.value);

return (
<MenuItem key={category.value} value={category.value}>
{category.label}
<MenuItem key={category.value} value={category.value} sx={{ paddingY: 0.25 }}>
<Checkbox checked={isChecked} size="small" />
<ListItemText primary={category.label} />
</MenuItem>
);
})}
Expand Down
31 changes: 30 additions & 1 deletion apps/antalmanac/src/components/RightPane/RightPaneStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EventEmitter } from 'events';

import { AdvancedSearchParam, ManualSearchParam } from '$components/RightPane/CoursePane/SearchForm/constants';
import { normalizeGeSelection } from '$lib/multiGeSearch';
import { getDefaultTerm } from '$lib/termData';

const defaultAdvancedSearchValues: Record<AdvancedSearchParam, string> = {
Expand Down Expand Up @@ -40,11 +41,39 @@ class RightPaneStore extends EventEmitter {
private urlCourseNumValue: string;
private urlDeptValue: string;

private normalizeGeQueryParam = (search: URLSearchParams) => {
const rawGeValue = search.get('ge') ?? search.get('GE');
if (rawGeValue == null) {
return;
}

const normalizedGe = normalizeGeSelection(rawGeValue);
const currentGe = search.get('ge');
const hadUppercaseGeParam = search.has('GE');

search.delete('GE');
if (normalizedGe === 'ANY') {
search.delete('ge');
} else {
search.set('ge', normalizedGe);
}

const wasChanged = (currentGe ?? '') !== (normalizedGe === 'ANY' ? '' : normalizedGe) || hadUppercaseGeParam;
if (!wasChanged) {
return;
}

const nextQuery = search.toString();
const nextURL = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}${window.location.hash}`;
history.replaceState({ url: 'url' }, 'url', nextURL);
};

constructor() {
super();
this.setMaxListeners(15);
this.formData = structuredClone(defaultFormValues);
const search = new URLSearchParams(window.location.search);
this.normalizeGeQueryParam(search);
this.urlSectionCodeValue = search.get('sectionCode') || '';
this.urlTermValue = search.get('term') || '';
this.urlGEValue = search.get('ge') || '';
Expand All @@ -61,7 +90,7 @@ class RightPaneStore extends EventEmitter {
const paramValue = search.get(field) || search.get(field.toUpperCase());

if (paramValue !== null) {
this.formData[field] = paramValue;
this.formData[field] = field === 'ge' ? normalizeGeSelection(paramValue) : paramValue;
}
});

Expand Down
Loading
Loading