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
38 changes: 38 additions & 0 deletions apps/backend/src/modules/catalog/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface CatalogQueryParams {
filters?: {
levels?: string[] | null;
departments?: string[] | null;
subjects?: string[] | null;
unitsMin?: number | null;
unitsMax?: number | null;
days?: number[] | null;
Expand All @@ -50,6 +51,7 @@ export interface CatalogQueryParams {
breadths?: string[] | null;
universityRequirements?: string[] | null;
online?: boolean | null;
isDecal?: boolean | null;
} | null;
sortBy?: string | null;
sortOrder?: string | null;
Expand Down Expand Up @@ -180,6 +182,15 @@ const applyInMemoryFilters = (
return false;
}

// Subject filter
if (
filters.subjects &&
filters.subjects.length > 0 &&
!filters.subjects.includes(item.subject ?? "")
) {
return false;
}

// Units range overlap
if (filters.unitsMin != null || filters.unitsMax != null) {
const min = filters.unitsMin ?? 0;
Expand Down Expand Up @@ -283,6 +294,14 @@ const applyInMemoryFilters = (
return false;
}

// DeCal filter
if (filters.isDecal === true && !item.decal?.title) {
return false;
}
if (filters.isDecal === false && item.decal?.title) {
return false;
}

return true;
});
};
Expand All @@ -306,6 +325,11 @@ const buildFilterQuery = (
query.academicOrganization = { $in: filters.departments };
}

// Subject filter
if (filters.subjects && filters.subjects.length > 0) {
query.subject = { $in: filters.subjects };
}

// Units range overlap
if (filters.unitsMin != null || filters.unitsMax != null) {
const min = filters.unitsMin ?? 0;
Expand Down Expand Up @@ -415,6 +439,15 @@ const buildFilterQuery = (
query.primaryOnline = true;
}

// DeCal filter
if (filters.isDecal === true) {
query["decal.title"] = { $exists: true, $ne: null };
} else if (filters.isDecal === false) {
appendAndCondition(query, {
$or: [{ "decal.title": { $exists: false } }, { "decal.title": null }],
});
}

return query;
};

Expand Down Expand Up @@ -465,6 +498,7 @@ export const getCatalogFilterOptions = async (
name: "$academicOrganizationName",
},
},
subjects: { $addToSet: "$subject" },
levels: { $addToSet: "$level" },
gradingOptions: { $addToSet: "$gradingBasis" },
breadthRequirements: { $addToSet: "$breadthRequirements" },
Expand Down Expand Up @@ -515,6 +549,10 @@ export const getCatalogFilterOptions = async (
.sort((a: { name: string }, b: { name: string }) =>
a.name.localeCompare(b.name)
),
subjects: (result?.subjects ?? [])
.filter(Boolean)
.sort()
.map((code: string) => ({ code, name: null })),
levels: (result?.levels ?? []).filter(Boolean).sort(),
gradingOptions: (result?.gradingOptions ?? []).filter(Boolean).sort(),
breadthRequirements: breadths,
Expand Down
8 changes: 8 additions & 0 deletions apps/backend/src/modules/catalog/typedefs/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const catalogTypeDef = gql`
input CatalogFilters {
levels: [String!]
departments: [String!]
subjects: [String!]
unitsMin: Float
unitsMax: Float
days: [Int!]
Expand All @@ -32,6 +33,7 @@ export const catalogTypeDef = gql`
breadths: [String!]
universityRequirements: [String!]
online: Boolean
isDecal: Boolean
}

