@@ -103,196 +114,48 @@ const Year: FC = ({ yearIndex, data }) => {
-
-
-
-
-
-
-
-
-
-
+
+
+
+ {
+ setShowEditYear(false);
+ if (startYear !== data.startYear) {
+ setPlaceholderYear(startYear);
+ dispatch(editYear({ startYear, index: yearIndex }));
+ }
+ if (name !== data.name) {
+ setPlaceholderName(name);
+ dispatch(editName({ name, index: yearIndex }));
+ }
+ const existing = data.quarters;
+ let removed = 0;
+ existing.forEach(({ name }, index) => {
+ const remove = !quarters.find((q) => q.name === name);
+ // Increment removed because the index of the quarters will change
+ if (remove) dispatch(deleteQuarter({ yearIndex, quarterIndex: index - removed++ }));
+ });
+ const addQuarters = quarters.filter(({ name }) => !existing.find((q) => q.name === name));
+ for (const { name } of addQuarters) {
+ dispatch(addQuarter({ startYear, quarterData: { name, courses: [] } }));
+ }
+ }}
+ currentQuarters={data.quarters.map((q) => q.name)}
+ type="edit"
+ />
{showContent && (
diff --git a/site/src/pages/RoadmapPage/YearModal.scss b/site/src/pages/RoadmapPage/YearModal.scss
new file mode 100644
index 00000000..c5ee6223
--- /dev/null
+++ b/site/src/pages/RoadmapPage/YearModal.scss
@@ -0,0 +1,99 @@
+.planner-year-modal {
+ .modal-header {
+ align-items: center;
+ border-bottom: none;
+ padding-bottom: 0;
+ }
+ .modal-content {
+ border: none;
+ background-color: var(--overlay2);
+ padding: 4px 8px 8px;
+ }
+ .modal-dialog {
+ max-width: 400px;
+ }
+ h2 {
+ margin-bottom: 0;
+ font-size: 1.8rem;
+ font-weight: 600;
+ }
+ button.close {
+ margin: -4px -4px;
+ padding: 4px 8px;
+ font-size: 32px;
+ overflow: hidden;
+ }
+
+ font-size: 18px;
+
+ .form-group {
+ > label {
+ font-size: 18px;
+ font-weight: 600;
+ }
+ input.form-group-input {
+ font-size: 16px;
+ padding: 4px 12px;
+ }
+ input.form-check-input {
+ width: 1.2em;
+ height: 1.2em;
+ margin-top: 0.15em;
+ transition: background-color 0.2s;
+ }
+ .form-check {
+ padding-block: 2px;
+ align-items: center;
+ .form-check-label {
+ margin-left: 12px;
+ }
+ }
+ }
+
+ button.btn-primary {
+ border-color: var(--peterportal-primary-color-1);
+ background-color: var(--peterportal-primary-color-1);
+ }
+}
+
+[data-theme='dark'] {
+ .add-year-form {
+ .form-control,
+ .form-control:focus {
+ background-color: var(--overlay1);
+ }
+ }
+}
+
+.add-year-form-label {
+ font-weight: bold;
+}
+
+.form-check-input {
+ --bs-form-check-bg: var(--overlay1);
+ -webkit-appearance: none;
+ appearance: none;
+ background-color: var(--bs-form-check-bg);
+ background-image: var(--bs-form-check-bg-image);
+ background-position: 50%;
+ background-repeat: no-repeat;
+ background-size: contain;
+ border: 1px solid #8888;
+ border-radius: 0.25rem;
+ width: 1em;
+ height: 1em;
+ margin-top: 0.25em;
+ transition: box-shadow 0.2s;
+ &:focus {
+ border-color: #86b7fe;
+ box-shadow: 0 0 0 0.25rem #0d6efd40;
+ outline: 0;
+ }
+ &:checked {
+ background-color: var(--peterportal-primary-color-1);
+ border-color: var(--peterportal-primary-color-1);
+ &[type='checkbox'] {
+ --bs-form-check-bg-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3E%3C/svg%3E");
+ }
+ }
+}
diff --git a/site/src/pages/RoadmapPage/YearModal.tsx b/site/src/pages/RoadmapPage/YearModal.tsx
new file mode 100644
index 00000000..72184e30
--- /dev/null
+++ b/site/src/pages/RoadmapPage/YearModal.tsx
@@ -0,0 +1,155 @@
+import React, { FC, useState } from 'react';
+import { Button, Form, Modal } from 'react-bootstrap';
+import { PlannerYearData } from '../../types/types';
+import './YearModal.scss';
+
+interface YearPopupQuarter {
+ id: string;
+ name: string;
+ checked?: boolean;
+}
+
+interface YearModalProps {
+ placeholderName: string;
+ placeholderYear: number;
+ show: boolean;
+ setShow: React.Dispatch>;
+ type: 'add' | 'edit';
+ saveHandler: (x: PlannerYearData) => void;
+ currentQuarters: string[];
+}
+
+const quarterValues: (selectedQuarters: string[]) => YearPopupQuarter[] = (quarterIds: string[]) => {
+ const base: YearPopupQuarter[] = [
+ { id: 'fall', name: 'Fall' },
+ { id: 'winter', name: 'Winter' },
+ { id: 'spring', name: 'Spring' },
+ { id: 'summer I', name: 'Summer I' },
+ { id: 'summer II', name: 'Summer II' },
+ { id: 'summer 10 Week', name: 'Summer 10 Week' },
+ ];
+ quarterIds.forEach((id) => {
+ const quarter = base.find((q) => q.id === id)!;
+ quarter.checked = true;
+ });
+ return base;
+};
+
+const YearModal: FC = (props) => {
+ const { placeholderName, placeholderYear, show, setShow, type, saveHandler, currentQuarters } = props;
+ const [validated, setValidated] = useState(false);
+
+ const [name, setName] = useState(placeholderName);
+ const [year, setYear] = useState(placeholderYear);
+
+ const [quarters, setQuarters] = useState(quarterValues(currentQuarters));
+ const quarterCheckboxes = quarters.map((q, i) => {
+ const handleClick = (i: number) => {
+ const newQuarters = quarters.slice();
+ newQuarters[i].checked = !newQuarters[i].checked;
+ setQuarters(newQuarters);
+ };
+ return (
+ handleClick(i)}
+ />
+ );
+ });
+
+ const title = type === 'add' ? 'Add Year' : `Editing "${placeholderName}"`;
+
+ const resetForm = () => {
+ setName(placeholderName);
+ setYear(placeholderYear);
+ setQuarters(quarterValues(currentQuarters));
+ };
+
+ const handleHide = () => {
+ resetForm();
+ setShow(false);
+ };
+
+ return (
+
+
+ {title}
+
+
+
+ Name
+ setName(e.target.value)}
+ onKeyDown={(e: React.KeyboardEvent) => {
+ // prevent submitting form (reloads the page)
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ }
+ }}
+ maxLength={35}
+ placeholder={placeholderName}
+ >
+
+
+ Start Year
+ {
+ setYear(parseInt(e.target.value));
+ }}
+ onKeyDown={(e: React.KeyboardEvent) => {
+ // prevent submitting form (reloads the page)
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ }
+ }}
+ min={1000}
+ max={9999}
+ placeholder={placeholderYear.toString()}
+ >
+
+
+ Include Quarters
+ {quarterCheckboxes}
+
+
+
+
+
+ );
+};
+
+export default YearModal;
diff --git a/site/src/store/slices/roadmapSlice.ts b/site/src/store/slices/roadmapSlice.ts
index 5941b9c9..2d229df6 100644
--- a/site/src/store/slices/roadmapSlice.ts
+++ b/site/src/store/slices/roadmapSlice.ts
@@ -11,6 +11,7 @@ import {
TransferData,
PlannerQuarterData,
} from '../../types/types';
+import { defaultYear } from '../../helpers/planner';
// Define a type for the slice state
interface RoadmapState {
@@ -28,17 +29,20 @@ interface RoadmapState {
showSearch: boolean;
// Whether or not to show the add course modal on mobile
showAddCourse: boolean;
+ // Whether or not to alert the user of unsaved changes before leaving
+ unsavedChanges: boolean;
}
// Define the initial state using that type
const initialState: RoadmapState = {
- yearPlans: [],
+ yearPlans: [defaultYear()],
activeCourse: null!,
invalidCourses: [],
showTransfer: false,
transfers: [],
showSearch: false,
showAddCourse: false,
+ unsavedChanges: false,
};
// Payload to pass in to move a course
@@ -76,6 +80,9 @@ interface SetTransferPayload {
transfer: TransferData;
}
+// onbeforeunload event listener
+const alertUnsaved = (event: BeforeUnloadEvent) => event.preventDefault();
+
export const roadmapSlice = createSlice({
name: 'roadmap',
// `createSlice` will infer the state type from the `initialState` argument
@@ -262,6 +269,17 @@ export const roadmapSlice = createSlice({
setShowAddCourse: (state, action: PayloadAction) => {
state.showAddCourse = action.payload;
},
+ setUnsavedChanges: (state, action: PayloadAction) => {
+ state.unsavedChanges = action.payload;
+
+ // when there are unsaved changes, add event listener for alert on page leave
+ if (state.unsavedChanges) {
+ window.addEventListener('beforeunload', alertUnsaved);
+ } else {
+ // remove listener after saving changes
+ window.removeEventListener('beforeunload', alertUnsaved);
+ }
+ },
},
});
@@ -287,6 +305,7 @@ export const {
deleteTransfer,
setShowSearch,
setShowAddCourse,
+ setUnsavedChanges,
} = roadmapSlice.actions;
// Other code such as selectors can use the imported `RootState` type
diff --git a/site/src/store/slices/searchSlice.ts b/site/src/store/slices/searchSlice.ts
index 55aa280c..005358d9 100644
--- a/site/src/store/slices/searchSlice.ts
+++ b/site/src/store/slices/searchSlice.ts
@@ -5,6 +5,8 @@ interface SearchData {
names: string[];
pageNumber: number;
results: CourseGQLData[] | ProfessorGQLData[];
+ hasFullResults: boolean;
+ lastQuery: string;
}
// Define a type for the slice state
@@ -19,11 +21,15 @@ const initialState: SearchState = {
names: [],
pageNumber: 0,
results: [],
+ hasFullResults: false,
+ lastQuery: '',
},
professors: {
names: [],
pageNumber: 0,
results: [],
+ hasFullResults: false,
+ lastQuery: '',
},
};
@@ -42,9 +48,18 @@ export const searchSlice = createSlice({
setResults: (state, action: PayloadAction<{ index: SearchIndex; results: SearchData['results'] }>) => {
state[action.payload.index].results = action.payload.results;
},
+ setHasFullResults: (
+ state,
+ action: PayloadAction<{ index: SearchIndex; hasFullResults: SearchData['hasFullResults'] }>,
+ ) => {
+ state[action.payload.index].hasFullResults = action.payload.hasFullResults;
+ },
+ setLastQuery: (state, action: PayloadAction<{ index: SearchIndex; lastQuery: string }>) => {
+ state[action.payload.index].lastQuery = action.payload.lastQuery;
+ },
},
});
-export const { setNames, setPageNumber, setResults } = searchSlice.actions;
+export const { setNames, setPageNumber, setResults, setHasFullResults, setLastQuery } = searchSlice.actions;
export default searchSlice.reducer;
diff --git a/site/src/style/theme.scss b/site/src/style/theme.scss
index 40f27d77..c822925d 100644
--- a/site/src/style/theme.scss
+++ b/site/src/style/theme.scss
@@ -85,6 +85,27 @@
color: var(--text);
text-shadow: none;
}
+
+ .page-link {
+ background-color: var(--overlay1);
+ border-color: var(--overlay2);
+
+ &:hover,
+ &:focus {
+ background-color: var(--overlay2);
+ color: #1284ff;
+ }
+ }
+
+ .page-item.active .page-link:hover {
+ background-color: #1284ff;
+ color: #fff;
+ }
+
+ .page-item.disabled .page-link {
+ background-color: var(--overlay1);
+ border-color: var(--overlay2);
+ }
}
.popover-body {