diff --git a/apps/backend/src/modules/catalog/controller.ts b/apps/backend/src/modules/catalog/controller.ts index 408c212ec..86ca6ee1a 100644 --- a/apps/backend/src/modules/catalog/controller.ts +++ b/apps/backend/src/modules/catalog/controller.ts @@ -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; @@ -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; @@ -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; @@ -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; }); }; @@ -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; @@ -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; }; @@ -465,6 +498,7 @@ export const getCatalogFilterOptions = async ( name: "$academicOrganizationName", }, }, + subjects: { $addToSet: "$subject" }, levels: { $addToSet: "$level" }, gradingOptions: { $addToSet: "$gradingBasis" }, breadthRequirements: { $addToSet: "$breadthRequirements" }, @@ -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, diff --git a/apps/backend/src/modules/catalog/typedefs/catalog.ts b/apps/backend/src/modules/catalog/typedefs/catalog.ts index 67cc1c3f1..39fb1a802 100644 --- a/apps/backend/src/modules/catalog/typedefs/catalog.ts +++ b/apps/backend/src/modules/catalog/typedefs/catalog.ts @@ -22,6 +22,7 @@ export const catalogTypeDef = gql` input CatalogFilters { levels: [String!] departments: [String!] + subjects: [String!] unitsMin: Float unitsMax: Float days: [Int!] @@ -32,6 +33,7 @@ export const catalogTypeDef = gql` breadths: [String!] universityRequirements: [String!] online: Boolean + isDecal: Boolean } type CatalogMeeting { @@ -166,6 +168,11 @@ export const catalogTypeDef = gql` name: String! } + type CatalogSubject { + code: String! + name: String + } + type CatalogTimeRange { minStartTime: String! maxEndTime: String! @@ -173,6 +180,7 @@ export const catalogTypeDef = gql` type CatalogFilterOptions { departments: [CatalogDepartment!]! + subjects: [CatalogSubject!]! levels: [String!]! gradingOptions: [String!]! breadthRequirements: [String!]! diff --git a/apps/frontend/src/components/ClassBrowser/Filters/index.tsx b/apps/frontend/src/components/ClassBrowser/Filters/index.tsx index c8164c80c..2609fd1ee 100644 --- a/apps/frontend/src/components/ClassBrowser/Filters/index.tsx +++ b/apps/frontend/src/components/ClassBrowser/Filters/index.tsx @@ -10,6 +10,7 @@ import { sortByTermDescending } from "@/lib/classes"; import { Day, + DecalFilter, EMPTY_DAYS, EnrollmentFilter, GradingFilter, @@ -45,6 +46,10 @@ export default function Filters() { updateGradingFilters, enrollmentFilter, updateEnrollmentFilter, + decalFilter, + updateDecalFilter, + subjects, + updateSubjects, sortBy, reverse, effectiveOrder, @@ -171,6 +176,15 @@ export default function Filters() { })); }, []); + const subjectOptions = useMemo[]>( + () => + (filterOptions?.subjects ?? []).map((subj) => ({ + value: subj.code, + label: subj.code, + })), + [filterOptions] + ); + const isClassLevelDisabled = Object.values(filteredLevels).every( (count) => count === 0 ); @@ -206,6 +220,8 @@ export default function Filters() { updateTimeRange([null, null]); updateSortBy(SortBy.Relevance); updateEnrollmentFilter(null); + updateDecalFilter(DecalFilter.All); + updateSubjects([]); }; return ( @@ -271,6 +287,22 @@ export default function Filters() { +
+

Department

+ + 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." + /> +

Class level

updateDecalFilter(value as DecalFilter)} + options={Object.values(DecalFilter).map((option) => ({ + value: option, + label: option, + }))} + /> +
); diff --git a/apps/frontend/src/components/ClassBrowser/browser.ts b/apps/frontend/src/components/ClassBrowser/browser.ts index 1f4084520..5719cb0b2 100644 --- a/apps/frontend/src/components/ClassBrowser/browser.ts +++ b/apps/frontend/src/components/ClassBrowser/browser.ts @@ -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", +} diff --git a/apps/frontend/src/components/ClassBrowser/context/FilterContext.ts b/apps/frontend/src/components/ClassBrowser/context/FilterContext.ts index 64cf5b152..2d9551a27 100644 --- a/apps/frontend/src/components/ClassBrowser/context/FilterContext.ts +++ b/apps/frontend/src/components/ClassBrowser/context/FilterContext.ts @@ -6,6 +6,7 @@ import { Semester } from "@/lib/generated/graphql"; import { Day, + DecalFilter, EnrollmentFilter, GradingFilter, Level, @@ -30,6 +31,8 @@ export interface FilterContextType { effectiveOrder: "asc" | "desc"; enrollmentFilter: EnrollmentFilter | null; online: boolean; + decalFilter: DecalFilter; + subjects: string[]; filterOptions: ICatalogFilterOptions | null; updateUnits: Dispatch; updateLevels: Dispatch; @@ -41,6 +44,8 @@ export interface FilterContextType { updateSortBy: Dispatch; updateEnrollmentFilter: Dispatch; updateOnline: Dispatch; + updateDecalFilter: Dispatch; + updateSubjects: Dispatch; updateReverse: Dispatch>; } diff --git a/apps/frontend/src/components/ClassBrowser/hooks/useCatalogBrowser.ts b/apps/frontend/src/components/ClassBrowser/hooks/useCatalogBrowser.ts index 58df95d1c..39c88c9f0 100644 --- a/apps/frontend/src/components/ClassBrowser/hooks/useCatalogBrowser.ts +++ b/apps/frontend/src/components/ClassBrowser/hooks/useCatalogBrowser.ts @@ -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, @@ -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] diff --git a/apps/frontend/src/components/ClassBrowser/hooks/useCatalogFilters.ts b/apps/frontend/src/components/ClassBrowser/hooks/useCatalogFilters.ts index 9b9afec97..7502524c8 100644 --- a/apps/frontend/src/components/ClassBrowser/hooks/useCatalogFilters.ts +++ b/apps/frontend/src/components/ClassBrowser/hooks/useCatalogFilters.ts @@ -14,6 +14,7 @@ import type { GetCatalogSearchQueryVariables } from "@/lib/generated/graphql"; import { Day, + DecalFilter, EnrollmentFilter, GradingFilter, Level, @@ -117,6 +118,8 @@ export interface CatalogFilterState { effectiveOrder: "asc" | "desc"; enrollmentFilter: EnrollmentFilter | null; online: boolean; + decalFilter: DecalFilter; + subjects: string[]; hasActiveFilters: boolean; filterVariables: ICatalogFilters | undefined; } @@ -133,6 +136,8 @@ export interface CatalogFilterUpdaters { updateSortBy: Dispatch; updateEnrollmentFilter: Dispatch; updateOnline: Dispatch; + updateDecalFilter: Dispatch; + updateSubjects: Dispatch; updateReverse: Dispatch>; } @@ -162,6 +167,10 @@ export default function useCatalogFilters({ const [localEnrollmentFilter, setLocalEnrollmentFilter] = useState(null); const [localOnline, setLocalOnline] = useState(false); + const [localDecalFilter, setLocalDecalFilter] = useState( + DecalFilter.All + ); + const [localSubjects, setLocalSubjects] = useState([]); // Derive state from search params when persistent const query = localQuery; @@ -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(() => { const filters: NonNullable = {}; @@ -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, @@ -321,6 +361,8 @@ export default function useCatalogFilters({ breadths, universityRequirements, online, + decalFilter, + subjects, ]); const hasActiveFilters = @@ -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 @@ -431,6 +475,8 @@ export default function useCatalogFilters({ effectiveOrder, enrollmentFilter, online, + decalFilter, + subjects, hasActiveFilters, filterVariables, // Updaters @@ -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, }; } diff --git a/apps/frontend/src/lib/api/catalog.ts b/apps/frontend/src/lib/api/catalog.ts index 93edc5f8e..3667218e4 100644 --- a/apps/frontend/src/lib/api/catalog.ts +++ b/apps/frontend/src/lib/api/catalog.ts @@ -78,6 +78,9 @@ export const GET_CATALOG_FILTER_OPTIONS = gql` gradingOptions breadthRequirements universityRequirements + subjects { + code + } timeRange { minStartTime maxEndTime diff --git a/packages/gql-typedefs/catalog.ts b/packages/gql-typedefs/catalog.ts index 8ecfacc9c..cb6a80f43 100644 --- a/packages/gql-typedefs/catalog.ts +++ b/packages/gql-typedefs/catalog.ts @@ -22,6 +22,7 @@ export const catalogTypeDef = gql` input CatalogFilters { levels: [String!] departments: [String!] + subjects: [String!] unitsMin: Float unitsMax: Float days: [Int!] @@ -32,6 +33,7 @@ export const catalogTypeDef = gql` breadths: [String!] universityRequirements: [String!] online: Boolean + isDecal: Boolean } type CatalogMeeting { @@ -173,6 +175,11 @@ export const catalogTypeDef = gql` name: String! } + type CatalogSubject { + code: String! + name: String + } + type CatalogTimeRange { minStartTime: String! maxEndTime: String! @@ -180,6 +187,7 @@ export const catalogTypeDef = gql` type CatalogFilterOptions { departments: [CatalogDepartment!]! + subjects: [CatalogSubject!]! levels: [String!]! gradingOptions: [String!]! breadthRequirements: [String!]!