diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index c9d4ab12..94dd50c7 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -12,23 +12,29 @@ jobs: build_and_deploy: name: Build and deploy PeterPortal runs-on: ubuntu-latest + if: (github.event_name != 'pull_request' || !contains(github.event.pull_request.labels.*.name, 'no deploy')) + environment: + name: ${{ (github.event_name == 'pull_request' && format('staging-{0}', github.event.pull_request.number)) || 'production' }} + url: https://${{ (github.event_name == 'pull_request' && format('staging-{0}.peterportal.org', github.event.pull_request.number)) || 'peterportal.org' }} concurrency: - group: build-${{ github.head_ref || github.run_id }} + group: build-and-deploy-${{ github.head_ref || github.run_id }} cancel-in-progress: true steps: - name: Check Out Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install Dependencies run: npm install + env: + HUSKY: 0 - name: Build and deploy run: npx sst deploy --stage ${{ (github.event_name == 'pull_request' && format('staging-{0}', github.event.pull_request.number)) || 'prod' }} @@ -46,11 +52,3 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} NODE_ENV: ${{ github.event_name == 'pull_request' && 'staging' || 'production' }} - - - name: Comment staging URL - uses: marocchino/sticky-pull-request-comment@v2 - if: ${{ github.event_name == 'pull_request' }} - with: - header: staging url - recreate: true - message: Deployed staging instance to https://staging-${{ github.event.pull_request.number }}.peterportal.org diff --git a/.github/workflows/clean-up-pr.yml b/.github/workflows/clean-up-pr.yml index 5658d377..60038cf2 100644 --- a/.github/workflows/clean-up-pr.yml +++ b/.github/workflows/clean-up-pr.yml @@ -7,7 +7,7 @@ on: - master concurrency: - group: deploy-${{ github.head_ref || github.run_id }} + group: clean-up-pr-${{ github.head_ref || github.run_id }} jobs: clean-up-pr: @@ -18,16 +18,18 @@ jobs: steps: - name: Check Out Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install Dependencies run: npm install + env: + HUSKY: 0 - name: Remove staging stack run: npx sst remove --stage ${{ format('staging-{0}', github.event.pull_request.number) }} backend && npx sst remove --stage ${{ format('staging-{0}', github.event.pull_request.number) }} frontend @@ -44,3 +46,10 @@ jobs: PRODUCTION_DOMAIN: ${{secrets.PRODUCTION_DOMAIN}} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Deactivate deployment + uses: strumwolf/delete-deployment-environment@v3.0.0 + with: + environment: ${{ format('staging-{0}', github.event.pull_request.number) }} + token: ${{ secrets.GITHUB_TOKEN }} + onlyDeactivateDeployments: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index aef8fe65..24a5b211 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,16 +16,18 @@ jobs: steps: - name: Check Out Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install Dependencies run: npm install + env: + HUSKY: 0 - name: Lint run: npm run lint diff --git a/site/.eslintrc.cjs b/site/.eslintrc.cjs index baaa6cfd..5cd6ef2b 100644 --- a/site/.eslintrc.cjs +++ b/site/.eslintrc.cjs @@ -14,6 +14,11 @@ module.exports = { rules: { 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 'react/no-unescaped-entities': 'off', - 'no-console': 'warn', + 'no-console': ['warn', { allow: ['warn', 'error'] }], + }, + settings: { + react: { + version: 'detect', + }, }, }; diff --git a/site/src/asset/arrow.svg b/site/src/asset/arrow.svg deleted file mode 100644 index 9f371be5..00000000 --- a/site/src/asset/arrow.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/site/src/asset/bell.svg b/site/src/asset/bell.svg deleted file mode 100644 index ce764118..00000000 --- a/site/src/asset/bell.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/site/src/asset/cog.svg b/site/src/asset/cog.svg deleted file mode 100644 index 27c5c970..00000000 --- a/site/src/asset/cog.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/site/src/asset/plus.svg b/site/src/asset/plus.svg deleted file mode 100644 index 1056b27c..00000000 --- a/site/src/asset/plus.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/site/src/component/GradeDist/Pie.tsx b/site/src/component/GradeDist/Pie.tsx index 2520d279..9e189571 100644 --- a/site/src/component/GradeDist/Pie.tsx +++ b/site/src/component/GradeDist/Pie.tsx @@ -187,6 +187,7 @@ export default class Pie extends React.Component {
; ProfessorHitItem?: FC; } +const SearchResults = ({ + index, + results, + CourseHitItem, + ProfessorHitItem, +}: Required & { results: CourseGQLData[] | ProfessorGQLData[] }) => { + if (index === 'courses') { + return (results as CourseGQLData[]).map((course, i) => ); + } else { + return (results as ProfessorGQLData[]).map((professor, i) => ( + + )); + } +}; + const SearchHitContainer: FC = ({ index, CourseHitItem, ProfessorHitItem }) => { - const courseResults = useAppSelector((state) => state.search.courses.results); - const professorResults = useAppSelector((state) => state.search.professors.results); + const { names, results } = useAppSelector((state) => state.search[index]); const containerDivRef = useRef(null); + const isFirstRender = useFirstRender(); useEffect(() => { containerDivRef.current!.scrollTop = 0; - }, [courseResults, professorResults]); + }, [results]); if (index == 'professors' && !ProfessorHitItem) { throw 'Professor Component not provided'; } + /** + * if its first render, we are waiting for initial results + * if names is non-empty but results is empty, we are waiting for results + * otherwise, if results is still empty, we have no results for the search + */ + const noResults = results.length === 0 && !(isFirstRender || names.length > 0); + return (
- {index == 'courses' && ( - <> - {courseResults.map((course, i) => { - return ; - })} - + {noResults && ( +
+ No results found + Sorry, we couldn't find any results for that search! +
)} - {index == 'professors' && ProfessorHitItem && ( - <> - {professorResults.map((professor, i) => { - return ; - })} - + {results.length > 0 && ( + )} +
+ +
); }; diff --git a/site/src/component/SearchModule/SearchModule.tsx b/site/src/component/SearchModule/SearchModule.tsx index 2405eb35..b7eb7f9c 100644 --- a/site/src/component/SearchModule/SearchModule.tsx +++ b/site/src/component/SearchModule/SearchModule.tsx @@ -1,17 +1,19 @@ -import { FC, useEffect } from 'react'; -import { Search } from 'react-bootstrap-icons'; +import { useState, useEffect, FC, useCallback } from 'react'; +import './SearchModule.scss'; +import wfs from 'websoc-fuzzy-search'; import Form from 'react-bootstrap/Form'; import InputGroup from 'react-bootstrap/InputGroup'; -import wfs from 'websoc-fuzzy-search'; -import './SearchModule.scss'; +import { Search } from 'react-bootstrap-icons'; -import { searchAPIResults } from '../../helpers/util'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { setNames, setResults } from '../../store/slices/searchSlice'; +import { setHasFullResults, setLastQuery, setNames, setPageNumber, setResults } from '../../store/slices/searchSlice'; +import { searchAPIResults } from '../../helpers/util'; import { SearchIndex } from '../../types/types'; +import { NUM_RESULTS_PER_PAGE } from '../../helpers/constants'; -const PAGE_SIZE = 10; -const SEARCH_TIMEOUT_MS = 500; +const SEARCH_TIMEOUT_MS = 300; +const FULL_RESULT_THRESHOLD = 3; +const INITIAL_MAX_PAGE = 5; interface SearchModuleProps { index: SearchIndex; @@ -19,36 +21,22 @@ interface SearchModuleProps { const SearchModule: FC = ({ index }) => { const dispatch = useAppDispatch(); - const courseSearch = useAppSelector((state) => state.search.courses); - const professorSearch = useAppSelector((state) => state.search.professors); - let pendingRequest: NodeJS.Timeout | null = null; - - // Search empty string to load some results - useEffect(() => { - searchNames(''); - }, [index]); - - // Refresh search results when names and page number changes - useEffect(() => { - searchResults('courses', courseSearch.pageNumber, courseSearch.names); - }, [courseSearch.names, courseSearch.pageNumber]); - useEffect(() => { - searchResults('professors', professorSearch.pageNumber, professorSearch.names); - }, [professorSearch.names, professorSearch.pageNumber]); + const search = useAppSelector((state) => state.search[index]); + const [pendingRequest, setPendingRequest] = useState(null); + const [prevIndex, setPrevIndex] = useState(null); - const searchNames = (query: string) => { - try { - /* - TODO: Search optimization - - Currently sending a query request for every input change - - Goal is to have only one query request pending - - Use setTimeout/clearTimeout to keep track of pending query request - */ + const searchNames = useCallback( + (query: string, pageNumber: number, lastQuery?: string) => { + // Get all results only when query changes or user reaches the fourth page or after const nameResults = wfs({ query: query, - numResults: PAGE_SIZE * 5, resultType: index === 'courses' ? 'COURSE' : 'INSTRUCTOR', - filterOptions: {}, + // Load INITIAL_MAX_PAGE pages first + // when user reaches the 4th page or after, load all results + numResults: + lastQuery !== query || pageNumber < FULL_RESULT_THRESHOLD + ? NUM_RESULTS_PER_PAGE * INITIAL_MAX_PAGE + : undefined, }); let names: string[] = []; if (index === 'courses') { @@ -63,29 +51,68 @@ const SearchModule: FC = ({ index }) => { ).ucinetid, ) as string[]; } - console.log('From frontend search', names); dispatch(setNames({ index, names })); - } catch (e) { - console.log(e); - } - }; + // reset page number and hasFullResults flag if query changes + if (query !== lastQuery) { + dispatch(setPageNumber({ index, pageNumber: 0 })); + dispatch(setHasFullResults({ index, hasFullResults: false })); + dispatch(setLastQuery({ index, lastQuery: query })); + } + }, + [dispatch, index], + ); + + // Search empty string to load some results on intial visit/when switching between courses and professors tabs + // make sure this runs before everything else for best performance and avoiding bugs + if (index !== prevIndex) { + setPrevIndex(index); + searchNames('', 0); + } - const searchResults = async (index: SearchIndex, pageNumber: number, names: string[]) => { + const searchResults = useCallback(async () => { + if (search.names.length === 0) { + dispatch(setResults({ index, results: [] })); + return; + } + if (!search.hasFullResults && search.pageNumber >= FULL_RESULT_THRESHOLD) { + dispatch(setHasFullResults({ index, hasFullResults: true })); + searchNames(search.lastQuery, search.pageNumber, search.lastQuery); + return; + } // Get the subset of names based on the page - const pageNames = names.slice(PAGE_SIZE * pageNumber, PAGE_SIZE * (pageNumber + 1)); + const pageNames = search.names.slice( + NUM_RESULTS_PER_PAGE * search.pageNumber, + NUM_RESULTS_PER_PAGE * (search.pageNumber + 1), + ); const results = await searchAPIResults(index, pageNames); dispatch(setResults({ index, results: Object.values(results) })); - }; + }, [dispatch, search.names, search.pageNumber, index, search.hasFullResults, search.lastQuery, searchNames]); + + // clear results and reset page number when component unmounts + // results will persist otherwise, e.g. current page of results from catalogue carries over to roadmap search container + useEffect(() => { + return () => { + dispatch(setPageNumber({ index: 'courses', pageNumber: 0 })); + dispatch(setPageNumber({ index: 'professors', pageNumber: 0 })); + dispatch(setResults({ index: 'courses', results: [] })); + dispatch(setResults({ index: 'professors', results: [] })); + }; + }, [dispatch]); + + // Refresh search results when names and page number changes (controlled by searchResults dependency array) + useEffect(() => { + searchResults(); + }, [index, searchResults]); const searchNamesAfterTimeout = (query: string) => { if (pendingRequest) { clearTimeout(pendingRequest); } const timeout = setTimeout(() => { - searchNames(query); - pendingRequest = null; + searchNames(query, 0); + setPendingRequest(null); }, SEARCH_TIMEOUT_MS); - pendingRequest = timeout; + setPendingRequest(timeout); }; const coursePlaceholder = 'Search a course number or department'; diff --git a/site/src/component/SearchPagination/SearchPagination.tsx b/site/src/component/SearchPagination/SearchPagination.tsx new file mode 100644 index 00000000..00e55eb2 --- /dev/null +++ b/site/src/component/SearchPagination/SearchPagination.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react'; +import { Pagination } from 'react-bootstrap'; +import { NUM_RESULTS_PER_PAGE } from '../../helpers/constants'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { setPageNumber } from '../../store/slices/searchSlice'; +import { SearchIndex } from '../../types/types'; + +interface SearchPaginationProps { + index: SearchIndex; +} + +/* SearchPagination is the page buttons at the bottom of the search results */ +const SearchPagination: FC = ({ index }) => { + const dispatch = useAppDispatch(); + const searchData = useAppSelector((state) => state.search[index]); + + const clickPageNumber = (pageNumber: number) => { + dispatch(setPageNumber({ index, pageNumber })); + }; + + const numPages = Math.ceil(searchData.names.length / NUM_RESULTS_PER_PAGE); + const activePage = searchData.pageNumber; + + // only show 5 page numbers at a time + const items = []; + let startPageNumber = Math.max(0, activePage - 2); + const endPageNumber = Math.min(numPages, startPageNumber + 5); // exclusive + startPageNumber = Math.max(0, endPageNumber - 5); + for (let i = startPageNumber; i < endPageNumber; i++) { + items.push( + clickPageNumber(i)}> + {i + 1} + , + ); + } + + return ( + // hide if there is no page or only one page + // last button intentionally left out since first 5 pages are fuzzy searched initially (we don't know what the last page # is) + numPages <= 1 ? null : ( + + clickPageNumber(0)} disabled={activePage === 0} /> + clickPageNumber(activePage - 1)} disabled={activePage === 0} /> + {items} + clickPageNumber(activePage + 1)} disabled={activePage === numPages - 1} /> + + ) + ); +}; + +export default SearchPagination; diff --git a/site/src/helpers/constants.ts b/site/src/helpers/constants.ts new file mode 100644 index 00000000..c6403e41 --- /dev/null +++ b/site/src/helpers/constants.ts @@ -0,0 +1,4 @@ +// SearchPage +// Defined in the constants file because it is used in both +// SearchModule (the search bar) and SearchPagination (the pagination buttons) +export const NUM_RESULTS_PER_PAGE = 10; diff --git a/site/src/helpers/planner.ts b/site/src/helpers/planner.ts new file mode 100644 index 00000000..91b1711a --- /dev/null +++ b/site/src/helpers/planner.ts @@ -0,0 +1,11 @@ +import { PlannerYearData, SavedPlannerYearData } from '../types/types'; + +export function defaultYear() { + return { + startYear: new Date().getFullYear(), + name: 'Year 1', + quarters: ['fall', 'winter', 'spring'].map((quarter) => { + return { name: quarter, courses: [] }; + }), + } as PlannerYearData | SavedPlannerYearData; +} diff --git a/site/src/helpers/util.tsx b/site/src/helpers/util.tsx index f480f8b1..2ce32f5d 100644 --- a/site/src/helpers/util.tsx +++ b/site/src/helpers/util.tsx @@ -77,7 +77,6 @@ export async function searchAPIResults( transformed[key] = transformGQLData(index, data[id]); } } - console.log('From backend search', transformed); return transformed; } diff --git a/site/src/pages/RoadmapPage/AddYearPopup.scss b/site/src/pages/RoadmapPage/AddYearPopup.scss index 0332bbb0..6f032a92 100644 --- a/site/src/pages/RoadmapPage/AddYearPopup.scss +++ b/site/src/pages/RoadmapPage/AddYearPopup.scss @@ -1,29 +1,21 @@ -[data-theme='dark'] { - .add-year-form { - .form-control, - .form-control:focus { - background-color: var(--overlay1); - } - } -} - .add-year-btn { - background-color: transparent; - color: var(--petr-gray); + border-color: var(--peterportal-primary-color-1); + background-color: var(--peterportal-primary-color-1); + color: var(--ring-road-white); border: none; - display: block; + display: flex; + align-items: center; margin: 0.5rem; } .add-year-btn:hover, -.add-year-btn:focus, -.add-year-btn:active { - background-color: transparent; - color: var(--petr-gray); +.add-year-btn:focus { + background-color: var(--peterportal-primary-color-1); } .add-year-icon { - margin-right: 10px; + margin-right: 4px; + transform: scale(1.5); } .add-year-text { @@ -31,17 +23,9 @@ vertical-align: middle; } -.add-year-form-label { - font-weight: bold; -} - .popup-btn { text-transform: uppercase; background-color: var(--peterportal-primary-color-1); color: var(--ring-road-white); border-radius: 1.25rem; } - -h5 { - font-weight: bold; -} diff --git a/site/src/pages/RoadmapPage/AddYearPopup.tsx b/site/src/pages/RoadmapPage/AddYearPopup.tsx index b216d809..38f7bccc 100644 --- a/site/src/pages/RoadmapPage/AddYearPopup.tsx +++ b/site/src/pages/RoadmapPage/AddYearPopup.tsx @@ -1,9 +1,11 @@ -import React, { FC, useState, useEffect } from 'react'; +import { FC, useState } from 'react'; import './AddYearPopup.scss'; -import { PlusCircleFill } from 'react-bootstrap-icons'; -import { Button, Form, Popover, OverlayTrigger } from 'react-bootstrap'; +import { Plus } from 'react-bootstrap-icons'; +import { Button } from 'react-bootstrap'; +import YearModal from './YearModal'; import { addYear } from '../../store/slices/roadmapSlice'; import { useAppDispatch } from '../../store/hooks'; +import { PlannerYearData } from '../../types/types'; interface AddYearPopupProps { placeholderName: string; @@ -11,102 +13,28 @@ interface AddYearPopupProps { } const AddYearPopup: FC = ({ placeholderName, placeholderYear }) => { - const dispatch = useAppDispatch(); - const [name, setName] = useState(placeholderName); - const [year, setYear] = useState(placeholderYear); - const [validated, setValidated] = useState(false); - const [show, setShow] = useState(false); - - useEffect(() => { - setYear(placeholderYear); - setName(placeholderName); - }, [placeholderYear, placeholderName]); - - const overlay = ( - - -
- - 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()} - > - - -
-
-
- ); + const dispatch = useAppDispatch(); + const saveHandler = (yearData: PlannerYearData) => dispatch(addYear({ yearData })); return (
- - - + +
); }; diff --git a/site/src/pages/RoadmapPage/Planner.tsx b/site/src/pages/RoadmapPage/Planner.tsx index 7534ff65..eabeb995 100644 --- a/site/src/pages/RoadmapPage/Planner.tsx +++ b/site/src/pages/RoadmapPage/Planner.tsx @@ -11,7 +11,7 @@ import { setYearPlans, setInvalidCourses, setTransfers, - addYear, + setUnsavedChanges, } from '../../store/slices/roadmapSlice'; import { useFirstRender } from '../../hooks/firstRenderer'; import { @@ -28,6 +28,7 @@ import { } from '../../types/types'; import { searchAPIResults } from '../../helpers/util'; import { Prerequisite, PrerequisiteTree } from 'peterportal-api-next-types'; +import { defaultYear } from '../../helpers/planner'; const Planner: FC = () => { const dispatch = useAppDispatch(); @@ -39,13 +40,29 @@ const Planner: FC = () => { const [missingPrerequisites, setMissingPrerequisites] = useState(new Set()); useEffect(() => { - // if is first render, load from local storage - if (isFirstRenderer) { + // stringify current roadmap + const roadmapStr = JSON.stringify({ + planner: collapsePlanner(data), + transfers: transfers, + }); + + // stringified value of an empty roadmap + const emptyRoadmap = JSON.stringify({ + planner: [defaultYear()], + transfers: [], + } as SavedRoadmap); + + // if first render and current roadmap is empty, load from local storage + if (isFirstRenderer && roadmapStr === emptyRoadmap) { loadRoadmap(); } // validate planner every time something changes else { validatePlanner(); + + // check current roadmap against last-saved roadmap in local storage + // if they are different, mark changes as unsaved to enable alert on page leave + dispatch(setUnsavedChanges(localStorage.getItem('roadmap') !== roadmapStr)); } }, [data, transfers]); @@ -142,6 +159,9 @@ const Planner: FC = () => { // save to local storage as well localStorage.setItem('roadmap', JSON.stringify(roadmap)); + // mark changes as saved to bypass alert on page leave + dispatch(setUnsavedChanges(false)); + if (savedAccount) { alert(`Roadmap saved under ${cookies.user.email}`); } else { @@ -269,28 +289,7 @@ const Planner: FC = () => { } } }; - //TODO: Support for Multiple Planner future implementation - // - Default year only added when a new planner is created - - const initializePlanner = () => { - if (data.length == 0) { - dispatch( - addYear({ - yearData: { - startYear: new Date().getFullYear(), - name: 'Year 1', - quarters: ['fall', 'winter', 'spring'].map((quarter) => { - return { name: quarter, courses: [] }; - }), - }, - }), - ); - } - return data.map((year, yearIndex) => { - return ; - }); - }; const { unitCount, courseCount } = calculatePlannerOverviewStats(); return ( @@ -301,7 +300,11 @@ const Planner: FC = () => { saveRoadmap={saveRoadmap} missingPrerequisites={missingPrerequisites} /> -
{initializePlanner()}
+
+ {data.map((year, yearIndex) => { + return ; + })} +
= ({ yearIndex, data }) => { const dispatch = useAppDispatch(); const [showContent, setShowContent] = useState(true); const [show, setShow] = useState(false); - const [showAddQuarter, setShowAddQuarter] = useState(false); const [showEditYear, setShowEditYear] = useState(false); - const [threeDotMenuTarget, setThreeDotMenuTarget] = useState(null); - const [addQuarterTarget, setAddQuarterTarget] = useState(null); - const [editYearTarget, setEditYearTarget] = useState(null!); const [placeholderYear, setPlaceholderYear] = useState(data.startYear); const [placeholderName, setPlaceholderName] = useState(data.name); - const [validated, setValidated] = useState(false); const { darkMode } = useContext(ThemeContext); const buttonVariant = darkMode ? 'dark' : 'light'; - const handleEditClick = (event: React.MouseEvent) => { - if (showAddQuarter) { - /* hide both overlays */ - setShowAddQuarter(!showAddQuarter); - setShow(!show); - } else if (showEditYear) { - setShowEditYear(!showEditYear); - setShow(!show); - } else { - setShow(!show); - setThreeDotMenuTarget(event.target as HTMLElement); - } - }; - - const handleShowAddQuarterClick = (event: React.MouseEvent) => { - setShowEditYear(false); // hide any other currently displayed menu bar options - setShowAddQuarter(!showAddQuarter); - setAddQuarterTarget(event.target as HTMLElement); - }; - - const handleAddQuarterClick = (year: number, quarter: string) => { - dispatch(addQuarter({ startYear: year, quarterData: { name: quarter, courses: [] } })); - }; - - const handleEditYearClick = (event: React.MouseEvent) => { - setShowAddQuarter(false); // hide any other currently displayed menu bar options + const handleEditYearClick = (/* event: React.MouseEvent */) => { setPlaceholderYear(data.startYear); // set default year to current year setPlaceholderName(data.name); - setShowEditYear(!showEditYear); - setEditYearTarget(event.target as HTMLElement); + setShowEditYear(true); + setShow(false); // when opening the modal, close the options menu }; const calculateYearStats = () => { @@ -75,6 +46,46 @@ const Year: FC = ({ yearIndex, data }) => { const { unitCount, courseCount } = calculateYearStats(); + const editYearOverlay = ( + + +
+ + + +
+
+
+ ); + return (
@@ -103,196 +114,48 @@ const Year: FC = ({ yearIndex, data }) => { - - - - -
- - - - -
-
-
-
- - - -
- {!data.quarters.map((quarter) => quarter.name).includes('fall') && ( - - )} - {!data.quarters.map((quarter) => quarter.name).includes('winter') && ( - - )} - {!data.quarters.map((quarter) => quarter.name).includes('spring') && ( - - )} - {!data.quarters.map((quarter) => quarter.name).includes('summer I') && ( - - )} - {!data.quarters.map((quarter) => quarter.name).includes('summer II') && ( - - )} - {!data.quarters.map((quarter) => quarter.name).includes('summer 10 Week') && ( - - )} -
-
-
-
- - - -
- - Name - { - setPlaceholderName(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 - { - setPlaceholderYear(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()} - > - - -
-
-
-
+ + + + { + 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 {