diff --git a/my-app/firebase.js b/my-app/firebase.js
index 07c32454..131da9e6 100644
--- a/my-app/firebase.js
+++ b/my-app/firebase.js
@@ -1,9 +1,26 @@
import { initializeApp } from "firebase/app";
import { getAuth, GoogleAuthProvider, onAuthStateChanged } from "firebase/auth";
-import { getFunctions, httpsCallable } from 'firebase/functions';
-import { get, getDatabase, ref, set, onValue, onChildRemoved, onChildAdded, runTransaction } from "firebase/database";
+import { getFunctions, httpsCallable } from "firebase/functions";
+import {
+ get,
+ getDatabase,
+ ref,
+ set,
+ onValue,
+ onChildRemoved,
+ onChildAdded,
+ runTransaction,
+} from "firebase/database";
import { reaction, toJS } from "mobx";
+/**
+ * Firebase configuration and initialization.
+ * This code connects to Firebase, sets up authentication and allows to save and fetch courses as well as user data.
+ * Data Synchronization and caching are also handled.
+ * The firebase realtime database is used to store courses, user data, and reviews.
+ * If you would like to reuse this project, make sure to configure rules as shown in firebas_rules.json.
+ */
+
// Your web app's Firebase configuration
const firebaseConfig = {
apiKey: "AIzaSyCBckVI9nhAP62u5jZJW3F4SLulUv7znis",
@@ -25,8 +42,13 @@ export const googleProvider = new GoogleAuthProvider();
googleProvider.addScope("profile");
googleProvider.addScope("email");
-export function connectToFirebase(model) {
- loadCoursesFromCacheOrFirebase(model);
+/**
+ * Startup hook to connect the model to Firebase.
+ * This function sets up the Firebase connection, fetches initial data and starts listener for syncing data.
+ * @param {object} model The reactive model
+ */
+export async function connectToFirebase(model) {
+ await loadCoursesFromCacheOrFirebase(model);
fetchDepartmentsAndLocations(model);
startAverageRatingListener(model);
// setting missing
@@ -37,14 +59,16 @@ export function connectToFirebase(model) {
model.setFilterOptions(options);
}
+ // automaticaly save filter options to local storage whenever they change
reaction(
() => ({ filterOptions: JSON.stringify(model.filterOptions) }),
- // eslint-disable-next-line no-unused-vars
({ filterOptions }) => {
localStorage.setItem("filterOptions", filterOptions);
}
);
-
+ /**
+ * Hook to start synchronization when user is authenticated.
+ */
onAuthStateChanged(auth, (user) => {
if (user) {
model.setUser(user); // Set the user ID once authenticated
@@ -59,48 +83,59 @@ export function connectToFirebase(model) {
// fetches all relevant information to create the model
async function firebaseToModel(model) {
- const userRef = ref(db, `users/${model.user.uid}`);
- onValue(userRef, async (snapshot) => {
- if (!snapshot.exists()) return;
- const data = snapshot.val();
-
- // Use a transaction to ensure atomicity
- await runTransaction(userRef, (currentData) => {
- if (currentData) {
- if (data?.favourites) model.setFavourite(data.favourites);
- if (data?.currentSearchText) model.setCurrentSearchText(data.currentSearchText);
- // Add other fields as needed
- }
- return currentData; // Return the current data to avoid overwriting
- });
- });
+ const userRef = ref(db, `users/${model.user.uid}`);
+ onValue(userRef, async (snapshot) => {
+ if (!snapshot.exists()) return;
+ const data = snapshot.val();
+
+ // Use a transaction to ensure atomicity
+ await runTransaction(userRef, (currentData) => {
+ if (currentData) {
+ if (data?.favourites) model.setFavourite(data.favourites);
+ if (data?.currentSearchText)
+ model.setCurrentSearchText(data.currentSearchText);
+ // Add other fields as needed
+ }
+ return currentData; // Return the current data to avoid overwriting
+ });
+ });
}
+/**
+ * If the userid, favourites or search changes, sync to firebase.
+ * @param {object} model reactive model object
+ */
export function syncModelToFirebase(model) {
- reaction(
- () => ({
- userId: model?.user.uid,
- favourites: toJS(model.favourites),
- currentSearchText: toJS(model.currentSearchText),
- }),
- async ({ userId, favourites, currentSearchText }) => {
- if (!userId) return;
-
- const userRef = ref(db, `users/${userId}`);
- await runTransaction(userRef, (currentData) => {
- // Merge the new data with the existing data
- return {
- ...currentData,
- favourites,
- currentSearchText,
- };
- }).catch((error) => {
- console.error('Error syncing model to Firebase:', error);
- });
- }
- );
+ reaction(
+ () => ({
+ userId: model?.user.uid,
+ favourites: toJS(model.favourites),
+ currentSearchText: toJS(model.currentSearchText),
+ }),
+ async ({ userId, favourites, currentSearchText }) => {
+ if (!userId) return;
+
+ const userRef = ref(db, `users/${userId}`);
+ await runTransaction(userRef, (currentData) => {
+ // Merge the new data with the existing data
+ return {
+ ...currentData,
+ favourites,
+ currentSearchText,
+ };
+ }).catch((error) => {
+ console.error("Error syncing model to Firebase:", error);
+ });
+ }
+ );
}
+/**
+ * Synchronizes the scroll position of the container to Firebase / local storage.
+ * @param {object} model
+ * @param {*} containerRef
+ * @returns
+ */
export function syncScrollPositionToFirebase(model, containerRef) {
if (!containerRef?.current) return;
let lastSavedPosition = 0;
@@ -128,11 +163,18 @@ export function syncScrollPositionToFirebase(model, containerRef) {
containerRef.current?.removeEventListener("scroll", handleScroll);
}
-
-
+/**
+ * Caches the courses to IndexedDB.
+ * @param {Array} courses The course array to save
+ * @param {number} timestamp The last update of the course array
+ */
function saveCoursesToCache(courses, timestamp) {
const request = indexedDB.open("CourseDB", 1);
+ function validateCourseData(course) {
+ return course && typeof course === "object" && course.code;
+ }
+
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains("courses")) {
@@ -150,7 +192,9 @@ function saveCoursesToCache(courses, timestamp) {
const metaStore = tx.objectStore("metadata");
courseStore.clear();
- courses.forEach((course) => courseStore.put(course));
+ courses
+ .filter(validateCourseData)
+ .forEach((course) => courseStore.put(course));
metaStore.put({ key: "timestamp", value: timestamp });
tx.oncomplete = () => console.log("Saved courses to IndexedDB");
@@ -173,16 +217,26 @@ async function fetchLastUpdatedTimestamp() {
return snapshot.exists() ? snapshot.val() : 0;
}
+/**
+ * Admin function to add a course to the database.
+ * @param {Array} course
+ */
export async function addCourse(course) {
- if (!auth.currentUser)
- throw new Error('User must be authenticated');
- if (!course?.code)
- throw new Error('Invalid course data');
- const myRef = ref(db, `courses/${course.code}`);
- await set(myRef, course);
+ if (!auth.currentUser) throw new Error("User must be authenticated");
+ if (!course?.code) throw new Error("Invalid course data");
+ if (!course || typeof course !== "object")
+ throw new Error("Invalid course object");
+ if (!course.code || typeof course.code !== "string")
+ throw new Error("Invalid course code");
+ const myRef = ref(db, `courses/${course.code}`);
+ await set(myRef, course);
updateLastUpdatedTimestamp();
}
+/**
+ * Fetches all courses from the database.
+ * @returns {Array} Array of courses
+ */
export async function fetchAllCourses() {
const myRef = ref(db, `courses`);
const snapshot = await get(myRef);
@@ -197,6 +251,12 @@ export async function fetchAllCourses() {
return courses;
}
+/**
+ * Admin function to upload departments and locations to the database.
+ * @param {} departments
+ * @param {*} locations
+ * @returns
+ */
export async function uploadDepartmentsAndLocations(departments, locations) {
if (departments) {
const departmentsRef = ref(db, "departments");
@@ -219,6 +279,11 @@ export async function uploadDepartmentsAndLocations(departments, locations) {
return true;
}
+/**
+ * Fetches departments and locations from the database.
+ * @param {object} model
+ * @returns {Array} Array of departments and locations
+ */
export async function fetchDepartmentsAndLocations(model) {
const departmentsRef = ref(db, "departments");
const locationsRef = ref(db, "locations");
@@ -242,6 +307,12 @@ export async function fetchDepartmentsAndLocations(model) {
}
}
+/**
+ * Try to restore the courses from IndexedDB.
+ * @param {object} model
+ * @returns void
+ * @throws Error if IndexedDB is not available or no courses are found
+ */
async function loadCoursesFromCacheOrFirebase(model) {
const firebaseTimestamp = await fetchLastUpdatedTimestamp();
const dbPromise = new Promise((resolve, reject) => {
@@ -279,11 +350,15 @@ async function loadCoursesFromCacheOrFirebase(model) {
getAllReq.onsuccess = () => resolve(getAllReq.result);
getAllReq.onerror = () => resolve([]);
});
+ if (!cachedCourses) throw new Error("No courses found in IndexedDB");
model.setCourses(cachedCourses);
return;
}
} catch (err) {
- console.warn("IndexedDB unavailable, falling back Posting anonymously is possible. Firebase:", err);
+ console.warn(
+ "IndexedDB unavailable, falling back Posting anonymously is possible. Firebase:",
+ err
+ );
}
// fallback: fetch from Firebase
@@ -292,18 +367,43 @@ async function loadCoursesFromCacheOrFirebase(model) {
saveCoursesToCache(courses, firebaseTimestamp);
}
+/**
+ * Function to add a review for a course. The firebase rules disallow to have more than one course per person.
+ * Adding a review will automatically update the average rating of the course.
+ * @param {string} courseCode The course code
+ * @param {object} review The review object containing the review data
+ * @param {string} review.userName The name of the user
+ * @param {string} review.uid The user ID
+ * @param {number} review.timestamp The timestamp of the review
+ * @param {string} review.text The review text
+ * @param {number} review.overallRating The overall rating
+ * @param {number} review.difficultyRating The difficulty rating
+ * @param {string} review.professorName The name of the professor
+ * @param {string} review.grade The grade received
+ * @param {boolean} review.recommended Whether the course is recommended
+ * @throws {Error} If there is an error adding the review or updating the average rating
+ */
export async function addReviewForCourse(courseCode, review) {
try {
const reviewsRef = ref(db, `reviews/${courseCode}/${review.uid}`);
await set(reviewsRef, review);
const updateCourseAvgRating = httpsCallable(functions, 'updateCourseAvgRating');
- const result = await updateCourseAvgRating({ courseCode });
+ await updateCourseAvgRating({ courseCode });
} catch (error) {
- console.error("Error when adding a course to firebase or updating the average:", error);
+ console.error(
+ "Error when adding a course to firebase or updating the average:",
+ error
+ );
}
}
+/**
+ * Adding a course triggers a firebase function to update the average rating of the course.
+ * This function sets up a listener for the average rating of each course, to update the model onChange.
+ * It also fetches the initial average ratings if the model is not initialized.
+ * @param {object} model
+ */
function startAverageRatingListener(model) {
const coursesRef = ref(db, "reviews");
@@ -323,7 +423,7 @@ function startAverageRatingListener(model) {
}
});
model.setAverageRatings(initialRatings);
- })
+ });
}
// Step 2: listener for each courses avgRating
@@ -348,17 +448,22 @@ function startAverageRatingListener(model) {
});
}
+/**
+ * Fetches reviews for a specific course.
+ * @param {string} courseCode
+ * @returns
+ */
export async function getReviewsForCourse(courseCode) {
const reviewsRef = ref(db, `reviews/${courseCode}`);
const snapshot = await get(reviewsRef);
if (!snapshot.exists()) return [];
const reviews = [];
snapshot.forEach((childSnapshot) => {
- if(childSnapshot.key!="avgRating")
- reviews.push({
- id: childSnapshot.key,
- ...childSnapshot.val(),
- });
+ if (childSnapshot.key != "avgRating")
+ reviews.push({
+ id: childSnapshot.key,
+ ...childSnapshot.val(),
+ });
});
return reviews;
}
diff --git a/my-app/src/dev/README.md b/my-app/src/dev/README.md
deleted file mode 100644
index 1f5e5e75..00000000
--- a/my-app/src/dev/README.md
+++ /dev/null
@@ -1,70 +0,0 @@
-This directory contains utility files which enable some visual features of the
-[React Buddy](https://plugins.jetbrains.com/plugin/17467-react-buddy/) plugin.
-Files in the directory should be committed to source control.
-
-React Buddy palettes describe reusable components and building blocks. `React Palette` tool window becomes available
-when an editor with React components is active. You can drag and drop items from the tool window to the code editor or
-JSX Outline. Alternatively, you can insert components from the palette using code generation action (`alt+insert` /
-`⌘ N`).
-
-Add components to the palette using `Add to React Palette` intention or via palette editor (look for the corresponding
-link in `palette.tsx`). There are some ready-to-use palettes for popular React libraries which are published as npm
-packages and can be added as a dependency:
-
-```jsx
-import AntdPalette from "@react-buddy/palette-antd";
-import ReactIntlPalette from "@react-buddy/palette-react-intl";
-
-export const PaletteTree = () => (
-
-
-
-
-
-
-
-
- Card content
-
-
-
-
-
-
-
-
-
-)
-```
-
-React Buddy explicitly registers any previewed component in the `previews.tsx` file so that you can specify required
-props.
-
-```jsx
-
-
-
-```
-
-You can add some global initialization logic for the preview mode in `useInitital.ts`,
-e.g. implicitly obtain user session:
-
-```typescript
-export const useInitial: () => InitialHookStatus = () => {
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(false);
-
- useEffect(() => {
- setLoading(true);
- async function login() {
- const response = await loginRequest(DEV_LOGIN, DEV_PASSWORD);
- if (response?.status !== 200) {
- setError(true);
- }
- setLoading(false);
- }
- login();
- }, []);
- return { loading, error };
-};
-```
\ No newline at end of file
diff --git a/my-app/src/dev/index.js b/my-app/src/dev/index.js
deleted file mode 100644
index d703b95f..00000000
--- a/my-app/src/dev/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import React from "react"
-import {useInitial} from "./useInitial"
-
-const ComponentPreviews = React.lazy(() => import("./previews"))
-
-export {
- ComponentPreviews,
- useInitial
-}
\ No newline at end of file
diff --git a/my-app/src/dev/palette.jsx b/my-app/src/dev/palette.jsx
deleted file mode 100644
index 42d9515a..00000000
--- a/my-app/src/dev/palette.jsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import {Fragment} from "react"
-import {
- Category,
- Component,
- Variant,
- Palette,
-} from "@react-buddy/ide-toolbox"
-
-export const PaletteTree = () => (
-
-
-
-
-
-
-
-
-
-)
-
-export function ExampleLoaderComponent() {
- return (
- Loading...
- )
-}
\ No newline at end of file
diff --git a/my-app/src/dev/previews.jsx b/my-app/src/dev/previews.jsx
deleted file mode 100644
index d1b4a458..00000000
--- a/my-app/src/dev/previews.jsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import {Previews} from '@react-buddy/ide-toolbox'
-import {PaletteTree} from './palette'
-
-const ComponentPreviews = () => {
- return (
- }>
-
- )
-}
-
-export default ComponentPreviews
\ No newline at end of file
diff --git a/my-app/src/dev/useInitial.js b/my-app/src/dev/useInitial.js
deleted file mode 100644
index 203f394a..00000000
--- a/my-app/src/dev/useInitial.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import {useState} from 'react'
-
-export const useInitial = () => {
- const [status, setStatus] = useState({
- loading: false,
- error: false
- })
- /*
- Implement hook functionality here.
- If you need to execute async operation, set loading to true and when it's over, set loading to false.
- If you caught some errors, set error status to true.
- Initial hook is considered to be successfully completed if it will return {loading: false, error: false}.
- */
- return status
-}
diff --git a/my-app/src/index.jsx b/my-app/src/index.jsx
index e1a368fa..08c05e01 100644
--- a/my-app/src/index.jsx
+++ b/my-app/src/index.jsx
@@ -11,7 +11,7 @@ import { JsonToDatabase } from "./presenters/Tests/JsonToDatabase";
import { AllCoursesPresenter } from "./presenters/Tests/AllCoursesPresenter.jsx";
-configure({ enforceActions: "never" });
+configure({ enforceActions: "observed", reactionScheduler: (f) => setTimeout(f, 0),});
const reactiveModel = makeAutoObservable(model);
connectToFirebase(reactiveModel);
diff --git a/my-app/src/model.js b/my-app/src/model.js
index 3c67197a..ad4fbf00 100644
--- a/my-app/src/model.js
+++ b/my-app/src/model.js
@@ -1,299 +1,300 @@
-import { query } from "firebase/database";
-import { addCourse, addReviewForCourse, getReviewsForCourse, uploadDepartmentsAndLocations } from "../firebase";
-
-
+import {
+ addCourse,
+ addReviewForCourse,
+ getReviewsForCourse,
+ uploadDepartmentsAndLocations,
+} from "../firebase";
+
+/**
+ * The model contains the data and "business logic" of the application.
+ * It manages the state of the application and contains getter / setter to manipulate the state.
+ * "firebase.js" is used to fetch data from the Firebase database, which are then stored in the model.
+ * In the application the model is used as a singleton, so that all components can access the same data.
+ * Initializing the model happens on top of the application tree in the "index.jsx".
+ */
export const model = {
- user: undefined,
- //add searchChange: false, //this is for reworking the searchbar presenter, so that it triggers as a model,
- //instead of passing searchcouses lambda function down into the searchbarview.
- /* courses returned from SearchbarPresenter (search is applied on top of filteredCourses[]) to be shown in the ListView */
- currentSearch: [],
-
- sidebarIsOpen: true,
- /* current query text */
- currentSearchText: "",
- scrollPosition: 0,
- /* list of all course objects downloaded from the Firebase realtime database and stored locally as JSON object in this array */
- courses: [],
- departments: [],
- locations: [],
- // indexes: 0 -> overall rating; 1 -> difficulty; 2->teacher rating
- avgRatings: [],
- // model.avgRatings["IK1203"][0]
- /* courses the user selected as their favourite */
- favourites: [],
- searchHistory: [],
- isReady: false,
- /* this is a boolean flag showing that filtering options in the UI have changed, triggering the FilterPresenter to recalculate the filteredCourses[] */
- filtersChange: false,
- /* this is a flag showing if the filteredCourses[] has changed (since FilterPresenter recalculated it), so now SearchBarPresenter needs to
+ user: undefined,
+ //add searchChange: false, //this is for reworking the searchbar presenter, so that it triggers as a model,
+ //instead of passing searchcouses lambda function down into the searchbarview.
+ /* courses returned from SearchbarPresenter (search is applied on top of filteredCourses[]) to be shown in the ListView */
+ currentSearch: [],
+
+ sidebarIsOpen: true,
+ /* current query text */
+ currentSearchText: "",
+ scrollPosition: 0,
+ /* list of all course objects downloaded from the Firebase realtime database and stored locally as JSON object in this array */
+ courses: [],
+ departments: [],
+ locations: [],
+ // indexes: 0 -> overall rating; 1 -> difficulty; 2->teacher rating
+ avgRatings: [],
+ // model.avgRatings["IK1203"][0]
+ /* courses the user selected as their favourite */
+ favourites: [],
+ searchHistory: [],
+ isReady: false,
+ /* this is a boolean flag showing that filtering options in the UI have changed, triggering the FilterPresenter to recalculate the filteredCourses[] */
+ filtersChange: false,
+ /* this is a flag showing if the filteredCourses[] has changed (since FilterPresenter recalculated it), so now SearchBarPresenter needs to
recalculate currentSearch[] depending this updated list of courses */
- filtersCalculated: false,
- /* this is the array that FilterPresenter fills up with course objects, filtered from the model.courses[] */
- filteredCourses: [],
- /* JSON object containing all important parameters the FilterPresenter needs to calculate the filtered list of courses */
- filterOptions: {
- //apply-X-Filter boolean triggering flag wether corresponding filtering functions should run or not
- //different arrays require different data, some uses string arrays, some boolean values, and so on
- applyTranscriptFilter: false,
- eligibility: "weak", //the possible values for the string are: "weak"/"moderate"/"strong"
- applyLevelFilter: true,
- level: ["PREPARATORY", "BASIC", "ADVANCED", "RESEARCH"], //the possible values for the array are: "PREPARATORY", "BASIC", "ADVANCED", "RESEARCH"
- applyLanguageFilter: false,
- language: "none", //the possible values for the string are: "none"/"english"/"swedish"/"both"
- applyLocationFilter: false,
- location: [], //the possible values for the array are: 'KTH Campus', 'KTH Kista', 'AlbaNova', 'KTH Flemingsberg', 'KTH Solna', 'KTH Södertälje', 'Handelshögskolan', 'KI Solna', 'Stockholms universitet', 'KONSTFACK'
- applyCreditsFilter: true,
- creditMin: 0,
- creditMax: 45,
- applyDepartmentFilter: false,
- department: [],
- applyRemoveNullCourses: false,
- period: [true, true, true, true],
- applyPeriodFilter: true
- },
- isPopupOpen: false,
- selectedCourse: null,
- searchQueryModel: "",
-
- _coursesListeners: [], // internal list of listeners
-
- onCoursesSet(callback) {
- this._coursesListeners.push(callback);
- },
-
- _coursesListeners: [], // internal list of listeners
- urlStackPointer: 0,
-
- onCoursesSet(callback) {
- this._coursesListeners.push(callback);
- },
-
- setUser(user) {
- if (!this.user)
- this.user = user;
- },
-
- setCurrentSearch(searchResults) {
- this.currentSearch = searchResults;
- },
-
- setCurrentSearchText(text) {
- this.currentSearchText = text;
- },
-
- setScrollPosition(position) {
- this.scrollPosition = position;
- },
-
- setCourses(courses) {
- this.courses = courses;
- this._coursesListeners.forEach(cb => cb(courses));
- },
-
- async addCourse(course) {
- try {
- await addCourse(course);
- this.courses = [...this.courses, course];
- } catch (error) {
- console.error("Error adding course:", error);
- }
- },
- addHistoryItem(course_id) {
- try {
- this.searchHistory = [...this.searchHistory, course_id];
- } catch (error) {
- console.error("Error adding course code to the history:", error);
- }
- },
- setDepartments(departments) {
- this.departments = departments;
- },
- setLocations(locations) {
- this.locations = locations;
- },
- setAverageRatings(ratings) {
- this.avgRatings = ratings;
- },
- updateAverageRating(courseCode, rating) {
- if (this.avgRatings != null)
- this.avgRatings[courseCode] = rating;
- },
- setFavourite(favorites) {
- this.favourites = favorites;
- },
-
- addFavourite(course) {
- this.favourites = [...this.favourites, course];
- },
-
- removeFavourite(course) {
- this.favourites = (this.favourites || []).filter(fav => fav.code !== course.code);
- },
-
- getCourse(courseID) {
- return this.courses.find(course => course.code === courseID);
- },
-
- getCourseNames(courseID_array) {
- let return_obj = {};
- for (let course of this.courses) {
- if (courseID_array.includes(course.code)) {
- return_obj[course.code] = course.name;
- }
- }
- return return_obj;
- },
-
- populateDatabase(data) {
- if (!data || !this) {
- console.log("no model or data");
- return;
- }
- const dep = new Set();
- const loc = new Set();
- const entries = Object.entries(data);
- entries.forEach(entry => {
- const course = {
- code: entry[1].code,
- name: entry[1]?.name ?? null,
- location: entry[1]?.location ?? null,
- department: entry[1]?.department ?? null,
- language: entry[1]?.language ?? null,
- description: entry[1]?.description ?? null,
- academicLevel: entry[1]?.academic_level ?? null,
- periods: entry[1]?.periods ?? null,
- credits: entry[1]?.credits ?? 0,
- prerequisites: entry[1]?.prerequisites ?? null,
- prerequisites_text: entry[1]?.prerequisites_text ?? null,
- learning_outcomes: entry[1]?.learning_outcomes ?? null
- };
- this.addCourse(course);
- dep.add(course.department);
- loc.add(course.location);
- });
- this.departments = Array.from(dep);
- this.locations = Array.from(loc);
- uploadDepartmentsAndLocations(this.departments, this.locations);
-
- },
- //for reviews
- async addReview(courseCode, review) {
- try {
- await addReviewForCourse(courseCode, review);
- return true;
- } catch (error) {
- console.error("Error adding review:", error);
- return false;
- }
- },
-
- async getReviews(courseCode) {
- try {
- return await getReviewsForCourse(courseCode);
- } catch (error) {
- console.error("Error fetching reviews:", error);
- return [];
- }
- },
- //for filters
-
- setFiltersChange() {
- this.filtersChange = true;
- },
-
- setFiltersCalculated() {
- this.filtersCalculated = true;
- },
-
- setFilterOptions(options) {
- this.filterOptions = options; // do we want to set the flags? What about useEffect?
- },
-
- setApplyRemoveNullCourses() {
- this.filterOptions.applyRemoveNullCourses = !this.filterOptions.applyRemoveNullCourses;
- this.setFiltersChange();
- },
-
- setApplyRemoveNullCourses() {
- this.filterOptions.applyRemoveNullCourses = !this.filterOptions.applyRemoveNullCourses;
- this.setFiltersChange();
- },
-
- updateLevelFilter(level) {
- this.filterOptions.level = level;
- },
-
- updateDepartmentFilter(department) {
- this.filterOptions.department = department;
- },
-
- updateLanguageFilter(languages) {
- this.filterOptions.language = languages;
- },
- updateLocationFilter(location) {
- this.filterOptions.location = location;
- },
- updateCreditsFilter(creditLimits) {
- this.filterOptions.creditMin = creditLimits[0];
- this.filterOptions.creditMax = creditLimits[1];
- },
- updateTranscriptElegibilityFilter(eligibility) {
- this.filterOptions.eligibility = eligibility;
- },
-
- updateDepartmentFilter(department) {
- this.filterOptions.department = department;
- },
-
- updatePeriodFilter(period) {
- this.filterOptions.period = period;
- },
-
- setApplyTranscriptFilter(transcriptFilterState) {
- this.filterOptions.applyTranscriptFilter = transcriptFilterState;
- },
- setApplyLevelFilter(levelFilterState) {
- this.filterOptions.applyLevelFilter = levelFilterState;
- },
- setApplyLanguageFilter(languageFilterState) {
- this.filterOptions.applyLanguageFilter = languageFilterState;
- },
- setApplyLocationFilter(locationFilterState) {
- this.filterOptions.applyLocationFilter = locationFilterState;
- },
- setApplyCreditsFilter(creditsFilterState) {
- this.filterOptions.applyCreditsFilter = creditsFilterState;
- },
- setApplyDepartmentFilter(departmentFilterState) {
- this.filterOptions.applyDepartmentFilter = departmentFilterState;
- },
- setApplyPeriodFilter(periodfilterState) {
- this.filterOptions.applyPeriodFilter = periodfilterState;
- },
- //for better display we would like the departments in a structured format based on school
- formatDepartments() {
- const grouped = this?.departments.reduce((acc, item) => {
- const [school, department] = item.split("/");
- if (!acc[school]) {
- acc[school] = [];
- }
- acc[school].push(department?.trim());
- return acc;
- }, {});
- const sortedGrouped = Object.keys(grouped)
- .sort()
- .reduce((acc, key) => {
- acc[key] = grouped[key].sort();
- return acc;
- }, {});
- const fields = Object.entries(sortedGrouped).map(([school, departments], index) => ({
- id: index + 1,
- label: school,
- subItems: departments,
- }));
- return fields;
- },
+ filtersCalculated: false,
+ /* this is the array that FilterPresenter fills up with course objects, filtered from the model.courses[] */
+ filteredCourses: [],
+ /* JSON object containing all important parameters the FilterPresenter needs to calculate the filtered list of courses */
+ filterOptions: {
+ //apply-X-Filter boolean triggering flag wether corresponding filtering functions should run or not
+ //different arrays require different data, some uses string arrays, some boolean values, and so on
+ applyTranscriptFilter: false,
+ eligibility: "weak", //the possible values for the string are: "weak"/"moderate"/"strong"
+ applyLevelFilter: true,
+ level: ["PREPARATORY", "BASIC", "ADVANCED", "RESEARCH"], //the possible values for the array are: "PREPARATORY", "BASIC", "ADVANCED", "RESEARCH"
+ applyLanguageFilter: false,
+ language: "none", //the possible values for the string are: "none"/"english"/"swedish"/"both"
+ applyLocationFilter: false,
+ location: [], //the possible values for the array are: 'KTH Campus', 'KTH Kista', 'AlbaNova', 'KTH Flemingsberg', 'KTH Solna', 'KTH Södertälje', 'Handelshögskolan', 'KI Solna', 'Stockholms universitet', 'KONSTFACK'
+ applyCreditsFilter: true,
+ creditMin: 0,
+ creditMax: 45,
+ applyDepartmentFilter: false,
+ department: [],
+ applyRemoveNullCourses: false,
+ period: [true, true, true, true],
+ applyPeriodFilter: true,
+ },
+ isPopupOpen: false,
+ selectedCourse: null,
+ searchQueryModel: "",
+
+ _coursesListeners: [], // internal list of listeners
+
+ onCoursesSet(callback) {
+ this._coursesListeners.push(callback);
+ },
+
+ urlStackPointer: 0,
+
+ setUser(user) {
+ if (!this.user) this.user = user;
+ },
+
+ setCurrentSearch(searchResults) {
+ this.currentSearch = searchResults;
+ },
+
+ setCurrentSearchText(text) {
+ this.currentSearchText = text;
+ },
+
+ setScrollPosition(position) {
+ this.scrollPosition = position;
+ },
+
+ setCourses(courses) {
+ this.courses = courses;
+ this._coursesListeners.forEach((cb) => cb(courses));
+ },
+
+ async addCourse(course) {
+ try {
+ await addCourse(course);
+ this.courses = [...this.courses, course];
+ } catch (error) {
+ console.error("Error adding course:", error);
+ }
+ },
+ addHistoryItem(course_id) {
+ try {
+ this.searchHistory = [...this.searchHistory, course_id];
+ } catch (error) {
+ console.error("Error adding course code to the history:", error);
+ }
+ },
+ setDepartments(departments) {
+ this.departments = departments;
+ },
+ setLocations(locations) {
+ this.locations = locations;
+ },
+ setAverageRatings(ratings) {
+ this.avgRatings = ratings;
+ },
+ updateAverageRating(courseCode, rating) {
+ if (this.avgRatings != null) this.avgRatings[courseCode] = rating;
+ },
+ setFavourite(favorites) {
+ this.favourites = favorites;
+ },
+
+ addFavourite(course) {
+ this.favourites = [...this.favourites, course];
+ },
+
+ removeFavourite(course) {
+ this.favourites = (this.favourites || []).filter(
+ (fav) => fav.code !== course.code
+ );
+ },
+
+ getCourse(courseID) {
+ return this.courses.find((course) => course.code === courseID);
+ },
+
+ getCourseNames(courseID_array) {
+ let return_obj = {};
+ for (let course of this.courses) {
+ if (courseID_array.includes(course.code)) {
+ return_obj[course.code] = course.name;
+ }
+ }
+ return return_obj;
+ },
+
+ /**
+ * An admin function to populate the database with courses,
+ * Currently used in the Tests/JsonToDatabase Test file to trigger.
+ * @param {data} data to populate the database with. Can be read from a JSON
+ * @returns void
+ */
+ populateDatabase(data) {
+ if (!data || !this) {
+ console.log("no model or data");
+ return;
+ }
+ // use sets to avoid duplicates
+ const dep = new Set();
+ const loc = new Set();
+ const entries = Object.entries(data);
+ entries.forEach((entry) => {
+ const course = {
+ code: entry[1].code,
+ name: entry[1]?.name ?? null,
+ location: entry[1]?.location ?? null,
+ department: entry[1]?.department ?? null,
+ language: entry[1]?.language ?? null,
+ description: entry[1]?.description ?? null,
+ academicLevel: entry[1]?.academic_level ?? null,
+ periods: entry[1]?.periods ?? null,
+ credits: entry[1]?.credits ?? 0,
+ prerequisites: entry[1]?.prerequisites ?? null,
+ prerequisites_text: entry[1]?.prerequisites_text ?? null,
+ learning_outcomes: entry[1]?.learning_outcomes ?? null,
+ };
+ this.addCourse(course);
+ dep.add(course.department);
+ loc.add(course.location);
+ });
+ this.departments = Array.from(dep);
+ this.locations = Array.from(loc);
+ uploadDepartmentsAndLocations(this.departments, this.locations);
+ },
+ //for reviews
+ async addReview(courseCode, review) {
+ try {
+ await addReviewForCourse(courseCode, review);
+ return true;
+ } catch (error) {
+ console.error("Error adding review:", error);
+ return false;
+ }
+ },
+
+ async getReviews(courseCode) {
+ try {
+ return await getReviewsForCourse(courseCode);
+ } catch (error) {
+ console.error("Error fetching reviews:", error);
+ return [];
+ }
+ },
+ //for filters
+
+ setFiltersChange() {
+ this.filtersChange = true;
+ },
+
+ setFiltersCalculated() {
+ this.filtersCalculated = true;
+ },
+
+ setFilterOptions(options) {
+ this.filterOptions = options; // do we want to set the flags? What about useEffect?
+ },
+
+ setApplyRemoveNullCourses() {
+ this.filterOptions.applyRemoveNullCourses =
+ !this.filterOptions.applyRemoveNullCourses;
+ this.setFiltersChange();
+ },
+
+ updateLevelFilter(level) {
+ this.filterOptions.level = level;
+ console.log(level);
+ },
+ updateLanguageFilter(languages) {
+ this.filterOptions.language = languages;
+ },
+ updateLocationFilter(location) {
+ this.filterOptions.location = location;
+ },
+ updateCreditsFilter(creditLimits) {
+ this.filterOptions.creditMin = creditLimits[0];
+ this.filterOptions.creditMax = creditLimits[1];
+ },
+ updateTranscriptElegibilityFilter(eligibility) {
+ this.filterOptions.eligibility = eligibility;
+ },
+
+ updatePeriodFilter(period) {
+ this.filterOptions.period = period;
+ },
+
+ setApplyTranscriptFilter(transcriptFilterState) {
+ this.filterOptions.applyTranscriptFilter = transcriptFilterState;
+ },
+ setApplyLevelFilter(levelFilterState) {
+ this.filterOptions.applyLevelFilter = levelFilterState;
+ },
+ setApplyLanguageFilter(languageFilterState) {
+ this.filterOptions.applyLanguageFilter = languageFilterState;
+ },
+ setApplyLocationFilter(locationFilterState) {
+ this.filterOptions.applyLocationFilter = locationFilterState;
+ },
+ setApplyCreditsFilter(creditsFilterState) {
+ this.filterOptions.applyCreditsFilter = creditsFilterState;
+ },
+ setApplyDepartmentFilter(departmentFilterState) {
+ this.filterOptions.applyDepartmentFilter = departmentFilterState;
+ },
+ setApplyPeriodFilter(periodfilterState) {
+ this.filterOptions.applyPeriodFilter = periodfilterState;
+ },
+ //for better display we would like the departments in a structured format based on school
+ formatDepartments() {
+ const grouped = this.departments?.reduce((acc, item) => {
+ const [school, department] = item.split("/");
+ if (!acc[school]) {
+ acc[school] = [];
+ }
+ acc[school].push(department?.trim());
+ return acc;
+ }, {});
+ const sortedGrouped = Object.keys(grouped)
+ .sort()
+ .reduce((acc, key) => {
+ acc[key] = grouped[key].sort();
+ return acc;
+ }, {});
+ const fields = Object.entries(sortedGrouped).map(
+ ([school, departments], index) => ({
+ id: index + 1,
+ label: school,
+ subItems: departments,
+ })
+ );
+ return fields;
+ },
async getAverageRating(courseCode, option) {
const reviews = await getReviewsForCourse(courseCode);
if (!reviews || reviews.length === 0) return null;
@@ -334,58 +335,54 @@ export const model = {
return (total / validReviews).toFixed(1);
},
- setPopupOpen(isOpen) {
- if (!isOpen) {
- let current_url = window.location.href;
- let end_index = indexOfNth(current_url, '/', 3);
- if (end_index + 1 != current_url.length) {
- window.history.back();
- }
- }
-
- this.isPopupOpen = isOpen;
- },
-
- setSelectedCourse(course) {
- this.selectedCourse = course;
- },
-
-
- toggleSidebarIsOpen() {
- this.sidebarIsOpen = !this.sidebarIsOpen;
- },
-
- handleUrlChange() {
- let current_url = window.location.href;
-
- let start_idx = indexOfNth(current_url, '/', 3) + 2;
-
- if (start_idx > 0 && start_idx < current_url.length) {
-
- let course_code = current_url.slice(start_idx);
- let course = this.getCourse(course_code);
- if (course) {
- this.setSelectedCourse(course);
- this.setPopupOpen(true);
- }
- this.urlStackPointer++;
- } else if (start_idx > 0) {
- this.setPopupOpen(false);
- }
- }
-
+ setPopupOpen(isOpen) {
+ if (!isOpen) {
+ let current_url = window.location.href;
+ let end_index = indexOfNth(current_url, "/", 3);
+ if (end_index + 1 != current_url.length) {
+ window.history.back();
+ }
+ }
+
+ this.isPopupOpen = isOpen;
+ },
+
+ setSelectedCourse(course) {
+ this.selectedCourse = course;
+ },
+
+ toggleSidebarIsOpen() {
+ this.sidebarIsOpen = !this.sidebarIsOpen;
+ },
+
+ handleUrlChange() {
+ let current_url = window.location.href;
+
+ let start_idx = indexOfNth(current_url, "/", 3) + 2;
+
+ if (start_idx > 0 && start_idx < current_url.length) {
+ let course_code = current_url.slice(start_idx);
+ let course = this.getCourse(course_code);
+ if (course) {
+ this.setSelectedCourse(course);
+ this.setPopupOpen(true);
+ }
+ this.urlStackPointer++;
+ } else if (start_idx > 0) {
+ this.setPopupOpen(false);
+ }
+ },
};
-
function indexOfNth(string, char, n) {
- let count = 0;
- for (let i = 0; i < string.length; i++) {
- if (string[i] == char) {
- count++;
- }
- if (count == n) {
- return i;
- }
- }
- return -1;
-}
\ No newline at end of file
+ let count = 0;
+ for (let i = 0; i < string.length; i++) {
+ if (string[i] == char) {
+ count++;
+ }
+ if (count == n) {
+ return i;
+ }
+ }
+ return -1;
+}
diff --git a/my-app/src/pages/App.jsx b/my-app/src/pages/App.jsx
index 3cb7a835..0e3d619a 100644
--- a/my-app/src/pages/App.jsx
+++ b/my-app/src/pages/App.jsx
@@ -4,15 +4,11 @@ import { SearchbarPresenter } from '../presenters/SearchbarPresenter.jsx';
import { ListViewPresenter } from '../presenters/ListViewPresenter.jsx';
import { FilterPresenter } from "../presenters/FilterPresenter.jsx";
import { slide as Menu } from 'react-burger-menu';
-import { observer } from 'mobx-react-lite';
function App({ model }) {
const [sidebarIsOpen, setSidebarIsOpen] = useState(model.sidebarIsOpen);
- const toggleSidebar = () => {
- setSidebarIsOpen(!sidebarIsOpen);
- }
return (
/* The sidebar styling(under the menu)*/
diff --git a/my-app/src/presenters/FilterPresenter.jsx b/my-app/src/presenters/FilterPresenter.jsx
index 40aba878..45970ace 100644
--- a/my-app/src/presenters/FilterPresenter.jsx
+++ b/my-app/src/presenters/FilterPresenter.jsx
@@ -1,12 +1,56 @@
-import React from 'react';
import { observer } from "mobx-react-lite";
+import { useMemo, useEffect } from "react";
import eligibility from "../scripts/eligibility_refined.js";
-import { SearchbarPresenter } from './SearchbarPresenter.jsx';
/* FilterPresenter is responsible for applying the logic necessary to filter out the courses from the overall list */
const FilterPresenter = observer(({ model }) => {
+
/* global variable for the scope of this presenter, all the smaller functions depend on it instead of passing it back and forth as params */
- var localFilteredCourses = [];
+ var localFilteredCourses = [...model?.courses];
+
+ const filteredCourses = useMemo(() => {
+ if (model.courses.length === 0 || !model.filtersChange) {
+ return model.filteredCourses;
+ }
+
+ if (model.filterOptions.applyRemoveNullCourses) {
+ updateNoNullcourses();
+ }
+ if(model.filterOptions.applyPeriodFilter){
+ updatePeriods();
+ }
+ if (model.filterOptions.applyLocationFilter) {
+ updateLocations();
+ }
+ if (model.filterOptions.applyLevelFilter) {
+ updateLevels();
+ }
+ if (model.filterOptions.applyLanguageFilter) {
+ updateLanguages();
+ }
+ if (model.filterOptions.applyCreditsFilter) {
+ updateCredits();
+ }
+ if (model.filterOptions.applyTranscriptFilter) {
+ applyTranscriptEligibility();
+ }
+ if (model.filterOptions.applyDepartmentFilter) {
+ updateDepartments();
+ }
+ model.filtersChange = false;
+ model.setFiltersCalculated();
+ return localFilteredCourses;
+
+ }, [model.courses,
+ model.filtersChange,
+ model.filterOptions
+ ]);
+
+ useEffect(() => {
+ model.filteredCourses = filteredCourses;
+ }, [filteredCourses]);
+
+
/* functions declared here are generally things the main function of this observer takes and runs if the given filters are enabled,
* this is determined through model.filterOptions.apply*Insert filter name* flags.
@@ -370,49 +414,12 @@ const FilterPresenter = observer(({ model }) => {
}
/* function that should run every single time the model changes (see note below) */
- async function run() {
- if (model.courses.length == 0) {
- return;
- }
- if (model.filtersChange) {
- localFilteredCourses = [...model.courses];
-
- if (model.filterOptions.applyRemoveNullCourses) {
- updateNoNullcourses();
- }
- if(model.filterOptions.applyPeriodFilter){
- updatePeriods();
- }
- if (model.filterOptions.applyLocationFilter) {
- updateLocations();
- }
- if (model.filterOptions.applyLevelFilter) {
- updateLevels();
- }
- if (model.filterOptions.applyLanguageFilter) {
- updateLanguages();
- }
- if (model.filterOptions.applyCreditsFilter) {
- updateCredits();
- }
- if (model.filterOptions.applyTranscriptFilter) {
- applyTranscriptEligibility();
- }
- if (model.filterOptions.applyDepartmentFilter) {
- updateDepartments();
- }
-
- model.filteredCourses = [...localFilteredCourses];
- model.filtersChange = false;
- model.setFiltersCalculated();
- }
- }
/* the problem is that unless using sideeffects, the run() not being async and/or it setting the filterschange = false very early can mean
* that 0 courses will get put into the model.filtered courses (which is the list of courses getting passed to search, and then listview)
* therefore TODO: rework it to stop using this dumb flags we started before learning anything about react,observers,js
*/
- run();
+ // run();
});
diff --git a/my-app/src/presenters/ListViewPresenter.jsx b/my-app/src/presenters/ListViewPresenter.jsx
index ccedd24b..4d46d0d6 100644
--- a/my-app/src/presenters/ListViewPresenter.jsx
+++ b/my-app/src/presenters/ListViewPresenter.jsx
@@ -1,12 +1,12 @@
import React from 'react';
import { observer } from "mobx-react-lite";
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useRef, useMemo } from 'react';
import ListView from "../views/ListView.jsx";
import CoursePagePopup from '../views/Components/CoursePagePopup.jsx';
import PrerequisitePresenter from './PrerequisitePresenter.jsx';
import {ReviewPresenter} from "./ReviewPresenter.jsx"
import {syncScrollPositionToFirebase} from "../../firebase.js"
-import { model } from '../model.js';
+import { useCallback } from 'react';
const ListViewPresenter = observer(({ model }) => {
const scrollContainerRef = useRef(null);
@@ -50,12 +50,12 @@ const ListViewPresenter = observer(({ model }) => {
if (savedPosition) {
model.setScrollPosition(parseInt(savedPosition, 10));
}
- }, [model.user]);
+ }, [model, model.user]);
useEffect(() => {
const cleanup = syncScrollPositionToFirebase(model, scrollContainerRef);
return () => cleanup;
- }, [model.user, model.currentSearch, scrollContainerRef]);
+ }, [model, model.user, model.currentSearch, scrollContainerRef]);
const addFavourite = (course) => {
model.addFavourite(course);
@@ -77,9 +77,8 @@ const ListViewPresenter = observer(({ model }) => {
const [sortBy, setSortBy] = useState('relevance');
const [sortDirection, setSortDirection] = useState('desc');
- const [sortedCourses, setSortedCourses] = useState([]);
- const sortCourses = (courses, sortType) => {
+ const sortCourses = useCallback((courses, sortType) => {
if (!courses) return [];
const sortedCourses = [...courses];
@@ -143,12 +142,12 @@ const ListViewPresenter = observer(({ model }) => {
default:
return direction === 1 ? sortedCourses : sortedCourses.reverse();
}
- };
+ }, [sortDirection, model.avgRatings]);
- useEffect(() => {
- const sorted = sortCourses(model.currentSearch, sortBy);
- setSortedCourses(sorted);
- }, [model.currentSearch, sortBy, sortDirection]);
+ const sortedCourses = useMemo(() => {
+ if (!model.currentSearch) return [];
+ return sortCourses(model.currentSearch, sortBy);
+ }, [model.currentSearch, sortBy,sortCourses]);
function indexOfNth(string, char, n) {
@@ -167,8 +166,8 @@ const ListViewPresenter = observer(({ model }) => {
window.addEventListener('popstate', () => {
model.handleUrlChange();
});
-
- model.onCoursesSet((courses) => {
+
+ model.onCoursesSet((courses) => { // eslint-disable-line no-unused-vars
let current_url = window.location.href;
if (current_url.indexOf("#") != -1) {return;}
let course_code = "";
diff --git a/my-app/src/presenters/PrerequisitePresenter.jsx b/my-app/src/presenters/PrerequisitePresenter.jsx
index e3d89b5a..22aeafae 100644
--- a/my-app/src/presenters/PrerequisitePresenter.jsx
+++ b/my-app/src/presenters/PrerequisitePresenter.jsx
@@ -35,9 +35,6 @@ export const PrerequisitePresenter = observer((props) => {
hover_popup.style.padding = "5px";
document.body.appendChild(hover_popup);
-
- let code_to_name;
-
let input_text_obj = {};
const position = { x: 0, y: 0 };
@@ -153,7 +150,8 @@ export const PrerequisitePresenter = observer((props) => {
}
- function handleMouseLeave(event, node) {
+
+ function handleMouseLeave(event, node) { // eslint-disable-line no-unused-vars
hover_popup.style.display = "none";
}
@@ -214,6 +212,11 @@ export const PrerequisitePresenter = observer((props) => {
type: node_type,
data: { label: name },
style: {
+ //padding: 0,
+ //maxWidth: "100px",
+ //display: 'inline-block',
+ //justifyContent: 'center',
+ //alignItems: 'center',
zIndex: 0
},
position,
@@ -456,7 +459,7 @@ export const PrerequisitePresenter = observer((props) => {
initialNodes.push(display_node);
} else {
try {
- let root = createNode(props.selectedCourse.code, props.selectedCourse.code, "input");
+ let root = createNode(props?.selectedCourse?.code, props?.selectedCourse?.code, "input");
let copy = JSON.parse(JSON.stringify(props.selectedCourse.prerequisites));
let courses_taken = [];
let local = JSON.parse(localStorage.getItem("completedCourses"));
diff --git a/my-app/src/presenters/ReviewPresenter.jsx b/my-app/src/presenters/ReviewPresenter.jsx
index 59d8b779..bc7de1c4 100644
--- a/my-app/src/presenters/ReviewPresenter.jsx
+++ b/my-app/src/presenters/ReviewPresenter.jsx
@@ -41,29 +41,33 @@ export const ReviewPresenter = observer(({ model, course }) => {
setErrorMessage("You need to be logged in to post a comment - Posting anonymously is possible.");
return;
}
- if (formData.text.trim()) {
- const review = {
- userName: postAnonymous ? "Anonymous" : model.user?.displayName,
- uid: model?.user?.uid,
- timestamp: Date.now(),
- ...formData,
- };
- if(!await model.addReview(course.code, review)){
- setErrorMessage("Something went wrong when posting. Are you logged in?")
- return;
- }
- const updatedReviews = await model.getReviews(course.code);
- setReviews(updatedReviews);
- setFormData({
- text: "",
- overallRating: 0,
- difficultyRating: 0,
- professorName: "",
- grade: "",
- recommended: false,
- });
+ if (!formData.text.trim() && formData.overallRating === 0) {
+ setErrorMessage("Please provide either a review text or an overall rating.");
+ return;
+ }
+
+ const review = {
+ userName: postAnonymous ? "Anonymous" : model.user?.displayName,
+ uid: model?.user?.uid,
+ timestamp: Date.now(),
+ ...formData,
+ };
+
+ if(!await model.addReview(course.code, review)){
+ setErrorMessage("Something went wrong when posting. Are you logged in?")
+ return;
}
+ const updatedReviews = await model.getReviews(course.code);
+ setReviews(updatedReviews);
+ setFormData({
+ text: "",
+ overallRating: 0,
+ difficultyRating: 0,
+ professorName: "",
+ grade: "",
+ recommended: false,
+ });
};
diff --git a/my-app/src/presenters/SearchbarPresenter.jsx b/my-app/src/presenters/SearchbarPresenter.jsx
index 033d6d14..9cdeca7f 100644
--- a/my-app/src/presenters/SearchbarPresenter.jsx
+++ b/my-app/src/presenters/SearchbarPresenter.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useCallback } from 'react';
+import React, { useMemo, useCallback } from 'react';
import { observer } from "mobx-react-lite";
import { useState } from 'react';
import CoursePagePopup from '../views/Components/CoursePagePopup.jsx';
@@ -11,19 +11,21 @@ import debounce from 'lodash.debounce';
const SearchbarPresenter = observer(({ model }) => {
const [searchQuery, setSearchQuery] = useState("");
- const fuseOptions = {
+ const fuseOptions = useMemo(() => ({
keys: [
{ name: 'code', weight: 0.5 },
{ name: 'name', weight: 0.4 },
{ name: 'description', weight: 0.1 },
],
- threshold: 0.3141592653589793238, // adjust this for sensitivity
+ threshold: 0.3141592653589793238,
ignoreLocation: true,
minMatchCharLength: 2,
- };
+ }), []); // Options never change
// Debounced search function
const searchCourses = useCallback(debounce((query) => {
+ if(!model?.filteredCourses)
+ return;
if (!query.trim()) {
model.setCurrentSearch(model.filteredCourses);
} else {
@@ -43,7 +45,7 @@ const SearchbarPresenter = observer(({ model }) => {
model.setCurrentSearch(sortedResults.map(r => r.item));
model.searchQueryModel = query;
}
- }, 500), []);
+ }, 750), []);
const addFavourite = (course) => {
model.addFavourite(course);
diff --git a/my-app/src/scripts/eligibility_refined.js b/my-app/src/scripts/eligibility_refined.js
index 411b12a4..c95b2f5d 100644
--- a/my-app/src/scripts/eligibility_refined.js
+++ b/my-app/src/scripts/eligibility_refined.js
@@ -52,6 +52,12 @@ function prereq_convert(courses_taken, current_object, previous_key, hash_bool,
function eligibility_check(courses_taken, prereqs_object, hash_bool, count_object) {
+ if (!Array.isArray(courses_taken)) {
+ throw new Error('Courses taken must be an array');
+ }
+ if (!prereqs_object || typeof prereqs_object !== 'object') {
+ throw new Error('Prerequisites must be an object');
+ }
prereq_convert(courses_taken, prereqs_object, null, hash_bool, count_object);
let key = Object.keys(prereqs_object);
return prereqs_object[key];
diff --git a/my-app/src/views/ListView.jsx b/my-app/src/views/ListView.jsx
index 29a1315d..4599e452 100644
--- a/my-app/src/views/ListView.jsx
+++ b/my-app/src/views/ListView.jsx
@@ -32,7 +32,7 @@ function ListView(props) {
};
const handleFavouriteClick = (course) => {
- if (props.favouriteCourses.some(fav => fav.code === course.code)) {
+ if (props.favouriteCourses.some(fav => fav.code === course?.code)) {
props.removeFavourite(course);
} else {
props.addFavourite(course);
@@ -274,7 +274,7 @@ function ListView(props) {
)}
{props.popup}
- {!isLoading && props.targetScroll > 1000 && (
+ {!isLoading && !props.isPopupOpen && props.targetScroll > 1000 && (