type CatalogMeeting {
Expand Down Expand Up @@ -166,13 +168,19 @@ export const catalogTypeDef = gql`
name: String!
}

type CatalogSubject {
code: String!
name: String
}

type CatalogTimeRange {
minStartTime: String!
maxEndTime: String!
}

type CatalogFilterOptions {
departments: [CatalogDepartment!]!
subjects: [CatalogSubject!]!
levels: [String!]!
gradingOptions: [String!]!
breadthRequirements: [String!]!
Expand Down
43 changes: 43 additions & 0 deletions apps/frontend/src/components/ClassBrowser/Filters/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { sortByTermDescending } from "@/lib/classes";

import {
Day,
DecalFilter,
EMPTY_DAYS,
EnrollmentFilter,
GradingFilter,
Expand Down Expand Up @@ -45,6 +46,10 @@ export default function Filters() {
updateGradingFilters,
enrollmentFilter,
updateEnrollmentFilter,
decalFilter,
updateDecalFilter,
subjects,
updateSubjects,
sortBy,
reverse,
effectiveOrder,
Expand Down Expand Up @@ -171,6 +176,15 @@ export default function Filters() {
}));
}, []);

const subjectOptions = useMemo<Option<string>[]>(
() =>
(filterOptions?.subjects ?? []).map((subj) => ({
value: subj.code,
label: subj.code,
})),
[filterOptions]
);

const isClassLevelDisabled = Object.values(filteredLevels).every(
(count) => count === 0
);
Expand Down Expand Up @@ -206,6 +220,8 @@ export default function Filters() {
updateTimeRange([null, null]);
updateSortBy(SortBy.Relevance);
updateEnrollmentFilter(null);
updateDecalFilter(DecalFilter.All);
updateSubjects([]);
};

return (
Expand Down Expand Up @@ -271,6 +287,22 @@ export default function Filters() {
</IconButton>
</div>
</div>
<div className={styles.formControl}>
<p className={styles.label}>Department</p>
<Select<string>
multi
searchable
value={subjects}
placeholder="Select departments"
disabled={subjectOptions.length === 0}
onChange={(v) => {
if (Array.isArray(v)) updateSubjects(v);
}}
options={subjectOptions}
searchPlaceholder="Search departments..."
emptyMessage="No departments found."
/>
</div>
<div className={styles.formControl}>
<p className={styles.label}>Class level</p>
<Select
Expand Down Expand Up @@ -408,6 +440,17 @@ export default function Filters() {
</div>
</div>
</div>
<div className={styles.formControl}>
<p className={styles.label}>DeCals</p>
<Select
value={decalFilter}
onChange={(value) => updateDecalFilter(value as DecalFilter)}
options={Object.values(DecalFilter).map((option) => ({
value: option,
label: option,
}))}
/>
</div>
</div>
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions apps/frontend/src/components/ClassBrowser/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,9 @@ export enum EnrollmentFilter {
OpenApartFromReserved = "Non-reserved Open Seats",
WaitlistOpen = "Open Seats or Open Waitlist",
}

export enum DecalFilter {
All = "All",
OnlyDecals = "Only DeCals",
Hide = "Hide",
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Semester } from "@/lib/generated/graphql";

import {
Day,
DecalFilter,
EnrollmentFilter,
GradingFilter,
Level,
Expand All @@ -30,6 +31,8 @@ export interface FilterContextType {
effectiveOrder: "asc" | "desc";
enrollmentFilter: EnrollmentFilter | null;
online: boolean;
decalFilter: DecalFilter;
subjects: string[];
filterOptions: ICatalogFilterOptions | null;
updateUnits: Dispatch<UnitRange>;
updateLevels: Dispatch<Level[]>;
Expand All @@ -41,6 +44,8 @@ export interface FilterContextType {
updateSortBy: Dispatch<SortBy>;
updateEnrollmentFilter: Dispatch<EnrollmentFilter | null>;
updateOnline: Dispatch<boolean>;
updateDecalFilter: Dispatch<DecalFilter>;
updateSubjects: Dispatch<string[]>;
updateReverse: Dispatch<SetStateAction<boolean>>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export default function useCatalogBrowser({
effectiveOrder: filterState.effectiveOrder,
enrollmentFilter: filterState.enrollmentFilter,
online: filterState.online,
decalFilter: filterState.decalFilter,
subjects: filterState.subjects,
filterOptions: queryResult.filterOptions,
updateUnits: filterState.updateUnits,
updateLevels: filterState.updateLevels,
Expand All @@ -69,6 +71,8 @@ export default function useCatalogBrowser({
updateSortBy: filterState.updateSortBy,
updateEnrollmentFilter: filterState.updateEnrollmentFilter,
updateOnline: filterState.updateOnline,
updateDecalFilter: filterState.updateDecalFilter,
updateSubjects: filterState.updateSubjects,
updateReverse: filterState.updateReverse,
}),
[year, semester, terms, filterState, queryResult.filterOptions]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { GetCatalogSearchQueryVariables } from "@/lib/generated/graphql";

import {
Day,
DecalFilter,
EnrollmentFilter,
GradingFilter,
Level,
Expand Down Expand Up @@ -117,6 +118,8 @@ export interface CatalogFilterState {
effectiveOrder: "asc" | "desc";
enrollmentFilter: EnrollmentFilter | null;
online: boolean;
decalFilter: DecalFilter;
subjects: string[];
hasActiveFilters: boolean;
filterVariables: ICatalogFilters | undefined;
}
Expand All @@ -133,6 +136,8 @@ export interface CatalogFilterUpdaters {
updateSortBy: Dispatch<SortBy>;
updateEnrollmentFilter: Dispatch<EnrollmentFilter | null>;
updateOnline: Dispatch<boolean>;
updateDecalFilter: Dispatch<DecalFilter>;
updateSubjects: Dispatch<string[]>;
updateReverse: Dispatch<SetStateAction<boolean>>;
}

Expand Down Expand Up @@ -162,6 +167,10 @@ export default function useCatalogFilters({
const [localEnrollmentFilter, setLocalEnrollmentFilter] =
useState<EnrollmentFilter | null>(null);
const [localOnline, setLocalOnline] = useState<boolean>(false);
const [localDecalFilter, setLocalDecalFilter] = useState<DecalFilter>(
DecalFilter.All
);
const [localSubjects, setLocalSubjects] = useState<string[]>([]);

// Derive state from search params when persistent
const query = localQuery;
Expand Down Expand Up @@ -281,6 +290,25 @@ export default function useCatalogFilters({
[searchParams, localOnline, persistent]
);

const decalFilter = useMemo(() => {
if (persistent) {
const param = searchParams.get("decalFilter");
if (param && Object.values(DecalFilter).includes(param as DecalFilter)) {
return param as DecalFilter;
}
return DecalFilter.All;
}
return localDecalFilter;
}, [searchParams, localDecalFilter, persistent]);

const subjects = useMemo(
() =>
persistent
? (searchParams.get("subjects")?.split(",").filter(Boolean) ?? [])
: localSubjects,
[searchParams, localSubjects, persistent]
);

// Build server-side filter variables
const filterVariables = useMemo<ICatalogFilters | undefined>(() => {
const filters: NonNullable<ICatalogFilters> = {};
Expand Down Expand Up @@ -310,6 +338,18 @@ export default function useCatalogFilters({

if (online) filters.online = true;

// DeCal filter
if (decalFilter === DecalFilter.OnlyDecals) {
filters.isDecal = true;
} else if (decalFilter === DecalFilter.Hide) {
filters.isDecal = false;
}

// Subject filter (shown as "Department" in UI)
if (subjects.length > 0) {
filters.subjects = subjects;
}

return Object.keys(filters).length > 0 ? filters : undefined;
}, [
levels,
Expand All @@ -321,6 +361,8 @@ export default function useCatalogFilters({
breadths,
universityRequirements,
online,
decalFilter,
subjects,
]);

const hasActiveFilters =
Expand All @@ -335,6 +377,8 @@ export default function useCatalogFilters({
gradingFilters.length > 0 ||
enrollmentFilter !== null ||
online ||
decalFilter !== DecalFilter.All ||
subjects.length > 0 ||
sortBy !== SortBy.Relevance;

// URL sync updater helpers
Expand Down Expand Up @@ -431,6 +475,8 @@ export default function useCatalogFilters({
effectiveOrder,
enrollmentFilter,
online,
decalFilter,
subjects,
hasActiveFilters,
filterVariables,
// Updaters
Expand Down Expand Up @@ -459,6 +505,16 @@ export default function useCatalogFilters({
setLocalEnrollmentFilter(filter);
},
updateOnline: (o) => updateBoolean("online", setLocalOnline, o),
updateDecalFilter: (filter) => {
if (persistent) {
if (filter === DecalFilter.All) searchParams.delete("decalFilter");
else searchParams.set("decalFilter", filter);
setSearchParams(searchParams);
return;
}
setLocalDecalFilter(filter);
},
updateSubjects: (s) => updateArray("subjects", setLocalSubjects, s),
updateReverse: setLocalReverse,
};
}
Loading
Loading