diff --git a/scripts/migration/savedCourses-migration.ts b/scripts/migration/savedCourses-migration.ts new file mode 100644 index 000000000..0eae10b9e --- /dev/null +++ b/scripts/migration/savedCourses-migration.ts @@ -0,0 +1,31 @@ +/* eslint-disable no-console */ + +import { usernameCollection, semestersCollection } from '../firebase-config'; + +/** + * Perform migration of a default The 'All' Collection for All Courses saved + */ +async function runOnUser(userEmail: string) { + const courses = [] as object; + await semestersCollection.doc(userEmail).update({ + savedCourses: [{ name: 'All', courses }], + }); +} + +async function main() { + const userEmail = process.argv[2]; + if (userEmail != null) { + await runOnUser(userEmail); + return; + } + const collection = await usernameCollection.get(); + for (const { id } of collection.docs) { + console.group(`Running on ${id}...`); + // Intentionally await in a loop to have no interleaved console logs. + // eslint-disable-next-line no-await-in-loop + await runOnUser(id); + console.groupEnd(); + } +} + +main(); diff --git a/src/assets/images/dropdown.svg b/src/assets/images/dropdown.svg new file mode 100644 index 000000000..40bbb0109 --- /dev/null +++ b/src/assets/images/dropdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/navbar/savedCoursesIcon.svg b/src/assets/images/navbar/savedCoursesIcon.svg new file mode 100644 index 000000000..d83b2998c --- /dev/null +++ b/src/assets/images/navbar/savedCoursesIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/navbar/savedCoursesIconBlue.svg b/src/assets/images/navbar/savedCoursesIconBlue.svg new file mode 100644 index 000000000..372e8d7c7 --- /dev/null +++ b/src/assets/images/navbar/savedCoursesIconBlue.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/plus.svg b/src/assets/images/plus.svg new file mode 100644 index 000000000..e1c6a5c79 --- /dev/null +++ b/src/assets/images/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/saveIconBig.svg b/src/assets/images/saveIconBig.svg new file mode 100644 index 000000000..df5eee259 --- /dev/null +++ b/src/assets/images/saveIconBig.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/saveIconSmall.svg b/src/assets/images/saveIconSmall.svg new file mode 100644 index 000000000..19f43c53f --- /dev/null +++ b/src/assets/images/saveIconSmall.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/trash-gray.svg b/src/assets/images/trash-gray.svg new file mode 100644 index 000000000..0a6966bed --- /dev/null +++ b/src/assets/images/trash-gray.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/scss/_variables.scss b/src/assets/scss/_variables.scss index 4754af753..b98805b88 100644 --- a/src/assets/scss/_variables.scss +++ b/src/assets/scss/_variables.scss @@ -17,6 +17,7 @@ $offWhite: #f8f8f8; $searchBoxWhite: #f1f1f1; $veryLightGray: #e5e5e5; $lightGray: #acacac; +$lightestGray: #e0e0e0; $viewLabelGray: #7e7e7e80; $medGray: #737373; $darkGray: #3c3c3c; diff --git a/src/components/Course/Course.vue b/src/components/Course/Course.vue index 03736dd14..907d4886d 100644 --- a/src/components/Course/Course.vue +++ b/src/components/Course/Course.vue @@ -7,6 +7,13 @@ }" class="course" > + - +
{{ courseObj.name }}
@@ -60,12 +80,15 @@
@@ -76,17 +99,20 @@ import { CSSProperties, PropType, defineComponent } from 'vue'; import CourseMenu from '@/components/Modals/CourseMenu.vue'; import CourseCaution from '@/components/Course/CourseCaution.vue'; +import SaveCourseModal from '@/components/Modals/SaveCourseModal.vue'; import { addCourseToBottomBar, reportCourseColorChange, reportSubjectColorChange, } from '@/components/BottomBar/BottomBarState'; +import { isCourseConflict } from '@/store'; import { clickOutside } from '@/utilities'; import EditColor from '../Modals/EditColor.vue'; -import { isCourseConflict } from '@/store'; +import trashGrayIcon from '@/assets/images/trash-gray.svg'; +import trashRedIcon from '@/assets/images/trash.svg'; export default defineComponent({ - components: { CourseCaution, CourseMenu, EditColor }, + components: { CourseCaution, CourseMenu, EditColor, SaveCourseModal }, props: { courseObj: { type: Object as PropType, required: true }, compact: { type: Boolean, required: true }, @@ -95,6 +121,7 @@ export default defineComponent({ semesterIndex: { type: Number, required: false, default: 0 }, season: { type: String, required: false, default: '' }, year: { type: Number, required: false, default: 0 }, + isSemesterCourseCard: { type: Boolean, required: true }, isSchedGenCourse: { type: Boolean, required: false, default: false }, }, emits: { @@ -107,6 +134,16 @@ export default defineComponent({ 'course-on-click': (course: FirestoreSemesterCourse) => typeof course === 'object', 'edit-course-credit': (credit: number, uniqueID: number) => typeof credit === 'number' && typeof uniqueID === 'number', + 'save-course': ( + course: FirestoreSemesterCourse, + addedToCollections: string[], + deletedFromCollection: string[] + ) => + typeof course === 'object' && + typeof addedToCollections === 'object' && + typeof deletedFromCollection === 'object', + 'add-collection': (name: string) => typeof name === 'string', + 'delete-course-from-collection': (courseCode: string) => typeof courseCode === 'string', }, data() { return { @@ -114,7 +151,11 @@ export default defineComponent({ stopCloseFlag: false, getCreditRange: this.courseObj.creditRange, isEditColorOpen: false, + isSaveCourseOpen: false, editedColor: '', + deletingCourse: false, + trashIcon: trashGrayIcon, // Default icon + courseCode: '', }; }, computed: { @@ -155,10 +196,21 @@ export default defineComponent({ this.menuOpen = false; } }, + openSaveCourseModal(courseCode: string) { + this.courseCode = courseCode; + this.isSaveCourseOpen = true; + this.closeMenuIfOpen(); + }, + closeSaveCourseModal() { + this.isSaveCourseOpen = false; + }, deleteCourse() { this.$emit('delete-course', this.courseObj.code, this.courseObj.uniqueID); this.closeMenuIfOpen(); }, + deleteCourseFromCollection() { + this.$emit('delete-course-from-collection', this.courseObj.code); + }, openEditColorModal(color: string) { this.editedColor = color; this.isEditColorOpen = true; @@ -166,6 +218,13 @@ export default defineComponent({ closeEditColorModal() { this.isEditColorOpen = false; }, + addCollection(name: string) { + this.$emit('add-collection', name); + }, + saveCourse(addedToCollections: string[], deletedFromCollections: string[]) { + const course = { ...this.courseObj }; + this.$emit('save-course', course, addedToCollections, deletedFromCollections); + }, colorCourse(color: string) { this.$emit('color-course', color, this.courseObj.uniqueID, this.courseObj.code); reportCourseColorChange(this.courseObj.uniqueID, color); @@ -177,7 +236,7 @@ export default defineComponent({ this.closeMenuIfOpen(); }, courseOnClick() { - if (!this.menuOpen) { + if (!this.menuOpen && !this.deletingCourse) { this.$emit('course-on-click', this.courseObj); addCourseToBottomBar(this.courseObj, this.season, this.year); } @@ -187,6 +246,12 @@ export default defineComponent({ this.closeMenuIfOpen(); }, isCourseConflict, + hoverTrashIcon() { + this.trashIcon = trashRedIcon; + }, + unhoverTrashIcon() { + this.trashIcon = trashGrayIcon; + }, }, directives: { 'click-outside': clickOutside, @@ -251,6 +316,18 @@ export default defineComponent({ } } + &-trash { + padding: 8px 0; + display: flex; + position: relative; + + &:hover, + &:active, + &:focus { + cursor: pointer; + } + } + &-content { width: calc(100% - #{$colored-grabber-width}); padding: 0 1rem; @@ -297,8 +374,7 @@ export default defineComponent({ display: flex; align-items: center; } - - &-credits { + port &-credits { white-space: nowrap; } diff --git a/src/components/DropDownArrow.vue b/src/components/DropDownArrow.vue index 259b531c1..7c246d4ee 100644 --- a/src/components/DropDownArrow.vue +++ b/src/components/DropDownArrow.vue @@ -31,6 +31,7 @@ export default defineComponent({ diff --git a/src/components/Modals/CourseMenu.vue b/src/components/Modals/CourseMenu.vue index fb3833292..81e84ef4a 100644 --- a/src/components/Modals/CourseMenu.vue +++ b/src/components/Modals/CourseMenu.vue @@ -1,6 +1,20 @@ diff --git a/src/components/Requirements/RequirementSideBar.vue b/src/components/Requirements/RequirementSideBar.vue index 7a2652687..beb8afb1b 100644 --- a/src/components/Requirements/RequirementSideBar.vue +++ b/src/components/Requirements/RequirementSideBar.vue @@ -18,146 +18,163 @@ data-tooltipClass="tooltipCenter tourStep1" > -
- - -
- - - - - -
- -
-
- - - - - -
- -
+
- -
-
-
-
-
- - - - -
-
-

{{ showAllCourses.name }}

-
- {{ pageText }} -
- + +
+ + + + + +
+ +
+
+
+ + +
+
+ - Prev - - + + +
+ +
+
- Next - + +
- - - +
+ + + +
@@ -177,6 +194,8 @@ import RequirementDebugger from '@/components/Requirements/RequirementDebugger.v import RequirementGroup from '@/components/Requirements/RequirementGroup.vue'; import DropDownArrow from '@/components/DropDownArrow.vue'; import MultiplePlansDropdown from './MultiplePlansDropdown.vue'; +import CollectionsSideBar from '../Modals/CollectionsSideBar.vue'; +import EditCollectionModal from '../Modals/EditCollectionModal.vue'; import clipboard from '@/assets/images/clipboard.svg'; import warning from '@/assets/images/warning.svg'; @@ -187,6 +206,11 @@ import { editPlan, deletePlan, addPlan, + editCollection, + editDefaultCollection, + deleteCollection, + deleteCourseFromCollection, + deleteCourseFromAllCollections, updateSawNewFeature, } from '@/global-firestore-data'; import AddPlanModal from '@/components/Modals/MultiplePlans/AddPlanModal.vue'; @@ -216,6 +240,9 @@ type Data = { isConfirmationOpen: boolean; selectedPlanCopy: string; confirmationText: string; + isEditCollectionOpen: boolean; + oldCollectionName: string; + defaultCollectionName: string; }; // This section will be revisited when we try to make first-time tooltips @@ -244,15 +271,18 @@ export default defineComponent({ CopyPlanModal, NamePlanModal, EditPlanModal, + CollectionsSideBar, + EditCollectionModal, }, props: { startTour: { type: Boolean, required: true }, isDisplayingMobile: { type: Boolean, required: true }, isMobile: { type: Boolean, required: true }, isMinimized: { type: Boolean, required: true }, + isDisplayingCollection: { type: Boolean, required: true }, startMultiplePlansTour: { type: Boolean, required: true }, }, - emits: ['showTourEndWindow', 'toggleMinimized'], + emits: ['showTourEndWindow', 'toggleMinimized'], // probably will remove emits with just using components data(): Data { return { displayDebugger: false, @@ -267,9 +297,12 @@ export default defineComponent({ isCopyPlanOpen: false, isNamePlanOpen: false, isEditPlanOpen: false, + isEditCollectionOpen: false, isConfirmationOpen: false, selectedPlanCopy: '', confirmationText: '', + oldCollectionName: '', + defaultCollectionName: 'All', }; }, watch: { @@ -353,9 +386,57 @@ export default defineComponent({ toggleEditPlan() { this.isEditPlanOpen = !this.isEditPlanOpen; }, + toggleEditCollection() { + this.isEditCollectionOpen = !this.isEditCollectionOpen; + this.oldCollectionName = ''; + }, toggleDebugger(): void { this.displayDebugger = !this.displayDebugger; }, + + openEditCollectionModal(collection: string) { + this.isEditCollectionOpen = true; + this.oldCollectionName = collection; + }, + deleteCourseFromCollection(collectionName: string, courseCode: string) { + if (collectionName === this.defaultCollectionName) { + deleteCourseFromAllCollections(courseCode); + } else { + deleteCourseFromCollection(collectionName, courseCode); + editDefaultCollection(); + } + this.confirmationText = `${courseCode} has been deleted from your collection!`; + this.isConfirmationOpen = true; + setTimeout(() => { + this.isConfirmationOpen = false; + }, 2000); + }, + deleteCollection(collectionName: string) { + deleteCollection(collectionName); + editDefaultCollection(); + this.confirmationText = `${collectionName} has been deleted!`; + this.isConfirmationOpen = true; + setTimeout(() => { + this.isConfirmationOpen = false; + }, 2000); + }, + editCollection(name: string, oldname: string) { + const { savedCourses } = store.state; + const toEdit = savedCourses.find(collection => collection.name === oldname); + const updater = (collection: Collection): Collection => ({ + name, + courses: collection.courses, + }); + if (toEdit !== undefined) { + editCollection(oldname, updater); + } + + this.confirmationText = `${oldname} has been renamed to ${name}!`; + this.isConfirmationOpen = true; + setTimeout(() => { + this.isConfirmationOpen = false; + }, 2000); + }, addPlan(name: string, copysem?: string) { if (copysem) { const { plans } = store.state; diff --git a/src/components/ScheduleGenerate/RequirementCourses.vue b/src/components/ScheduleGenerate/RequirementCourses.vue index ebd9031e4..c74eef8b8 100644 --- a/src/components/ScheduleGenerate/RequirementCourses.vue +++ b/src/components/ScheduleGenerate/RequirementCourses.vue @@ -50,6 +50,7 @@ :compact="false" :isSchedGenCourse="true" @delete-course="deleteCourse" + :isSemesterCourseCard="false" /> diff --git a/src/components/Semester/Semester.vue b/src/components/Semester/Semester.vue index b8c0bac0c..732c44c25 100644 --- a/src/components/Semester/Semester.vue +++ b/src/components/Semester/Semester.vue @@ -100,6 +100,7 @@ :isReqCourse="false" :compact="compact" :active="activatedCourse.uniqueID === element.uniqueID" + :isSemesterCourseCard="true" class="semester-course" data-cyId="semester-course" :semesterIndex="semesterIndex + 1" @@ -108,6 +109,9 @@ @color-subject="colorSubject" @course-on-click="courseOnClick" @edit-course-credit="editCourseCredit" + @save-course="saveCourse" + @add-collection="addCollection" + @edit-collection="editCollection" /> { + // If course is already in collection, remove it + deleteCourseFromCollection(collection, course.code); + }); + + addCourseToCollections( + store.state.currentPlan, + this.year, + this.season, + course, + addedToCollections + ); + + editDefaultCollection(); // edit the 'All' collection + + // Display confirmation message for all collections except the last one + if (addedToCollections.length !== 0 && deletedFromCollections.length !== 0) { + this.openConfirmationModal( + `Saved ${course.code} to ${addedToCollections.join(', ')}. Deleted ${ + course.code + } from ${deletedFromCollections.join(', ')}` + ); + } else if (deletedFromCollections.length === 0 && addedToCollections.length !== 0) { + this.openConfirmationModal(`Saved ${course.code} to ${addedToCollections.join(', ')}`); + } else if (addedToCollections.length === 0 && deletedFromCollections.length !== 0) { + this.openConfirmationModal( + ` Deleted ${course.code} from ${deletedFromCollections.join(', ')}` + ); + } + }, + addCollection(name: string) { + addCollection(name, []); + this.confirmationText = `${name} has been added!`; + this.isConfirmationOpen = true; + setTimeout(() => { + this.isConfirmationOpen = false; + }, 2000); + }, + editCollection(oldname: string, name: string) { + const { savedCourses } = store.state; + const toEdit = savedCourses.find(collection => collection.name === oldname); + const updater = (collection: Collection): Collection => ({ + name, + courses: collection.courses, + }); + if (toEdit !== undefined) { + editCollection(oldname, updater); + } + + this.confirmationText = `${oldname} has been renamed to ${name}!`; + this.isConfirmationOpen = true; + setTimeout(() => { + this.isConfirmationOpen = false; + }, 2000); + }, // TODO @willespencer refactor the below methods after gatekeep removed (to only 1 method) addCourse(data: CornellCourseRosterCourse, choice: FirestoreCourseOptInOptOutChoices) { const newCourse = cornellCourseRosterCourseToFirebaseSemesterCourseWithGlobalData(data); diff --git a/src/containers/Dashboard.vue b/src/containers/Dashboard.vue index e22b3f05a..e719132a9 100644 --- a/src/containers/Dashboard.vue +++ b/src/containers/Dashboard.vue @@ -31,6 +31,7 @@ @openProfile="openProfile" @openScheduleGenerate="openScheduleGenerate" @toggleRequirementsMobile="toggleRequirementsMobile" + @openCollection="openCollection" /> readonly Collection[] +): Promise => { + const savedCourses = updater(store.state.savedCourses); + store.commit('setSavedCourses', savedCourses); + await updateDoc(doc(semestersCollection, store.state.currentFirebaseUser.email), { + savedCourses, + }); +}; + export const editSemesters = ( plan: Plan, updater: (oldSemesters: readonly FirestoreSemester[]) => readonly FirestoreSemester[] @@ -41,6 +51,38 @@ export const setOrderByNewest = (orderByNewest: boolean): void => { }); }; +/** + * Updates the 'All'/Default Collection with all unique courses from all collections. + * + */ +export const editDefaultCollection = (): void => { + const allCollections = store.state.savedCourses; + const defaultCollectionName = 'All'; + + const uniqueCourses = new Set(); + allCollections.forEach(collection => { + if (collection.name !== defaultCollectionName) { + collection.courses.forEach(course => { + uniqueCourses.add(course); + }); + } + }); + + editCollection(defaultCollectionName, oldCollection => ({ + ...oldCollection, + courses: Array.from(uniqueCourses), + })); +}; + +export const editCollection = ( + name: string, + updater: (oldCollection: Collection) => Collection +): void => { + editCollections(oldCollection => + oldCollection.map(collection => (collection.name === name ? updater(collection) : collection)) + ); +}; + export const editSemester = ( plan: Plan, year: number, @@ -56,6 +98,17 @@ export const editPlan = (name: string, updater: (oldPlan: Plan) => Plan): void = editPlans(oldPlan => oldPlan.map(plan => (plan.name === name ? updater(plan) : plan))); }; +const createCollection = ( + name: string, + courses: readonly FirestoreSemesterCourse[] +): { + name: string; + courses: readonly FirestoreSemesterCourse[]; +} => ({ + name, + courses, +}); + const createSemester = ( year: number, season: FirestoreSemesterSeason, @@ -88,6 +141,15 @@ export const semesterEquals = ( season: FirestoreSemesterSeason ): boolean => semester.year === year && semester.season === season; +export const addCollection = async ( + name: string, + courses: readonly FirestoreSemesterCourse[], + gtag?: VueGtag +): Promise => { + GTagEvent(gtag, 'add-collection'); + await editCollections(oldCollections => [...oldCollections, createCollection(name, courses)]); +}; + export const addSemester = ( plan: Plan, year: number, @@ -112,6 +174,15 @@ export const addPlan = async ( ); }; +/** Deletes an entire collection including the courses. + */ +export const deleteCollection = async (name: string, gtag?: VueGtag): Promise => { + GTagEvent(gtag, 'delete-collection'); + if (store.state.savedCourses.some(p => p.name === name)) { + await editCollections(oldCollections => oldCollections.filter(p => p.name !== name)); + } +}; + export const deleteSemester = ( plan: Plan, year: number, @@ -138,6 +209,53 @@ export const deletePlan = async (name: string, gtag?: VueGtag): Promise => store.commit('setCurrentPlan', store.state.plans[0]); }; +/** Add one course to multiple collections. + * This course is removed from the requirement choices. + */ +export const addCourseToCollections = ( + plan: Plan, + year: number, + season: FirestoreSemesterSeason, + newCourse: FirestoreSemesterCourse, + collectionIDs: string[], + gtag?: VueGtag +): void => { + GTagEvent(gtag, 'add-course-collections'); + editCollections(oldCollections => + oldCollections.map(collection => { + if (collectionIDs.includes(collection.name)) { + return { ...collection, courses: [...collection.courses, newCourse] }; + } + return collection; + }) + ); + + deleteCourseFromSemester(plan, year, season, newCourse.uniqueID); + deleteCourseFromRequirementChoices(newCourse.uniqueID); +}; + +/** + * Delete a course from all collections including the 'All' Collection. + * + * @param code + */ +export const deleteCourseFromAllCollections = (code: string): void => { + // delete course from all collections + const allCollections = store.state.savedCourses; + allCollections.forEach(collection => { + deleteCourseFromCollection(collection.name, code); + }); +}; + +/** Delete a course from a certain collection. */ +export const deleteCourseFromCollection = (name: string, code: string): void => { + // delete course from collection + editCollection(name, oldCollection => ({ + ...oldCollection, + courses: oldCollection.courses.filter(course => course.code !== code), + })); +}; + export const addCourseToSemester = ( plan: Plan, year: number, diff --git a/src/gtag.ts b/src/gtag.ts index f79c48d2f..d19c1eafe 100644 --- a/src/gtag.ts +++ b/src/gtag.ts @@ -23,7 +23,9 @@ export const GTagLoginEvent = (gtag: VueGtag | undefined, method: string): void }; type EventType = + | 'add-collection' // User adds a collection | 'add-course' // User adds a course + | 'add-course-collections' // User adds a course to a collection(s) | 'add-modal-edit-requirements' // User clicks Edit Requirements on Add Modal | 'add-semester' // User adds a semester | 'add-plan' @@ -35,7 +37,9 @@ type EventType = | 'bottom-bar-view-course-information-on-roster' // User clicks View Course Information on Roster link on Bottom Bar | 'course-edit-color' // User edits the course color | 'subject-edit-color' // User edits the subject color + | 'delete-collection' // User deletes a collection | 'delete-course' // User deletes a course + | 'delete-course-collection' // User deletes a course from a collection | 'delete-semester' // User deletes a semester | 'delete-semester-courses' // User deletes all courses in a semester | 'delete-plan' @@ -58,6 +62,13 @@ export const GTagEvent = (gtag: VueGtag | undefined, eventType: EventType): void if (!gtag) return; let eventPayload: EventPayload | undefined; switch (eventType) { + case 'add-collection': + eventPayload = { + event_category: 'collection', + event_label: 'add-collection', + value: 1, + }; + break; case 'add-course': eventPayload = { event_category: 'course', @@ -65,6 +76,13 @@ export const GTagEvent = (gtag: VueGtag | undefined, eventType: EventType): void value: 1, }; break; + case 'add-course-collections': + eventPayload = { + event_category: 'collection', + event_label: 'add-course-collections', + value: 1, + }; + break; case 'add-modal-edit-requirements': eventPayload = { event_category: 'add-modal', @@ -142,6 +160,13 @@ export const GTagEvent = (gtag: VueGtag | undefined, eventType: EventType): void value: 1, }; break; + case 'delete-collection': + eventPayload = { + event_category: 'collection', + event_label: 'delete-collection', + value: 1, + }; + break; case 'delete-course': eventPayload = { event_category: 'course', @@ -149,6 +174,13 @@ export const GTagEvent = (gtag: VueGtag | undefined, eventType: EventType): void value: 1, }; break; + case 'delete-course-collection': + eventPayload = { + event_category: 'collection', + event_label: 'delete-course-collection', + value: 1, + }; + break; case 'delete-semester': eventPayload = { event_category: 'semester', diff --git a/src/store.ts b/src/store.ts index b562a10fd..750fd8291 100644 --- a/src/store.ts +++ b/src/store.ts @@ -51,6 +51,8 @@ export type VuexStoreState = { isTeleportModalOpen: boolean; plans: readonly Plan[]; currentPlan: Plan; + savedCourses: readonly Collection[]; + allSavedCourses: Collection; }; export class TypedVuexStore extends Store {} @@ -101,6 +103,8 @@ const store: TypedVuexStore = new TypedVuexStore({ isTeleportModalOpen: false, plans: [], currentPlan: { name: '', semesters: [] }, + savedCourses: [], + allSavedCourses: { name: '', courses: [] }, }, actions: {}, getters: { @@ -188,6 +192,12 @@ const store: TypedVuexStore = new TypedVuexStore({ setSawNewFeature(state: VuexStoreState, seen: boolean) { state.onboardingData.sawNewFeature = seen; }, + setSavedCourses(state: VuexStoreState, newSavedCourses: readonly Collection[]) { + state.savedCourses = newSavedCourses; + }, + setDefaultSavedCoursesCollection(state: VuexStoreState, allSavedCourses: Collection) { + state.allSavedCourses = allSavedCourses; + }, setSawGiveaway(state: VuexStoreState, seen: boolean) { state.onboardingData.sawGiveaway = seen; }, @@ -211,7 +221,7 @@ const autoRecomputeDerivedData = (): (() => void) => ); break; } - case 'setSemesters' || 'setPlans': { + case 'setSemesters' || 'setPlans' || 'setSavedCourses': { const allCourseSet = new Set(); const duplicatedCourseCodeSet = new Set(); const courseMap: Record = {}; @@ -253,7 +263,9 @@ const autoRecomputeDerivedData = (): (() => void) => mutation.type === 'setToggleableRequirementChoices' || mutation.type === 'setOverriddenFulfillmentChoices' || mutation.type === 'setCurrentPlan' || - mutation.type === 'setPlans' + mutation.type === 'setPlans' || + mutation.type === 'setDefaultSavedCoursesCollection' || + mutation.type === 'setSavedCourses' ) { if (state.onboardingData.college !== '') { store.commit( @@ -334,17 +346,22 @@ export const initializeFirestoreListeners = (onLoad: () => void): (() => void) = const plan = getFirstPlan(data); store.commit('setPlans', data.plans); store.commit('setCurrentPlan', plan); + store.commit('setSavedCourses', data.savedCourses); // Note: toggle this on and off to save collections progress after refresh const { orderByNewest } = data; store.commit('setSemesters', plan.semesters); updateDoc(doc(fb.semestersCollection, simplifiedUser.email), { plans: data.plans, + savedCourses: data.savedCourses, }); // if user hasn't yet chosen an ordering, choose true by default store.commit('setOrderByNewest', orderByNewest === undefined ? true : orderByNewest); } else { const plans = [{ name: 'Plan 1', semesters: [] }]; + const savedCourses = [{ name: 'All', courses: [] }]; // Warning: Every retruning user needs this Collection too store.commit('setPlans', plans); store.commit('setCurrentPlan', plans[0]); + store.commit('setSavedCourses', savedCourses); + store.commit('setDefaultSavedCoursesCollection', savedCourses[0]); const newSemester: FirestoreSemester = { year: getCurrentYear(), season: getCurrentSeason(), @@ -355,6 +372,7 @@ export const initializeFirestoreListeners = (onLoad: () => void): (() => void) = orderByNewest: true, plans: [{ name: 'Plan 1', semesters: [newSemester] }], semesters: [newSemester], + savedCourses: [{ name: 'All', courses: [] }], }); } semestersInitialLoadFinished = true; diff --git a/src/user-data.d.ts b/src/user-data.d.ts index d533f7ccc..1dc269850 100644 --- a/src/user-data.d.ts +++ b/src/user-data.d.ts @@ -36,6 +36,7 @@ type FirestoreSemester = { }; type FirestoreSemestersData = { + readonly savedCourses: readonly Collection[]; // confirmed works: readonly plans: readonly Plan[]; readonly semesters: readonly FirestoreSemester[]; readonly orderByNewest: boolean; @@ -232,3 +233,8 @@ type Plan = { readonly name: string; readonly semesters: readonly FirestoreSemester[]; }; + +type Collection = { + readonly name: string; + readonly courses: readonly FirestoreSemesterCourse[]; +}; diff --git a/src/utilities.ts b/src/utilities.ts index 7daceea59..6dcbaedaf 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -203,6 +203,11 @@ export const isPlaceholderCourse = ( ): element is FirestoreSemesterPlaceholder => !!(element as FirestoreSemesterPlaceholder).startingSemester; +// Determines whether the given element in a FireStoreSemester list is a Course or not +export const isFirestoreSemesterCourse = ( + element: FirestoreSemesterPlaceholder | FirestoreSemesterCourse | CourseTaken +): element is FirestoreSemesterCourse => !!(element as FirestoreSemesterCourse).crseId; + // Determines whether the given element used in CourseCaution is CourseTaken export const isCourseTaken = ( element: FirestoreSemesterPlaceholder | FirestoreSemesterCourse | CourseTaken