diff --git a/scripts/migration/savedCourses-migration.ts b/scripts/migration/savedCourses-migration.ts new file mode 100644 index 000000000..3ecb9ee59 --- /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 emptyCourses = []; + await semestersCollection.doc(userEmail).update({ + savedCourses: [{ name: 'All', emptyCourses }], + }); +} + +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/components/Course/Course.vue b/src/components/Course/Course.vue index c314f1636..81f7bd73d 100644 --- a/src/components/Course/Course.vue +++ b/src/components/Course/Course.vue @@ -10,7 +10,7 @@ @@ -88,9 +88,9 @@ import { reportCourseColorChange, reportSubjectColorChange, } from '@/components/BottomBar/BottomBarState'; +import { isCourseConflict } from '@/store'; import { clickOutside } from '@/utilities'; import EditColor from '../Modals/EditColor.vue'; -import { isCourseConflict } from '@/store'; export default defineComponent({ components: { CourseCaution, CourseMenu, EditColor, SaveCourseModal }, @@ -113,6 +113,15 @@ 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', }, data() { return { @@ -182,13 +191,12 @@ export default defineComponent({ closeEditColorModal() { this.isEditColorOpen = false; }, - addCollection() { - this.isSaveCourseOpen = false; - // TODO: implement add collection + addCollection(name: string) { + this.$emit('add-collection', name); }, - addCourseCollection() { - this.isSaveCourseOpen = false; - // TODO: implement save course + 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); diff --git a/src/components/Modals/SaveCourseModal.scss b/src/components/Modals/SaveCourseModal.scss new file mode 100644 index 000000000..79ed287c0 --- /dev/null +++ b/src/components/Modals/SaveCourseModal.scss @@ -0,0 +1,200 @@ +@import '@/assets/scss/_variables.scss'; +.saveCourseModal { + &-title { + display: flex; + justify-content: space-between; + padding-top: 0.6rem; + gap: 0.5rem; + img { + margin-top: 2%; + align-self: flex-start; + } + } + + &-header { + display: flex; + align-self: center; + margin-bottom: 0.7rem; + width: 112%; + height: 2rem; + border: 0.3px solid $lightGray; + color: $primaryGray; + padding: 1rem; + + &-text { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + font-weight: 900; + width: 100%; + + &-addButton { + cursor: pointer; + &:hover { + opacity: 0.5; + } + } + } + } + + &-body { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + color: $primaryGray; + width: 100%; + position: relative; + max-height: 4.5rem; + overflow-y: auto; + overflow-x: hidden; + box-sizing: border-box; + + ::-webkit-scrollbar-button { + display: none; // Hide the up and down arrows + } + + &-content { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 0.5rem; + width: 100%; + + &.default-collection { + justify-content: center; + align-items: center; + } + + &-collection { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + color: $primaryGray; + gap: 0.5rem; + padding: 0rem; + + input[type='checkbox'] { + margin: 0; + padding: 0; + appearance: none; + width: 12px; + height: 12px; + border-radius: 10%; + border: 1px solid $lightGray; + background-color: white; + cursor: pointer; + position: relative; // For the ::before element positioning + user-select: none; + outline: none; + + &:hover { + border: 1px solid $emGreen; + } + + &:checked{ + background-color: $emGreen; + border: 1px solid $emGreen; + } + + // Show checkbox vector when checked + &:checked::before{ + content: ''; + background-image: url('@/assets/images/checkmark-color.svg'); + background-size: contain; + background-repeat: no-repeat; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-48%, -38%); + width: 9px; + height: 9px; + } + } + + label { + cursor: pointer; + user-select: none; + margin: 0; + padding: 0; + outline: none; + } + } + } + &-bottom { + display: flex; + align-items: center; + justify-items: center; + width: 100%; + + input[type='checkbox'] { + margin-right: 0.35rem; + appearance: none; + width: 12px; + height: 12px; + border-radius: 10%; + border: 1px solid $lightGray; + background-color: white; + cursor: pointer; + position: relative; // For the ::before element positioning + user-select: none; + outline: none; + + &:hover { + border: 1px solid $emGreen; + } + + &:checked { + background-color: $emGreen; + border: 1px solid $emGreen; + } + + // Show checkbox vector when checked + &:checked::before { + content: ''; + background-image: url('@/assets/images/checkmark-color.svg'); + background-size: contain; + background-repeat: no-repeat; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-48%, -38%); + width: 9px; + height: 9px; + } + } + + .new-collection-label { + flex-grow: 1; + display: flex; + align-items: center; + margin-top: 0.3rem; + + .editable-input { + border: 1px solid $lightGray; + border-radius: 4px; + box-sizing: border-box; + transition: border 0.2s ease; + width: 80%; + padding-left: 0.1rem; + + &:focus { + outline: none; + border-color: $lightGray; + box-shadow: none; + } + } + } + } + } + &-divider-line { + display: flex; + align-self: center; + width: 112%; + height: 0.3px; + background-color: $lightGray; + } + } \ No newline at end of file diff --git a/src/components/Modals/SaveCourseModal.vue b/src/components/Modals/SaveCourseModal.vue index 412ed81c6..ed90e307d 100644 --- a/src/components/Modals/SaveCourseModal.vue +++ b/src/components/Modals/SaveCourseModal.vue @@ -2,6 +2,7 @@
-
Collections -
-
-
-

{{ collection }}

- +
+
+
+ No collections added yet +
+
+
+ + +
+
+
+ +
+ +
+
diff --git a/src/components/Semester/Semester.vue b/src/components/Semester/Semester.vue index b8c0bac0c..e9183c281 100644 --- a/src/components/Semester/Semester.vue +++ b/src/components/Semester/Semester.vue @@ -108,6 +108,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) { + this.openConfirmationModal(`Saved ${course.code} to ${addedToCollections.join(', ')}`); + } else { + 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/global-firestore-data/index.ts b/src/global-firestore-data/index.ts index 47b6b076d..3f68a28c6 100644 --- a/src/global-firestore-data/index.ts +++ b/src/global-firestore-data/index.ts @@ -17,12 +17,19 @@ export { updateSawNewFeature, } from './user-onboarding-data'; export { + editCollections, + editCollection, + editDefaultCollection, editPlans, editPlan, editSemesters, editSemester, + addCollection, + addCourseToCollections, addSemester, addPlan, + deleteCollection, + deleteCourseFromCollection, deletePlan, deleteSemester, addCourseToSemester, diff --git a/src/global-firestore-data/user-semesters.ts b/src/global-firestore-data/user-semesters.ts index f03e663ae..d9dc06cf3 100644 --- a/src/global-firestore-data/user-semesters.ts +++ b/src/global-firestore-data/user-semesters.ts @@ -12,6 +12,16 @@ import { deleteCoursesFromRequirementChoices, } from './user-overridden-fulfillment-choices'; +export const editCollections = async ( + updater: (oldCollections: readonly Collection[]) => 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 + * @param updater + */ +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('All', 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,16 @@ export const addPlan = async ( ); }; +/** [deleteCollection] delete an entire collection. Now all courses from + * the collection can be added to semesters. + */ +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 +210,40 @@ 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 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 6a5dc8d26..5cb73b170 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 {} @@ -99,6 +101,8 @@ const store: TypedVuexStore = new TypedVuexStore({ isTeleportModalOpen: false, plans: [], currentPlan: { name: '', semesters: [] }, + savedCourses: [], + allSavedCourses: { name: '', courses: [] }, }, actions: {}, getters: { @@ -186,6 +190,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; + }, }, }); @@ -203,7 +213,7 @@ const autoRecomputeDerivedData = (): (() => void) => ); break; } - case 'setSemesters' || 'setPlans': { + case 'setSemesters' || 'setPlans' || 'setSavedCourses': { const allCourseSet = new Set(); const duplicatedCourseCodeSet = new Set(); const courseMap: Record = {}; @@ -245,7 +255,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( @@ -326,17 +338,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(), @@ -347,6 +364,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 ceb82ba4a..a35a804ee 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; @@ -225,3 +226,8 @@ type Plan = { readonly name: string; readonly semesters: readonly FirestoreSemester[]; }; + +type Collection = { + readonly name: string; + readonly courses: readonly FirestoreSemesterCourse[]; +};