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 && (