-
{{ 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[];
+};