Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Requirement builder with the new data format #588

Merged
merged 6 commits into from
Dec 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/components/Modals/NewCourse/NewCourseModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ export default defineComponent({
components: { CourseSelector, TeleportModal, SelectedRequirementEditor },
emits: {
'close-course-modal': () => true,
'add-course': (course: CornellCourseRosterCourse, requirementID: string) =>
typeof course === 'object' && typeof requirementID === 'string',
'add-course': (course: CornellCourseRosterCourse, choice: FirestoreCourseOptInOptOutChoices) =>
typeof course === 'object' && typeof choice === 'object',
},
data() {
return {
Expand All @@ -78,9 +78,6 @@ export default defineComponent({
rightButtonText(): string {
return this.editMode ? 'Next' : 'Add';
},
selectableRequirementChoices(): AppSelectableRequirementChoices {
return store.state.selectableRequirementChoices;
},
},
methods: {
selectCourse(result: CornellCourseRosterCourse) {
Expand All @@ -98,7 +95,7 @@ export default defineComponent({
selectedCourse,
store.state.groupedRequirementFulfillmentReport,
store.state.toggleableRequirementChoices,
/* deprecated AppOverriddenFulfillmentChoices */ {}
store.state.overriddenFulfillmentChoices
);

const requirementsThatAllowDoubleCounting: string[] = [];
Expand Down Expand Up @@ -137,7 +134,17 @@ export default defineComponent({
},
addCourse() {
if (this.selectedCourse == null) return;
this.$emit('add-course', this.selectedCourse, this.selectedRequirementID);
this.$emit('add-course', this.selectedCourse, {
// Only exclude the selected requirement from opt-out.
optOut: this.relatedRequirements
.filter(it => it.id !== this.selectedRequirementID)
.map(it => it.id),
// Only include the selected requirement from opt-in.
acknowledgedCheckerWarningOptIn: this.selfCheckRequirements
.filter(it => it.id === this.selectedRequirementID)
.map(it => it.id),
arbitraryOptIn: {},
});
this.closeCurrentModal();
},
onSelectedChange(selected: string) {
Expand Down
125 changes: 73 additions & 52 deletions src/components/Requirements/IncompleteSelfCheck.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,12 @@ import store from '@/store';
import {
cornellCourseRosterCourseToFirebaseSemesterCourseWithGlobalData,
addCourseToSemester,
addCourseToSelectableRequirements,
updateRequirementChoice,
} from '@/global-firestore-data';
import { canFulfillChecker } from '@/requirements/requirement-frontend-utils';
import {
canFulfillChecker,
getAllEligibleRelatedRequirementIds,
} from '@/requirements/requirement-frontend-utils';

import NewSelfCheckCourseModal from '@/components/Modals/NewCourse/NewSelfCheckCourseModal.vue';

Expand Down Expand Up @@ -74,61 +77,46 @@ export default defineComponent({
// and courses that do not fulfill the requirement checker
selfCheckCourses(): Record<string, FirestoreSemesterCourse> {
const courses: Record<string, FirestoreSemesterCourse> = {};
store.state.semesters.forEach(semester => {
semester.courses.forEach(course => {
const selectableRequirementCourses =
store.state.derivedSelectableRequirementData.requirementToCoursesMap[this.subReqId];
store.state.semesters
.flatMap(it => it.courses)
.forEach(course => {
if (
!canFulfillChecker(
store.state.userRequirementsMap,
store.state.toggleableRequirementChoices,
this.subReqId,
course.crseId
)
) {
// If the course can't help fulfill the checker, do not add to choices.
return;
}

// if course is mapped to another req(s), only allow it if all other reqs are double countable
let isAddable = true;
const otherReqsMappedTo = store.state.requirementFulfillmentGraph.getConnectedRequirementsFromCourse(
const currentlyMatchedRequirements = store.state.requirementFulfillmentGraph.getConnectedRequirementsFromCourse(
{ uniqueId: course.uniqueID }
);

// true if all other requirements (if any) the course is assigned to are double countable, false otherwise
let allOtherReqsDoubleCountableIfAny = true;

// true if this requirement is double countable, false otherwise.
let thisReqDoubleCountable = false;

// loop through all reqs and determine if all other reqs this course is assigned to are
// double countable (if any exist) and whether or not this req itself is double countable
const collegesMajorsMinors = store.state.groupedRequirementFulfillmentReport;
collegesMajorsMinors.forEach(reqGroup => {
reqGroup.reqs.forEach(req => {
if (
otherReqsMappedTo.includes(req.requirement.id) &&
!req.requirement.allowCourseDoubleCounting
) {
allOtherReqsDoubleCountableIfAny = false;
} else if (
req.requirement.id === this.subReqId &&
req.requirement.allowCourseDoubleCounting
) {
thisReqDoubleCountable = true;
}
});
});

// if neither the current req or all other assigned reqs are not double countable, restrict from adding
if (!(allOtherReqsDoubleCountableIfAny || thisReqDoubleCountable)) {
isAddable = false;
if (currentlyMatchedRequirements.includes(this.subReqId)) {
// If the course is already matched to the current requirement, do not add to choices.
return;
}

const isAlreadyAddedToReq =
selectableRequirementCourses && selectableRequirementCourses.includes(course);

// filter out courses that cannot fulfill the self-check, for self-checks with warnings
const canFulfillReq = canFulfillChecker(
store.state.userRequirementsMap,
store.state.toggleableRequirementChoices,
this.subReqId,
course.crseId
);
const currentRequirementAllowDoubleCounting =
store.state.userRequirementsMap[this.subReqCourseId]?.allowCourseDoubleCounting;
const allOtherRequirementsAllowDoubleCounting = store.state.requirementFulfillmentGraph
.getConnectedRequirementsFromCourse({ uniqueId: course.uniqueID })
.every(reqID => store.state.userRequirementsMap[reqID]?.allowCourseDoubleCounting);
if (!currentRequirementAllowDoubleCounting && !allOtherRequirementsAllowDoubleCounting) {
// At this point, we need to consider double counting issues.
// There are 2 ways we can add the course to the requirement without double counting violations:
// 1. This requirement allows double counting.
// 2. All the already matched requirements allow double counting.
// If both don't hold, we cannot add.
return;
}

if (!isAlreadyAddedToReq && isAddable && canFulfillReq) courses[course.code] = course;
// All pre-conditions have been checked, we can add it as choice!
courses[course.code] = course;
});
});

return courses;
},
Expand All @@ -150,12 +138,45 @@ export default defineComponent({
},
addExistingCourse(option: string) {
this.showDropdown = false;
addCourseToSelectableRequirements(this.selfCheckCourses[option].uniqueID, this.subReqId);
updateRequirementChoice(this.selfCheckCourses[option].uniqueID, choice => ({
...choice,
// Since we edit from a self-check requirement,
// we know it must be `acknowledgedCheckerWarningOptIn`.
acknowledgedCheckerWarningOptIn: Array.from(
new Set([...choice.acknowledgedCheckerWarningOptIn, this.subReqId])
),
// Keep existing behavior of keeping it connected to at most one requirement.
optOut: getAllEligibleRelatedRequirementIds(
this.selfCheckCourses[option].crseId,
store.state.groupedRequirementFulfillmentReport,
store.state.toggleableRequirementChoices
),
}));
},
addNewCourse(course: CornellCourseRosterCourse, season: FirestoreSemesterSeason, year: number) {
this.showDropdown = false;
const newCourse = cornellCourseRosterCourseToFirebaseSemesterCourseWithGlobalData(course);
addCourseToSemester(year, season, newCourse, this.subReqId, this.$gtag);
addCourseToSemester(
year,
season,
newCourse,
// Since the course is new, we know the old choice does not exist.
() => ({
arbitraryOptIn: {},
// Since we edit from a self-check requirement,
// we know it must be `acknowledgedCheckerWarningOptIn`.
acknowledgedCheckerWarningOptIn: [this.subReqId],
// We also need to opt-out of all requirements without warnings,
// because the user intention is clear that we only want to bind
// the course to this specific requirement.
optOut: getAllEligibleRelatedRequirementIds(
newCourse.crseId,
store.state.groupedRequirementFulfillmentReport,
store.state.toggleableRequirementChoices
),
}),
this.$gtag
);
},
openCourseModal() {
this.isCourseModalOpen = true;
Expand Down
11 changes: 3 additions & 8 deletions src/components/Requirements/RequirementSelfCheckSlots.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import IncompleteSelfCheck from '@/components/Requirements/IncompleteSelfCheck.v

import store from '@/store';
import {
convertFirestoreSemesterCourseToCourseTaken,
getMatchedRequirementFulfillmentSpecification,
courseIsAPIB,
} from '@/requirements/requirement-frontend-utils';
Expand All @@ -39,13 +38,9 @@ export default defineComponent({
computed: {
fulfilledSelfCheckCourses(): readonly CourseTaken[] {
// selectedCourses are courses that fulfill the requirement based on user-choice
// they are taken from derivedSelectableRequirementData
const selectedFirestoreCourses =
store.state.derivedSelectableRequirementData.requirementToCoursesMap[
this.requirementFulfillment.requirement.id
] || [];
const selectedCourses = selectedFirestoreCourses.map(
convertFirestoreSemesterCourseToCourseTaken
// they are taken from requirement graph
const selectedCourses = store.state.requirementFulfillmentGraph.getConnectedCoursesFromRequirement(
this.requirementFulfillment.requirement.id
);

// fulfillableCourses are the courses that can fulfill this requirement
Expand Down
49 changes: 41 additions & 8 deletions src/components/Semester/Semester.vue
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,10 @@ import {
addCourseToSemester,
deleteCourseFromSemester,
deleteAllCoursesFromSemester,
addCoursesToSelectableRequirements,
updateRequirementChoices,
} from '@/global-firestore-data';
import { updateSubjectColorData } from '@/store';
import store, { updateSubjectColorData } from '@/store';
import { getAllEligibleRelatedRequirementIds } from '@/requirements/requirement-frontend-utils';

type ComponentRef = { $el: HTMLDivElement };

Expand Down Expand Up @@ -229,6 +230,15 @@ export default defineComponent({
get(): readonly FirestoreSemesterCourse[] {
return this.courses;
},
/**
* This function is called when a course is dragged into the semester.
*
* It can be a semester-to-semester drag-n-drop, which does not have `requirementID`
* and does not require update the requirement.
* It can also be a requirement-bar-to-semester drag-n-drop, which has a `requirementID`
* attached to the course. We need to check the presence of this field and update requirement
* choice accordingly.
*/
set(newCourses: readonly AppFirestoreSemesterCourseWithRequirementID[]) {
const courses = newCourses.map(({ requirementID: _, ...rest }) => rest);
editSemester(
Expand All @@ -239,11 +249,33 @@ export default defineComponent({
courses,
})
);
const newChoices: Record<string, string> = {};
newCourses.forEach(({ uniqueID, requirementID }) => {
if (requirementID) newChoices[uniqueID] = requirementID;
updateRequirementChoices(oldChoices => {
const choices = { ...oldChoices };
newCourses.forEach(({ uniqueID, requirementID, crseId }) => {
if (requirementID == null) {
// In this case, it's not a course from requirement bar
return;
}
const choice = choices[uniqueID] || {
arbitraryOptIn: {},
acknowledgedCheckerWarningOptIn: [],
optOut: [],
};
// We know the requirement must be dragged from requirements without warnings,
// because only those courses provide suggested courses.
// As a result, `acknowledgedCheckerWarningOptIn` is irrelevant and we only need to update
// the `optOut` field.
// Below, we find all the requirements it can possibly match,
// and only remove the requirementID since that's the one we should keep.
const optOut = getAllEligibleRelatedRequirementIds(
crseId,
store.state.groupedRequirementFulfillmentReport,
store.state.toggleableRequirementChoices
).filter(it => it !== requirementID);
choices[uniqueID] = { ...choice, optOut };
});
return choices;
});
addCoursesToSelectableRequirements(newChoices);
},
},
// Add space for a course if there is a "shadow" of it, decrease if it is from the current sem
Expand Down Expand Up @@ -330,9 +362,10 @@ export default defineComponent({
closeConfirmationModal() {
this.isConfirmationOpen = false;
},
addCourse(data: CornellCourseRosterCourse, requirementID: string) {
addCourse(data: CornellCourseRosterCourse, choice: FirestoreCourseOptInOptOutChoices) {
const newCourse = cornellCourseRosterCourseToFirebaseSemesterCourseWithGlobalData(data);
addCourseToSemester(this.year, this.season, newCourse, requirementID, this.$gtag);
// Since the course is new, we know the old choice does not exist.
addCourseToSemester(this.year, this.season, newCourse, () => choice, this.$gtag);

const courseCode = `${data.subject} ${data.catalogNbr}`;
this.openConfirmationModal(`Added ${courseCode} to ${this.season} ${this.year}`);
Expand Down
4 changes: 0 additions & 4 deletions src/firebase-admin-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,6 @@ export const toggleableRequirementChoicesCollection = db
.collection('user-toggleable-requirement-choices')
.withConverter(getTypedFirestoreDataConverter<AppToggleableRequirementChoices>());

export const selectableRequirementChoicesCollection = db
.collection('user-selectable-requirement-choices')
.withConverter(getTypedFirestoreDataConverter<AppSelectableRequirementChoices>());

export const overriddenFulfillmentChoicesCollection = db
.collection('user-overridden-fulfillment-choices')
.withConverter(getTypedFirestoreDataConverter<FirestoreOverriddenFulfillmentChoices>());
Expand Down
4 changes: 0 additions & 4 deletions src/firebase-frontend-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,6 @@ export const toggleableRequirementChoicesCollection = db
.collection('user-toggleable-requirement-choices')
.withConverter(getTypedFirestoreDataConverter<AppToggleableRequirementChoices>());

export const selectableRequirementChoicesCollection = db
.collection('user-selectable-requirement-choices')
.withConverter(getTypedFirestoreDataConverter<AppSelectableRequirementChoices>());

export const overriddenFulfillmentChoicesCollection = db
.collection('user-overridden-fulfillment-choices')
.withConverter(getTypedFirestoreDataConverter<FirestoreOverriddenFulfillmentChoices>());
Expand Down
8 changes: 4 additions & 4 deletions src/global-firestore-data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export {
} from './semesters';
export { default as chooseToggleableRequirementOption } from './toggleable-requirement-choices';
export {
addCourseToSelectableRequirements,
addCoursesToSelectableRequirements,
deleteCourseFromSelectableRequirements,
} from './selectable-requirement-choices';
updateRequirementChoice,
updateRequirementChoices,
deleteCourseFromRequirementChoices,
} from './override-fulfillment-choices';
46 changes: 46 additions & 0 deletions src/global-firestore-data/override-fulfillment-choices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { overriddenFulfillmentChoicesCollection } from '../firebase-frontend-config';
import store from '../store';

export const updateRequirementChoice = (
courseUniqueID: string | number,
choiceUpdater: (choice: FirestoreCourseOptInOptOutChoices) => FirestoreCourseOptInOptOutChoices
): void => {
overriddenFulfillmentChoicesCollection.doc(store.state.currentFirebaseUser.email).set({
...store.state.overriddenFulfillmentChoices,
[courseUniqueID]: choiceUpdater(
store.state.overriddenFulfillmentChoices[courseUniqueID] || {
arbitraryOptIn: {},
acknowledgedCheckerWarningOptIn: [],
optOut: [],
}
),
});
};

export const updateRequirementChoices = (
updater: (
oldChoices: FirestoreOverriddenFulfillmentChoices
) => FirestoreOverriddenFulfillmentChoices
): void => {
overriddenFulfillmentChoicesCollection
.doc(store.state.currentFirebaseUser.email)
.set(updater(store.state.overriddenFulfillmentChoices));
};

export const deleteCourseFromRequirementChoices = (courseUniqueID: string | number): void =>
deleteCoursesFromRequirementChoices([courseUniqueID]);

export const deleteCoursesFromRequirementChoices = (
courseUniqueIds: readonly (string | number)[]
): void => {
const courseUniqueIdStrings = new Set(courseUniqueIds.map(uniqueId => uniqueId.toString()));
overriddenFulfillmentChoicesCollection
.doc(store.state.currentFirebaseUser.email)
.set(
Object.fromEntries(
Object.entries(store.state.overriddenFulfillmentChoices).filter(
([uniqueId]) => !courseUniqueIdStrings.has(uniqueId)
)
)
);
};
Loading