From 5086bb22c910b67229b56d883fc3a06580045a3f Mon Sep 17 00:00:00 2001 From: Hailey Ho Date: Sun, 22 Jan 2023 17:01:58 -0500 Subject: [PATCH 01/87] Enable preview per PR --- .github/workflows/deploy.yaml | 1 + .github/workflows/preview.yaml | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 .github/workflows/preview.yaml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index b1b047b3..ac64d38f 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -53,3 +53,4 @@ jobs: BRANCH: gh-pages FOLDER: build CLEAN: true + CLEAN-EXCLUDE: pr-preview diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml new file mode 100644 index 00000000..335025cb --- /dev/null +++ b/.github/workflows/preview.yaml @@ -0,0 +1,41 @@ +name: Deploy PR Previews +on: + pull-request: + +concurrnecy: preview-${{ github.ref }} + +jobs: + deploy-preview: + name: Deploy PR Preview + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + # Fetch all history for Sentry to properly create the release + fetch-depth: 0 + + - name: Install + run: yarn install --frozen-lockfile + + - name: Build + run: yarn build + env: + CI: 'true' + # None of these values are actually secret; + # they all get included in the final bundle. + REACT_APP_MAPBOX_TOKEN: ${{ secrets.REACT_APP_MAPBOX_TOKEN }} + REACT_APP_SENTRY_VERSION: ${{ github.sha }} + REACT_APP_FIREBASE_API_KEY: ${{ secrets.REACT_APP_FIREBASE_API_KEY }} + REACT_APP_FIREBASE_AUTH_DOMAIN: ${{ secrets.REACT_APP_FIREBASE_AUTH_DOMAIN }} + REACT_APP_FIREBASE_PROJECT_ID: ${{ secrets.REACT_APP_FIREBASE_PROJECT_ID }} + REACT_APP_FIREBASE_STORAGE_BUCKET: ${{ secrets.REACT_APP_FIREBASE_STORAGE_BUCKET }} + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.REACT_APP_FIREBASE_MESSAGING_SENDER_ID }} + REACT_APP_FIREBASE_APP_ID: ${{ secrets.REACT_APP_FIREBASE_APP_ID }} + REACT_APP_FIREBASE_MEASUREMENT_ID: ${{ secrets.REACT_APP_FIREBASE_MEASUREMENT_ID }} + + - name: Deploy preview + uses: rossjrw/pr-preview-action@v1 + with: + source-dir: build \ No newline at end of file From 383152c8a09be2b4ef8774ffb8950eefb383007d Mon Sep 17 00:00:00 2001 From: Hailey Ho Date: Sun, 22 Jan 2023 17:31:08 -0500 Subject: [PATCH 02/87] Migrate schedule data schema to version 3; fix deploy preview bugs --- .github/workflows/ci.yaml | 23 ++- .github/workflows/deploy.yaml | 2 +- .github/workflows/preview.yaml | 46 ----- src/data/firebase.ts | 8 +- src/data/hooks/useMigrateScheduleData.test.ts | 9 +- .../hooks/useRawScheduleDataFromStorage.ts | 4 +- src/data/migrations/2to3.test.ts | 162 ++++++++++++++++++ src/data/migrations/2to3.ts | 54 ++++++ src/data/migrations/index.test.ts | 11 +- src/data/migrations/index.ts | 11 +- src/data/types.ts | 47 ++++- src/types.ts | 7 + 12 files changed, 318 insertions(+), 66 deletions(-) delete mode 100644 .github/workflows/preview.yaml create mode 100644 src/data/migrations/2to3.test.ts create mode 100644 src/data/migrations/2to3.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1689b532..0392a3c8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,8 @@ name: Continuous Integration on: [pull_request] +concurrency: preview-${{ github.ref }} + jobs: lint: name: Lint @@ -23,7 +25,7 @@ jobs: build: - name: Build + name: Build & Stage runs-on: ubuntu-latest steps: - name: Checkout @@ -33,9 +35,24 @@ jobs: run: yarn install --frozen-lockfile - name: Build - run: yarn run build + run: yarn build env: CI: "true" + PRODUCTION: "false" + PUBLIC_URL: ${{ env.npm_package_homepage }}/pr-preview/pr-${{ github.event.number }}/ + REACT_APP_MAPBOX_TOKEN: ${{ secrets.REACT_APP_MAPBOX_TOKEN }} + REACT_APP_FIREBASE_API_KEY: ${{ secrets.REACT_APP_FIREBASE_API_KEY }} + REACT_APP_FIREBASE_AUTH_DOMAIN: ${{ secrets.REACT_APP_FIREBASE_AUTH_DOMAIN }} + REACT_APP_FIREBASE_PROJECT_ID: ${{ secrets.REACT_APP_FIREBASE_PROJECT_ID }} + REACT_APP_FIREBASE_STORAGE_BUCKET: ${{ secrets.REACT_APP_FIREBASE_STORAGE_BUCKET }} + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.REACT_APP_FIREBASE_MESSAGING_SENDER_ID }} + REACT_APP_FIREBASE_APP_ID: ${{ secrets.REACT_APP_FIREBASE_APP_ID }} + REACT_APP_FIREBASE_MEASUREMENT_ID: ${{ secrets.REACT_APP_FIREBASE_MEASUREMENT_ID }} + + - name: Stage + uses: rossjrw/pr-preview-action@v1 + with: + source-dir: ./build/ test: @@ -51,4 +68,4 @@ jobs: - name: Test run: yarn run test --coverage env: - CI: "true" + CI: "true" \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index ac64d38f..2f305f28 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -53,4 +53,4 @@ jobs: BRANCH: gh-pages FOLDER: build CLEAN: true - CLEAN-EXCLUDE: pr-preview + CLEAN-EXCLUDE: pr-preview/ diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml deleted file mode 100644 index 8f6f07c9..00000000 --- a/.github/workflows/preview.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy PR Previews -on: - pull_request: - types: - - opened - - reopened - - synchronize - - closed - -concurrency: preview-${{ github.ref }} - -jobs: - deploy-preview: - name: Deploy PR Preview - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - persist-credentials: false - # Fetch all history for Sentry to properly create the release - fetch-depth: 0 - - - name: Install - run: yarn install --frozen-lockfile - - - name: Build - run: yarn build - env: - CI: 'true' - # None of these values are actually secret; - # they all get included in the final bundle. - REACT_APP_MAPBOX_TOKEN: ${{ secrets.REACT_APP_MAPBOX_TOKEN }} - REACT_APP_SENTRY_VERSION: ${{ github.sha }} - REACT_APP_FIREBASE_API_KEY: ${{ secrets.REACT_APP_FIREBASE_API_KEY }} - REACT_APP_FIREBASE_AUTH_DOMAIN: ${{ secrets.REACT_APP_FIREBASE_AUTH_DOMAIN }} - REACT_APP_FIREBASE_PROJECT_ID: ${{ secrets.REACT_APP_FIREBASE_PROJECT_ID }} - REACT_APP_FIREBASE_STORAGE_BUCKET: ${{ secrets.REACT_APP_FIREBASE_STORAGE_BUCKET }} - REACT_APP_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.REACT_APP_FIREBASE_MESSAGING_SENDER_ID }} - REACT_APP_FIREBASE_APP_ID: ${{ secrets.REACT_APP_FIREBASE_APP_ID }} - REACT_APP_FIREBASE_MEASUREMENT_ID: ${{ secrets.REACT_APP_FIREBASE_MEASUREMENT_ID }} - - - name: Deploy preview - uses: rossjrw/pr-preview-action@v1 - with: - source-dir: build \ No newline at end of file diff --git a/src/data/firebase.ts b/src/data/firebase.ts index e60e0f19..29acb27a 100644 --- a/src/data/firebase.ts +++ b/src/data/firebase.ts @@ -17,6 +17,10 @@ const firebaseConfig = { measurementId: process.env['REACT_APP_FIREBASE_MEASUREMENT_ID'], }; +const SCHEDULE_COLLECTION = process.env['PRODUCTION'] + ? 'schedules' + : 'schedules-dev'; + /** * Whether Firebase authentication is enabled in this environment. * To enable, supply the 5 Firebase config environment variables. @@ -38,7 +42,9 @@ if (isAuthEnabled) { auth = app.auth(); db = app.firestore(); - schedulesCollection = db.collection('schedules') as SchedulesCollection; + schedulesCollection = db.collection( + SCHEDULE_COLLECTION + ) as SchedulesCollection; auth.setPersistence(firebase.auth.Auth.Persistence.LOCAL).catch((err) => { softError( diff --git a/src/data/hooks/useMigrateScheduleData.test.ts b/src/data/hooks/useMigrateScheduleData.test.ts index 2e1c9b80..7cb66cf4 100644 --- a/src/data/hooks/useMigrateScheduleData.test.ts +++ b/src/data/hooks/useMigrateScheduleData.test.ts @@ -94,6 +94,7 @@ describe('useMigrateScheduleData', () => { '94424', ], excludedCrns: ['95199'], + events: [], colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, @@ -101,7 +102,7 @@ describe('useMigrateScheduleData', () => { }, }, }, - version: 2, + version: 3, }; // The migrated data should have been passed to `setRawScheduleData` @@ -150,6 +151,7 @@ describe('useMigrateScheduleData', () => { '94424', ], excludedCrns: ['95199'], + events: [], colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, @@ -157,7 +159,7 @@ describe('useMigrateScheduleData', () => { }, }, }, - version: 2, + version: 3, }, }) ); @@ -180,6 +182,7 @@ describe('useMigrateScheduleData', () => { '94424', ], excludedCrns: ['95199'], + events: [], colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, @@ -187,7 +190,7 @@ describe('useMigrateScheduleData', () => { }, }, }, - version: 2, + version: 3, }; // The callback shouldn't have been invoked, diff --git a/src/data/hooks/useRawScheduleDataFromStorage.ts b/src/data/hooks/useRawScheduleDataFromStorage.ts index cd6d5068..e5c0aa40 100644 --- a/src/data/hooks/useRawScheduleDataFromStorage.ts +++ b/src/data/hooks/useRawScheduleDataFromStorage.ts @@ -6,7 +6,9 @@ import { renderDataNotPersistentNotification } from '../../components/DataNotPer import { LoadingState } from '../../types'; import { AnyScheduleData } from '../types'; -export const SCHEDULE_DATA_LOCAL_STORAGE_KEY = 'schedule-data'; +export const SCHEDULE_DATA_LOCAL_STORAGE_KEY = process.env['PRODUCTION'] + ? 'schedule-data' + : 'schedule-data-dev'; type HookResult = { rawScheduleData: Immutable | null; diff --git a/src/data/migrations/2to3.test.ts b/src/data/migrations/2to3.test.ts new file mode 100644 index 00000000..b30fc690 --- /dev/null +++ b/src/data/migrations/2to3.test.ts @@ -0,0 +1,162 @@ +import { asMockFunction } from '../../utils/tests'; +import * as dataTypes from '../types'; +import migrate2To3 from './2to3'; + +describe('migrate2to3', () => { + it('handles a migration with no term data', () => { + expect( + migrate2To3({ + version: 2, + terms: {}, + }) + ).toEqual({ + version: 3, + terms: {}, + }); + }); + + it('handles a migration with no schedule versions', () => { + expect( + migrate2To3({ + version: 2, + terms: { + 202008: { + versions: {}, + }, + }, + }) + ).toEqual({ + version: 3, + terms: { + 202008: { + versions: {}, + }, + }, + }); + }); + + it('handles a migration with multiple schedule versions', () => { + // Mock the current time to January 1, 1970 + jest.useFakeTimers().setSystemTime(new Date('1970-01-01').getTime()); + + // Mock the ID generation to be deterministic + let current_id = 0; + jest.spyOn(dataTypes, 'generateScheduleVersionId'); + asMockFunction(dataTypes.generateScheduleVersionId).mockImplementation( + () => { + const id = `sv_${String(current_id).padStart(20, '0')}`; + current_id += 1; + return id; + } + ); + + expect( + migrate2To3({ + version: 2, + terms: { + 202008: { + versions: { + sv_00000000000000000000: { + name: 'Primary', + // January 1, 1970 at 0 seconds + createdAt: '1970-01-01T00:00:00.000Z', + schedule: { + desiredCourses: ['CS 1100', 'CS 1331'], + pinnedCrns: [ + '87695', + '82294', + '88999', + '90769', + '89255', + '94424', + ], + excludedCrns: ['95199'], + colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, + sortingOptionIndex: 0, + }, + }, + sv_00000000000000000001: { + name: 'Secondary', + // January 1, 1970 at 1 second + createdAt: '1970-01-01T00:00:01.000Z', + schedule: { + desiredCourses: [], + pinnedCrns: [], + excludedCrns: [], + colorMap: {}, + sortingOptionIndex: 0, + }, + }, + sv_00000000000000000002: { + name: 'Tertiary', + // January 1, 1970 at 2 seconds + createdAt: '1970-01-01T00:00:02.000Z', + schedule: { + desiredCourses: [], + pinnedCrns: [], + excludedCrns: [], + colorMap: {}, + sortingOptionIndex: 0, + }, + }, + }, + }, + }, + }) + ).toEqual({ + version: 3, + terms: { + 202008: { + versions: { + sv_00000000000000000000: { + name: 'Primary', + // January 1, 1970 at 0 seconds + createdAt: '1970-01-01T00:00:00.000Z', + schedule: { + desiredCourses: ['CS 1100', 'CS 1331'], + pinnedCrns: [ + '87695', + '82294', + '88999', + '90769', + '89255', + '94424', + ], + excludedCrns: ['95199'], + events: [], + colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, + sortingOptionIndex: 0, + }, + }, + sv_00000000000000000001: { + name: 'Secondary', + // January 1, 1970 at 1 second + createdAt: '1970-01-01T00:00:01.000Z', + schedule: { + desiredCourses: [], + pinnedCrns: [], + excludedCrns: [], + events: [], + colorMap: {}, + sortingOptionIndex: 0, + }, + }, + sv_00000000000000000002: { + name: 'Tertiary', + // January 1, 1970 at 2 seconds + createdAt: '1970-01-01T00:00:02.000Z', + schedule: { + desiredCourses: [], + pinnedCrns: [], + excludedCrns: [], + events: [], + colorMap: {}, + sortingOptionIndex: 0, + }, + }, + }, + }, + }, + }); + }); +}); diff --git a/src/data/migrations/2to3.ts b/src/data/migrations/2to3.ts new file mode 100644 index 00000000..1e5f5e30 --- /dev/null +++ b/src/data/migrations/2to3.ts @@ -0,0 +1,54 @@ +import { + Version2ScheduleData, + Version3ScheduleData, + Version3ScheduleVersion, + Version3TermScheduleData, +} from '../types'; + +/** + * Migrates from version 1 to version 2 data, + * performing the main 3 operations: + * - generating random IDs for each schedule version + * - generating 'createdAt' fields for each schedule version + * - adding 'events' field + */ + +export default function migrate2To3( + version2: Version2ScheduleData +): Version3ScheduleData { + const newData: Version3ScheduleData = { + version: 3, + terms: {}, + }; + + Object.entries(version2.terms).forEach(([term, version2TermData]) => { + const version3TermData: Version3TermScheduleData = { + versions: {}, + }; + + // Create updated schedule versions + const newEntries = Object.entries(version2TermData.versions).map< + [string, Version3ScheduleVersion] + >(([id, version2ScheduleVersion]) => { + const newFields = { + events: [], + }; + + const version3ScheduleVersion: Version3ScheduleVersion = { + name: version2ScheduleVersion.name, + createdAt: version2ScheduleVersion.createdAt, + schedule: { + ...version2ScheduleVersion.schedule, + ...newFields, + }, + }; + + return [id, version3ScheduleVersion]; + }); + + version3TermData.versions = Object.fromEntries(newEntries); + newData.terms[term] = version3TermData; + }); + + return newData; +} diff --git a/src/data/migrations/index.test.ts b/src/data/migrations/index.test.ts index 0b8b8078..48c0684d 100644 --- a/src/data/migrations/index.test.ts +++ b/src/data/migrations/index.test.ts @@ -58,6 +58,7 @@ describe('migrateScheduleData', () => { '94424', ], excludedCrns: ['95199'], + events: [], colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, @@ -65,7 +66,7 @@ describe('migrateScheduleData', () => { }, }, }, - version: 2, + version: 3, }); }); @@ -126,6 +127,7 @@ describe('migrateScheduleData', () => { desiredCourses: [], pinnedCrns: [], excludedCrns: [], + events: [], colorMap: {}, sortingOptionIndex: 0, }, @@ -149,6 +151,7 @@ describe('migrateScheduleData', () => { '94424', ], excludedCrns: ['95199'], + events: [], colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, @@ -156,7 +159,7 @@ describe('migrateScheduleData', () => { }, }, }, - version: 2, + version: 3, }); }); @@ -215,6 +218,7 @@ describe('migrateScheduleData', () => { desiredCourses: [], pinnedCrns: [], excludedCrns: [], + events: [], colorMap: {}, sortingOptionIndex: 0, }, @@ -237,6 +241,7 @@ describe('migrateScheduleData', () => { '94424', ], excludedCrns: ['95199'], + events: [], colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, @@ -244,7 +249,7 @@ describe('migrateScheduleData', () => { }, }, }, - version: 2, + version: 3, }); }); }); diff --git a/src/data/migrations/index.ts b/src/data/migrations/index.ts index 3d125c3c..3a764590 100644 --- a/src/data/migrations/index.ts +++ b/src/data/migrations/index.ts @@ -6,8 +6,10 @@ import { ScheduleData, Version1ScheduleDataOrNewer, Version2ScheduleDataOrNewer, + Version3ScheduleDataOrNewer, } from '../types'; import migrate1To2 from './1to2'; +import migrate2To3 from './2to3'; import migrateCookiesTo1, { defaultVersion1ScheduleData } from './cookiesTo1'; /** @@ -48,5 +50,12 @@ export default function migrateScheduleData( scheduleDataVersion2OrNewer = scheduleDataVersion1OrNewer; } - return scheduleDataVersion2OrNewer; + let scheduleDataVersion3OrNewer: Version3ScheduleDataOrNewer; + if (scheduleDataVersion2OrNewer.version === 2) { + scheduleDataVersion3OrNewer = migrate2To3(scheduleDataVersion2OrNewer); + } else { + scheduleDataVersion3OrNewer = scheduleDataVersion2OrNewer; + } + + return scheduleDataVersion3OrNewer; } diff --git a/src/data/types.ts b/src/data/types.ts index 9d4d8ec4..6f955933 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -1,5 +1,6 @@ import { Immutable } from 'immer'; +import { Event } from '../types'; import { generateRandomId } from '../utils/misc'; // This file defines all of the possible types that the schedule data can take @@ -13,11 +14,11 @@ import { generateRandomId } from '../utils/misc'; // and the app not losing/corrupting their state. // These should always be the latest version of schedule data -export const LATEST_SCHEDULE_DATA_VERSION: ScheduleData['version'] = 2; -export type ScheduleData = Version2ScheduleData; -export type TermScheduleData = Version2TermScheduleData; -export type ScheduleVersion = Version2ScheduleVersion; -export type Schedule = Version2Schedule; +export const LATEST_SCHEDULE_DATA_VERSION: ScheduleData['version'] = 3; +export type ScheduleData = Version3ScheduleData; +export type TermScheduleData = Version3TermScheduleData; +export type ScheduleVersion = Version3ScheduleVersion; +export type Schedule = Version3Schedule; // Add additional types here named like "Version{N}OrNewer" for each version, // where they are an alias for: @@ -25,14 +26,17 @@ export type Schedule = Version2Schedule; export type Version1ScheduleDataOrNewer = | Version2ScheduleDataOrNewer | Version1ScheduleData; -export type Version2ScheduleDataOrNewer = Version2ScheduleData; +export type Version2ScheduleDataOrNewer = + | Version3ScheduleDataOrNewer + | Version2ScheduleData; +export type Version3ScheduleDataOrNewer = Version3ScheduleData; // This type should automatically accept any schedule data export type AnyScheduleData = Version1ScheduleDataOrNewer; export const defaultScheduleData: Immutable = { terms: {}, - version: 2, + version: 3, }; export const defaultTermScheduleData: Immutable = { @@ -43,6 +47,7 @@ export const defaultSchedule: Immutable = { desiredCourses: [], pinnedCrns: [], excludedCrns: [], + events: [], colorMap: {}, sortingOptionIndex: 0, }; @@ -109,3 +114,31 @@ export interface Version2Schedule { colorMap: Record; sortingOptionIndex: number; } + +// Version 3 schedule data (2023-01-22) +// =================================== +// - addition of custom events + +export interface Version3ScheduleData { + terms: Record; + version: 3; +} + +export interface Version3TermScheduleData { + versions: Record; +} + +export interface Version3ScheduleVersion { + name: string; + createdAt: string; + schedule: Version3Schedule; +} + +export interface Version3Schedule { + desiredCourses: string[]; + pinnedCrns: string[]; + excludedCrns: string[]; + events: Event[]; + colorMap: Record; + sortingOptionIndex: number; +} diff --git a/src/types.ts b/src/types.ts index 139f526b..87b44e5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,6 +77,13 @@ export interface Meeting { finalTime: Period | null; } +export interface Event { + id: string; + name: string; + period: Period; + days: string[]; +} + // Note: if this type ever changes, // the course gpa cache needs to be invalidated // (by changing the local storage key). From 28fdaba2f10283b278f05b09ee06aa195329da44 Mon Sep 17 00:00:00 2001 From: Hailey Ho Date: Tue, 24 Jan 2023 21:05:10 -0500 Subject: [PATCH 03/87] Add yarn secrets --- .github/workflows/ci.yaml | 2 +- README.md | 4 ++++ package.json | 6 +++++- src/data/firebase.ts | 7 ++++--- src/data/hooks/useRawScheduleDataFromStorage.ts | 7 ++++--- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0392a3c8..ef0c5345 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,7 +38,7 @@ jobs: run: yarn build env: CI: "true" - PRODUCTION: "false" + PREVIEW: "true" PUBLIC_URL: ${{ env.npm_package_homepage }}/pr-preview/pr-${{ github.event.number }}/ REACT_APP_MAPBOX_TOKEN: ${{ secrets.REACT_APP_MAPBOX_TOKEN }} REACT_APP_FIREBASE_API_KEY: ${{ secrets.REACT_APP_FIREBASE_API_KEY }} diff --git a/README.md b/README.md index 8f5959e6..7731c088 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ With that, you're able to make changes to the code and have them be re-built and > > See https://github.com/facebook/create-react-app/discussions/11767 for more details. +### Secrets (only for BoG Developers) +- `yarn run secrets:linux` - obtain app secrets for Linux and MacOS; ask Engineering Manager for password +- `yarn run secrets:windows` - obtain app secrets for Windows; ask Engineering Manager for password + ### Linting The project uses pre-commit hooks using [Husky](https://typicode.github.io/husky/#/) and [`lint-staged`](https://www.npmjs.com/package/lint-staged) to run linting (via [ESLint](https://eslint.org/)) and formatting (via [Prettier](https://prettier.io/)). These can be run manually from the command line to format/lint the code on-demand, using the following commands: diff --git a/package.json b/package.json index 9b89c93b..9b13f1a4 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,11 @@ "lint": "eslint \"**/*.{js,jsx,ts,tsx}\"", "lint:fix": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix", "format": "prettier \"**/*.{js,jsx,ts,tsx,json}\" --write", - "format:check": "prettier \"**/*.{js,jsx,ts,tsx,json}\" -l" + "format:check": "prettier \"**/*.{js,jsx,ts,tsx,json}\" -l", + "secrets:linux": "echo Enter Bitwarden Password: && read BW_PASSWORD && (bw logout || exit 0) && export BW_SESSION=`bw login product@bitsofgood.org $BW_PASSWORD --raw` && npm run secrets:get", + "secrets:windows": "set /p BW_PASSWORD=Enter Bitwarden Password:&& (bw logout || VER>NUL) && npm run secrets:login", + "secrets:login": "FOR /F %a IN ('bw login product@bitsofgood.org %BW_PASSWORD% --raw') DO SET BW_SESSION=%a && npm run secrets:get", + "secrets:get": "bw sync && bw get item gt-scheduler/.env | fx .notes > \".env\"" }, "eslintConfig": { "extends": "react-app" diff --git a/src/data/firebase.ts b/src/data/firebase.ts index 29acb27a..042fe101 100644 --- a/src/data/firebase.ts +++ b/src/data/firebase.ts @@ -17,9 +17,10 @@ const firebaseConfig = { measurementId: process.env['REACT_APP_FIREBASE_MEASUREMENT_ID'], }; -const SCHEDULE_COLLECTION = process.env['PRODUCTION'] - ? 'schedules' - : 'schedules-dev'; +const SCHEDULE_COLLECTION = + process.env.NODE_ENV === 'production' && !process.env['PREVIEW'] + ? 'schedules' + : 'schedules-dev'; /** * Whether Firebase authentication is enabled in this environment. diff --git a/src/data/hooks/useRawScheduleDataFromStorage.ts b/src/data/hooks/useRawScheduleDataFromStorage.ts index e5c0aa40..df4a91d2 100644 --- a/src/data/hooks/useRawScheduleDataFromStorage.ts +++ b/src/data/hooks/useRawScheduleDataFromStorage.ts @@ -6,9 +6,10 @@ import { renderDataNotPersistentNotification } from '../../components/DataNotPer import { LoadingState } from '../../types'; import { AnyScheduleData } from '../types'; -export const SCHEDULE_DATA_LOCAL_STORAGE_KEY = process.env['PRODUCTION'] - ? 'schedule-data' - : 'schedule-data-dev'; +export const SCHEDULE_DATA_LOCAL_STORAGE_KEY = + process.env.NODE_ENV === 'production' && !process.env['PREVIEW'] + ? 'schedule-data' + : 'schedule-data-dev'; type HookResult = { rawScheduleData: Immutable | null; From 76d2a2d1fca3e4684aecc12dfc2ee1d7b1dece96 Mon Sep 17 00:00:00 2001 From: Hailey Ho Date: Wed, 25 Jan 2023 13:24:24 -0500 Subject: [PATCH 04/87] Create PR template --- .github/pull_request_template.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 52d83930..d881c25e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1 +1,13 @@ -Resolves #[issue-number] +### Summary + +Resolves #[ticket-number] + + + +### Checklist + +- [ ] Copy paste checklist from issue. + + +### How to Test + \ No newline at end of file From c4d57023c625a9ecd13aea433ac57281d740dde5 Mon Sep 17 00:00:00 2001 From: Hailey Ho Date: Sun, 29 Jan 2023 16:24:48 -0500 Subject: [PATCH 05/87] Fix deploy preview bugs --- .github/workflows/ci.yaml | 6 +++--- src/data/firebase.ts | 2 +- src/data/hooks/useRawScheduleDataFromStorage.ts | 2 +- src/index.tsx | 4 ++++ 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ef0c5345..daef62d6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,9 +37,9 @@ jobs: - name: Build run: yarn build env: - CI: "true" - PREVIEW: "true" + CI: 'true' PUBLIC_URL: ${{ env.npm_package_homepage }}/pr-preview/pr-${{ github.event.number }}/ + REACT_APP_PREVIEW: 'true' REACT_APP_MAPBOX_TOKEN: ${{ secrets.REACT_APP_MAPBOX_TOKEN }} REACT_APP_FIREBASE_API_KEY: ${{ secrets.REACT_APP_FIREBASE_API_KEY }} REACT_APP_FIREBASE_AUTH_DOMAIN: ${{ secrets.REACT_APP_FIREBASE_AUTH_DOMAIN }} @@ -68,4 +68,4 @@ jobs: - name: Test run: yarn run test --coverage env: - CI: "true" \ No newline at end of file + CI: 'true' \ No newline at end of file diff --git a/src/data/firebase.ts b/src/data/firebase.ts index 042fe101..213f9408 100644 --- a/src/data/firebase.ts +++ b/src/data/firebase.ts @@ -18,7 +18,7 @@ const firebaseConfig = { }; const SCHEDULE_COLLECTION = - process.env.NODE_ENV === 'production' && !process.env['PREVIEW'] + process.env.NODE_ENV === 'production' && !process.env['REACT_APP_PREVIEW'] ? 'schedules' : 'schedules-dev'; diff --git a/src/data/hooks/useRawScheduleDataFromStorage.ts b/src/data/hooks/useRawScheduleDataFromStorage.ts index df4a91d2..a4438d12 100644 --- a/src/data/hooks/useRawScheduleDataFromStorage.ts +++ b/src/data/hooks/useRawScheduleDataFromStorage.ts @@ -7,7 +7,7 @@ import { LoadingState } from '../../types'; import { AnyScheduleData } from '../types'; export const SCHEDULE_DATA_LOCAL_STORAGE_KEY = - process.env.NODE_ENV === 'production' && !process.env['PREVIEW'] + process.env.NODE_ENV === 'production' && !process.env['REACT_APP_PREVIEW'] ? 'schedule-data' : 'schedule-data-dev'; diff --git a/src/index.tsx b/src/index.tsx index 8e4b8bc9..74492b18 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,10 @@ import './stylesheet.scss'; if (process.env.NODE_ENV === 'production') { // eslint-disable-next-line no-console console.log('Initializing Sentry'); + + // eslint-disable-next-line no-console + console.log('Preview test - ', process.env['REACT_APP_PREVIEW']); + Sentry.init({ dsn: 'https://8955ef982197469e97c7644a8c090db1@o552970.ingest.sentry.io/5679614', integrations: [new Integrations.BrowserTracing()], From 81094642db8629f115fb0abd4e53a8e5a29771b7 Mon Sep 17 00:00:00 2001 From: Samarth Chandna <57265280+samarth52@users.noreply.github.com> Date: Sun, 5 Feb 2023 13:17:12 -0500 Subject: [PATCH 06/87] Update Course Combination Algorithm (#155) ### Summary Resolves #151 ### Checklist - [x] Generated combinations do not conflict with the events. - [x] Update getCombinations's usage in CourseContainer. ### How to Test - In `src/components/CombinationContainer/index.tsx`, assign a dummy events list of type `Immutable` to `events` after the `events` prop import from ScheduleContext - Choose a term in the dev environment and add courses - Experiment with different Event times by updating `events`, and check for conflicts in the Combinations tab (the combinations where sections conflict with the recurring event should not appear) - An example `events`, which will reserve the time between 9:30am to 5:00pm on Monday and Wednesday. ``` events = castImmutable([ { id: '123', name: 'test', period: { start: 570, end: 1020, }, days: ['M', 'W'], }, ]); ``` --------- Co-authored-by: Hailey Ho Co-authored-by: Nghi Ho <38119460+nhatnghiho@users.noreply.github.com> --- src/components/CombinationContainer/index.tsx | 14 ++++- src/data/beans/Oscar.ts | 56 +++++++++++++------ src/utils/misc.tsx | 41 ++++++++++---- 3 files changed, 81 insertions(+), 30 deletions(-) diff --git a/src/components/CombinationContainer/index.tsx b/src/components/CombinationContainer/index.tsx index 1c1236cf..0a8adb2a 100644 --- a/src/components/CombinationContainer/index.tsx +++ b/src/components/CombinationContainer/index.tsx @@ -22,7 +22,14 @@ const AutoSizer = _AutoSizer as unknown as React.ComponentType; export default function CombinationContainer(): React.ReactElement { const [ - { oscar, desiredCourses, pinnedCrns, excludedCrns, sortingOptionIndex }, + { + oscar, + desiredCourses, + pinnedCrns, + excludedCrns, + events, + sortingOptionIndex, + }, { patchSchedule }, ] = useContext(ScheduleContext); const [, setOverlayCrns] = useContext(OverlayCrnsContext); @@ -33,8 +40,9 @@ export default function CombinationContainer(): React.ReactElement { }, []); const combinations = useMemo( - () => oscar.getCombinations(desiredCourses, pinnedCrns, excludedCrns), - [oscar, desiredCourses, pinnedCrns, excludedCrns] + () => + oscar.getCombinations(desiredCourses, pinnedCrns, excludedCrns, events), + [oscar, desiredCourses, pinnedCrns, excludedCrns, events] ); const sortedCombinations = useMemo( () => oscar.sortCombinations(combinations, sortingOptionIndex), diff --git a/src/data/beans/Oscar.ts b/src/data/beans/Oscar.ts index be270f87..4e3cd158 100644 --- a/src/data/beans/Oscar.ts +++ b/src/data/beans/Oscar.ts @@ -1,11 +1,19 @@ +import { Immutable } from 'immer'; + import { Course, Section, SortingOption } from '.'; -import { hasConflictBetween, stringToTime } from '../../utils/misc'; +import { + hasConflictBetween, + hasConflictBetweenSectionAndEvent, + stringToTime, +} from '../../utils/misc'; import { Combination, Period, DateRange, Location, CrawlerTermData, + Event, + Meeting, } from '../../types'; import { ErrorWithFields, softError } from '../../log'; @@ -204,7 +212,8 @@ export default class Oscar { getCombinations( desiredCourses: readonly string[], pinnedCrns: readonly string[], - excludedCrns: readonly string[] + excludedCrns: readonly string[], + events: Immutable ): Combination[] { const crnsList: string[][] = []; const dfs = (courseIndex = 0, crns: string[] = []): void => { @@ -225,7 +234,10 @@ export default class Oscar { const crnSection = this.findSection(crn); if (crnSection === undefined) return false; return hasConflictBetween(crnSection, section); - }); + }) || + events.some((event) => + hasConflictBetweenSectionAndEvent(section, event) + ); if (course.hasLab) { // If a course has a lab, then `onlyLectures`, `onlyLabs`, // and `allInOnes` should be non-undefined, but we have to check @@ -278,13 +290,18 @@ export default class Oscar { return crnsList.map((crns) => { const startMap: Record = {}; const endMap: Record = {}; - this.iterateTimeBlocks([...pinnedCrns, ...crns], (day, period) => { - if (period === undefined) return; - const end = endMap[day]; - const start = startMap[day]; - if (start == null || start > period.start) startMap[day] = period.start; - if (end == null || end < period.end) endMap[day] = period.end; - }); + this.iterateTimeBlocks( + [...pinnedCrns, ...crns], + events, + (day, period) => { + if (period === undefined) return; + const end = endMap[day]; + const start = startMap[day]; + if (start == null || start > period.start) + startMap[day] = period.start; + if (end == null || end < period.end) endMap[day] = period.end; + } + ); return { crns, startMap, @@ -319,20 +336,25 @@ export default class Oscar { iterateTimeBlocks( crns: string[], + events: Immutable, callback: (day: string, period: Period | undefined) => void ): void { + const meetingCallback = (meeting: Meeting | Immutable): void => + meeting.period && + meeting.days.forEach((day) => { + callback(day, meeting.period); + }); + crns.forEach((crn) => { const section = this.findSection(crn); if (section !== undefined) { - section.meetings.forEach( - (meeting) => - meeting.period && - meeting.days.forEach((day) => { - callback(day, meeting.period); - }) - ); + section.meetings.forEach(meetingCallback); } }); + + events.forEach((event) => { + if (event !== undefined) meetingCallback(event); + }); } } diff --git a/src/utils/misc.tsx b/src/utils/misc.tsx index 9edca958..00e810e8 100644 --- a/src/utils/misc.tsx +++ b/src/utils/misc.tsx @@ -4,11 +4,19 @@ import { DelayFactory } from 'exponential-backoff/dist/delay/delay.factory'; import { getSanitizedOptions } from 'exponential-backoff/dist/options'; import domtoimage from 'dom-to-image'; import { saveAs } from 'file-saver'; +import { Immutable } from 'immer'; import { Oscar, Section } from '../data/beans'; import { DAYS, PALETTE, PNG_SCALE_FACTOR } from '../constants'; import { ErrorWithFields, softError } from '../log'; -import { ICS, Period, PrerequisiteClause, Theme } from '../types'; +import { + Event, + ICS, + Meeting, + Period, + PrerequisiteClause, + Theme, +} from '../types'; import ics from '../vendor/ics'; export const stringToTime = (string: string): number => { @@ -58,23 +66,36 @@ export const getContentClassName = (color: string | undefined): string => { : 'dark-content'; }; +export const hasConflictBetweenMeetings = ( + meeting1: Meeting | Immutable, + meeting2: Meeting | Immutable +): boolean | undefined => + meeting1.period && + meeting2.period && + DAYS.some( + (day) => meeting1.days.includes(day) && meeting2.days.includes(day) + ) && + meeting1.period.start < meeting2.period.end && + meeting2.period.start < meeting1.period.end; + export const hasConflictBetween = ( section1: Section, section2: Section ): boolean => section1.meetings.some((meeting1) => - section2.meetings.some( - (meeting2) => - meeting1.period && - meeting2.period && - DAYS.some( - (day) => meeting1.days.includes(day) && meeting2.days.includes(day) - ) && - meeting1.period.start < meeting2.period.end && - meeting2.period.start < meeting1.period.end + section2.meetings.some((meeting2) => + hasConflictBetweenMeetings(meeting1, meeting2) ) ); +export const hasConflictBetweenSectionAndEvent = ( + section: Section, + event: Immutable +): boolean => + section.meetings.some((meeting) => + hasConflictBetweenMeetings(meeting, event) + ); + export const classes = ( ...classList: (string | boolean | null | undefined)[] ): string => classList.filter((c) => c).join(' '); From 06c2ae94b2c013572de2a6d5cd0e63b506aa578f Mon Sep 17 00:00:00 2001 From: EmilyAL001 <70612063+EmilyAL001@users.noreply.github.com> Date: Tue, 7 Feb 2023 10:17:27 -0500 Subject: [PATCH 07/87] Emily/148-seperate-course-reccuring (#156) ### Summary Resolves #148 Adds functionality for courses and recurring events tab ### Checklist - [X] Two tabs exist for Courses and Recurring Events - [X] Clicking on the tab should show the correct view. - [X] Works for both light and dark modes. - [X] Works with mobile view. ### How to Test Click on the recurring events tab and it should switch views. Click back to courses and it should show the courses view. --------- Co-authored-by: Nghi Ho <38119460+nhatnghiho@users.noreply.github.com> --- src/components/CourseContainer/index.tsx | 28 +++++++++++----- src/components/CourseNavMenu/index.tsx | 34 ++++++++++++++++++++ src/components/CourseNavMenu/stylesheet.scss | 25 ++++++++++++++ src/components/index.ts | 1 + 4 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 src/components/CourseNavMenu/index.tsx create mode 100644 src/components/CourseNavMenu/stylesheet.scss diff --git a/src/components/CourseContainer/index.tsx b/src/components/CourseContainer/index.tsx index 6f7a73ac..fc855a47 100644 --- a/src/components/CourseContainer/index.tsx +++ b/src/components/CourseContainer/index.tsx @@ -1,25 +1,37 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import ago from 's-ago'; import { Button, Course, CourseAdd } from '..'; import { ScheduleContext } from '../../contexts'; +import CourseNavMenu from '../CourseNavMenu'; import 'react-virtualized/styles.css'; import './stylesheet.scss'; export default function CourseContainer(): React.ReactElement { const [{ oscar, desiredCourses }] = useContext(ScheduleContext); + const courseTabs = ['Courses', 'Recurring Events']; + const [currentTab, setCurrentTab] = useState(0); return (
-
-
- {desiredCourses.map((courseId) => { - return ; - })} + + {courseTabs[currentTab] === courseTabs[0] ? ( +
+
+ {desiredCourses.map((courseId) => { + return ; + })} +
+
- -
+ ) : ( +
+ )} {error &&
{error}
} diff --git a/src/components/EventAdd/stylesheet.scss b/src/components/EventAdd/stylesheet.scss index a5906f7f..176b5ce2 100644 --- a/src/components/EventAdd/stylesheet.scss +++ b/src/components/EventAdd/stylesheet.scss @@ -3,7 +3,6 @@ .EventAdd { display: flex; flex-direction: column; - padding: 4px; .add { @include card; diff --git a/src/components/TimeBlocks/index.tsx b/src/components/TimeBlocks/index.tsx index 65c41dd5..632377f0 100644 --- a/src/components/TimeBlocks/index.tsx +++ b/src/components/TimeBlocks/index.tsx @@ -17,7 +17,7 @@ export interface TimeBlockPosition { crn: string; } -export interface EventTimeBlockPosition { +export interface EventBlockPosition { rowIndex: number; rowSize: number; period: Period; diff --git a/src/components/index.ts b/src/components/index.ts index 0531e51c..fa051a78 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -23,5 +23,5 @@ export { default as Select } from './Select'; export { default as Tab } from './Tab'; export { default as TimeBlocks } from './TimeBlocks'; export { default as Attribution } from './Attribution'; -export { default as CustomEvent } from './Event'; +export { default as Event } from './Event'; export { default as CourseNavMenu } from './CourseNavMenu'; diff --git a/src/index.tsx b/src/index.tsx index 74492b18..eee92b7f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,9 +15,6 @@ if (process.env.NODE_ENV === 'production') { // eslint-disable-next-line no-console console.log('Initializing Sentry'); - // eslint-disable-next-line no-console - console.log('Preview test - ', process.env['REACT_APP_PREVIEW']); - Sentry.init({ dsn: 'https://8955ef982197469e97c7644a8c090db1@o552970.ingest.sentry.io/5679614', integrations: [new Integrations.BrowserTracing()], From a6a84e61163c2acadd10dfffcbfb047f70661995 Mon Sep 17 00:00:00 2001 From: Sophia Lin <52588981+sophiazlin@users.noreply.github.com> Date: Thu, 23 Feb 2023 05:07:52 -0500 Subject: [PATCH 12/87] Display custom blocks (#165) ### Summary Resolves #153 Adds custom event blocks to calendar ### Checklist #### UI Requirements - [x] The block is placed at the right date and time on the calendar. - [x] The block has a minimum height, so even when the event's duration is too short, users can at least see the name of the event ([Figma](https://www.figma.com/file/CkystexX4qQO9OnZRxktXa/GT-Scheduler-%2F-Spr21?node-id=3207%3A2125&t=8EcWnMFd70puiz4A-0)) - [x] Name of the event should have a character limit when displayed on `Calendar`. Full name is shown in the popup instead. - [x] Mobile view works. #### Functional Requirements - [x] Popup appears when hovered over and is focused on click. ### How to Test Add events of varying sizes (10 min, 20 min, 30 min) and varying name lengths. Notes: - There is probably some extraneous code I haven't gotten rid of or property edited from TimeBlock. - Minimum time block height is set to 15 min. - Minimum time block length to see time info is set to 30 min. --------- Co-authored-by: Nghi Ho <38119460+nhatnghiho@users.noreply.github.com> Co-authored-by: Hailey Ho --- .github/workflows/ci.yaml | 15 +- package.json | 2 +- src/components/Calendar/index.tsx | 48 ++-- src/components/Event/index.tsx | 15 +- src/components/EventBlocks/index.tsx | 91 ++++++++ src/components/EventBlocks/stylesheet.scss | 15 ++ src/components/SectionBlocks/index.tsx | 129 +++++++++++ src/components/SectionBlocks/stylesheet.scss | 18 ++ src/components/TimeBlocks/index.tsx | 225 ++++++++----------- src/components/TimeBlocks/stylesheet.scss | 15 -- src/components/index.ts | 2 + src/constants.ts | 4 + src/data/beans/Course.ts | 4 +- src/data/firebase.ts | 7 +- src/utils/misc.tsx | 5 + 15 files changed, 417 insertions(+), 178 deletions(-) create mode 100644 src/components/EventBlocks/index.tsx create mode 100644 src/components/EventBlocks/stylesheet.scss create mode 100644 src/components/SectionBlocks/index.tsx create mode 100644 src/components/SectionBlocks/stylesheet.scss diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index daef62d6..c6780b3a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,14 +40,13 @@ jobs: CI: 'true' PUBLIC_URL: ${{ env.npm_package_homepage }}/pr-preview/pr-${{ github.event.number }}/ REACT_APP_PREVIEW: 'true' - REACT_APP_MAPBOX_TOKEN: ${{ secrets.REACT_APP_MAPBOX_TOKEN }} - REACT_APP_FIREBASE_API_KEY: ${{ secrets.REACT_APP_FIREBASE_API_KEY }} - REACT_APP_FIREBASE_AUTH_DOMAIN: ${{ secrets.REACT_APP_FIREBASE_AUTH_DOMAIN }} - REACT_APP_FIREBASE_PROJECT_ID: ${{ secrets.REACT_APP_FIREBASE_PROJECT_ID }} - REACT_APP_FIREBASE_STORAGE_BUCKET: ${{ secrets.REACT_APP_FIREBASE_STORAGE_BUCKET }} - REACT_APP_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.REACT_APP_FIREBASE_MESSAGING_SENDER_ID }} - REACT_APP_FIREBASE_APP_ID: ${{ secrets.REACT_APP_FIREBASE_APP_ID }} - REACT_APP_FIREBASE_MEASUREMENT_ID: ${{ secrets.REACT_APP_FIREBASE_MEASUREMENT_ID }} + REACT_APP_FIREBASE_API_KEY: ${{ secrets.REACT_APP_FIREBASE_API_KEY_DEV }} + REACT_APP_FIREBASE_AUTH_DOMAIN: ${{ secrets.REACT_APP_FIREBASE_AUTH_DOMAIN_DEV }} + REACT_APP_FIREBASE_PROJECT_ID: ${{ secrets.REACT_APP_FIREBASE_PROJECT_ID_DEV }} + REACT_APP_FIREBASE_STORAGE_BUCKET: ${{ secrets.REACT_APP_FIREBASE_STORAGE_BUCKET_DEV }} + REACT_APP_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.REACT_APP_FIREBASE_MESSAGING_SENDER_ID_DEV }} + REACT_APP_FIREBASE_APP_ID: ${{ secrets.REACT_APP_FIREBASE_APP_ID_DEV }} + REACT_APP_FIREBASE_MEASUREMENT_ID: ${{ secrets.REACT_APP_FIREBASE_MEASUREMENT_ID_DEV }} - name: Stage uses: rossjrw/pr-preview-action@v1 diff --git a/package.json b/package.json index 9b13f1a4..16e4a302 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "secrets:linux": "echo Enter Bitwarden Password: && read BW_PASSWORD && (bw logout || exit 0) && export BW_SESSION=`bw login product@bitsofgood.org $BW_PASSWORD --raw` && npm run secrets:get", "secrets:windows": "set /p BW_PASSWORD=Enter Bitwarden Password:&& (bw logout || VER>NUL) && npm run secrets:login", "secrets:login": "FOR /F %a IN ('bw login product@bitsofgood.org %BW_PASSWORD% --raw') DO SET BW_SESSION=%a && npm run secrets:get", - "secrets:get": "bw sync && bw get item gt-scheduler/.env | fx .notes > \".env\"" + "secrets:get": "bw sync && bw get item gt-scheduler/.env.development.local | fx .notes > \".env\"" }, "eslintConfig": { "extends": "react-app" diff --git a/src/components/Calendar/index.tsx b/src/components/Calendar/index.tsx index 08373695..adf5fd36 100644 --- a/src/components/Calendar/index.tsx +++ b/src/components/Calendar/index.tsx @@ -2,13 +2,11 @@ import React, { useContext } from 'react'; import { CLOSE, DAYS, OPEN } from '../../constants'; import { classes, timeToShortString } from '../../utils/misc'; -import { TimeBlocks } from '..'; +import { SectionBlocks, EventBlocks } from '..'; import { ScheduleContext } from '../../contexts'; -import { - makeSizeInfoKey, - TimeBlockPosition, - EventBlockPosition, -} from '../TimeBlocks'; +import { makeSizeInfoKey } from '../TimeBlocks'; +import { EventBlockPosition } from '../EventBlocks'; +import { SectionBlockPosition } from '../SectionBlocks'; import { Period } from '../../types'; import useMedia from '../../hooks/useMedia'; @@ -43,7 +41,7 @@ export default function Calendar({ // e.g. crnSizeInfo[crn][day]["period.start-period.end"].rowIndex const crnSizeInfo: Record< string, - Record> + Record> > = {}; // Contains the rowIndex's and rowSize's passed into each custom event's @@ -56,7 +54,7 @@ export default function Calendar({ // Recursively sets the rowSize of all time blocks within the current // connected grouping of blocks to the current block's rowSize const updateJoinedRowSizes = ( - periodInfos: (TimeBlockPosition | EventBlockPosition)[], + periodInfos: (SectionBlockPosition | EventBlockPosition)[], seen: Set, curCrn: string, curPeriod: Period, @@ -140,10 +138,10 @@ export default function Calendar({ meeting.days.forEach((day) => { const crnPeriodInfos = Object.values(crnSizeInfo) - .flatMap((days) => + .flatMap((days) => days != null ? Object.values(days[day] ?? {}) : [] ) - .flatMap((info) => (info == null ? [] : [info])); + .flatMap((info) => (info == null ? [] : [info])); const eventPeriodInfos = Object.values(eventSizeInfo) .flatMap((days) => @@ -151,7 +149,7 @@ export default function Calendar({ ) .flatMap((info) => (info == null ? [] : [info])); - const dayPeriodInfos: (TimeBlockPosition | EventBlockPosition)[] = + const dayPeriodInfos: (SectionBlockPosition | EventBlockPosition)[] = crnPeriodInfos; dayPeriodInfos.push(...eventPeriodInfos); @@ -267,7 +265,7 @@ export default function Calendar({ )}
{pinnedCrnsByFirstMeeting.map((crn) => ( - !pinnedCrns.includes(crn)) .map((crn) => ( - ))} + {events && + events.map((event) => ( + { + if (meeting === null) { + setSelectedMeeting(null); + } else { + setSelectedMeeting([event.id, meeting[0], meeting[1]]); + } + }} + /> + ))}
); diff --git a/src/components/Event/index.tsx b/src/components/Event/index.tsx index 7bcc4e22..95646d62 100644 --- a/src/components/Event/index.tsx +++ b/src/components/Event/index.tsx @@ -6,11 +6,15 @@ import { faTrash, } from '@fortawesome/free-solid-svg-icons'; -import { classes, getContentClassName, periodToString } from '../../utils/misc'; +import { + classes, + getContentClassName, + periodToString, + daysToString, +} from '../../utils/misc'; import { ActionRow, EventAdd, Palette } from '..'; import { ScheduleContext } from '../../contexts'; import { Event as EventData } from '../../types'; -import { DAYS } from '../../constants'; import './stylesheet.scss'; @@ -72,10 +76,9 @@ export default function Event({ >
- {[ - DAYS.filter((day) => new Set(event.days).has(day)).join(''), - periodToString(event.period), - ].join(' ')} + {[daysToString(event.days), periodToString(event.period)].join( + ' ' + )}
{paletteShown && ( diff --git a/src/components/EventBlocks/index.tsx b/src/components/EventBlocks/index.tsx new file mode 100644 index 00000000..15d362ec --- /dev/null +++ b/src/components/EventBlocks/index.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Immutable } from 'immer'; + +import { daysToString, periodToString } from '../../utils/misc'; +import { TimeBlocks } from '..'; +import { Period, Event } from '../../types'; +import { TimeBlockPosition, SizeInfo } from '../TimeBlocks'; + +import './stylesheet.scss'; + +export interface EventBlockPosition extends TimeBlockPosition { + rowIndex: number; + rowSize: number; + period: Period; + id: string; +} + +export type EventBlocksProps = { + className?: string; + event: Immutable; + capture: boolean; + includeDetailsPopover: boolean; + includeContent: boolean; + sizeInfo: SizeInfo; + canBeTabFocused?: boolean; + deviceHasHover?: boolean; + selectedMeeting?: [meetingIndex: number, day: string] | null; + onSelectMeeting?: ( + meeting: [meetingIndex: number, day: string] | null + ) => void; +}; + +export default function EventBlocks({ + className, + event, + capture, + sizeInfo, + includeDetailsPopover, + includeContent, + canBeTabFocused = false, + deviceHasHover = true, + selectedMeeting, + onSelectMeeting, +}: EventBlocksProps): React.ReactElement | null { + return ( + = 30 + ? [ + { + className: 'period', + content: periodToString(event.period), + }, + ] + : [] + } + popover={[ + { + name: 'Name', + content: event.name, + }, + { + name: 'Time', + content: [ + daysToString(event.days), + periodToString(event.period), + ].join(' '), + }, + ]} + capture={capture} + sizeInfo={sizeInfo} + includeDetailsPopover={includeDetailsPopover} + includeContent={includeContent} + canBeTabFocused={canBeTabFocused} + deviceHasHover={deviceHasHover} + selectedMeeting={selectedMeeting} + onSelectMeeting={onSelectMeeting} + /> + ); +} diff --git a/src/components/EventBlocks/stylesheet.scss b/src/components/EventBlocks/stylesheet.scss new file mode 100644 index 00000000..4aa032d5 --- /dev/null +++ b/src/components/EventBlocks/stylesheet.scss @@ -0,0 +1,15 @@ +@import '../../variables'; + +.mobile .TimeBlocks:not(.capture) .meeting .meeting-wrapper { + .ids { + .event-name { + flex: 1; + } + } +} + + +.popover tbody tr td { + max-width: calc(max(200px, 15vw)); + overflow-wrap: break-word; +} \ No newline at end of file diff --git a/src/components/SectionBlocks/index.tsx b/src/components/SectionBlocks/index.tsx new file mode 100644 index 00000000..f91982a3 --- /dev/null +++ b/src/components/SectionBlocks/index.tsx @@ -0,0 +1,129 @@ +import React, { useContext } from 'react'; + +import { periodToString } from '../../utils/misc'; +import { ScheduleContext } from '../../contexts'; +import { Period } from '../../types'; +import { TimeBlocks } from '..'; +import { TimeBlockPosition, SizeInfo } from '../TimeBlocks'; + +import './stylesheet.scss'; + +export interface SectionBlockPosition extends TimeBlockPosition { + rowIndex: number; + rowSize: number; + period: Period; + crn: string; +} + +export type SectionBlocksProps = { + className?: string; + crn: string; + overlay?: boolean; + capture: boolean; + includeDetailsPopover: boolean; + includeContent: boolean; + sizeInfo: SizeInfo; + canBeTabFocused?: boolean; + deviceHasHover?: boolean; + selectedMeeting?: [meetingIndex: number, day: string] | null; + onSelectMeeting?: ( + meeting: [meetingIndex: number, day: string] | null + ) => void; +}; + +export default function SectionBlocks({ + className, + crn, + overlay = false, + capture, + sizeInfo, + includeDetailsPopover, + includeContent, + canBeTabFocused = false, + deviceHasHover = true, + selectedMeeting, + onSelectMeeting, +}: SectionBlocksProps): React.ReactElement | null { + const [{ oscar }] = useContext(ScheduleContext); + + const section = oscar.findSection(crn); + if (section == null) return null; + + return ( +
+ {section.meetings.map((meeting, i) => { + const { period } = meeting; + if (period == null) return; + + return ( + + ); + })} +
+ ); +} diff --git a/src/components/SectionBlocks/stylesheet.scss b/src/components/SectionBlocks/stylesheet.scss new file mode 100644 index 00000000..734a75d3 --- /dev/null +++ b/src/components/SectionBlocks/stylesheet.scss @@ -0,0 +1,18 @@ +@import '../../variables'; + +.mobile .TimeBlocks:not(.capture) .meeting .meeting-wrapper { + .ids { + .course-id { + flex: 1; + } + + .section-id { + display: none; + } + } + + .where, + .instructors { + display: none; + } +} diff --git a/src/components/TimeBlocks/index.tsx b/src/components/TimeBlocks/index.tsx index 632377f0..4e6d7d26 100644 --- a/src/components/TimeBlocks/index.tsx +++ b/src/components/TimeBlocks/index.tsx @@ -2,11 +2,10 @@ import React, { useContext, useId } from 'react'; import { Tooltip as ReactTooltip } from 'react-tooltip'; import { useRootClose } from 'react-overlays'; -import { classes, getContentClassName, periodToString } from '../../utils/misc'; +import { classes, getContentClassName } from '../../utils/misc'; import { CLOSE, OPEN, DAYS } from '../../constants'; import { ScheduleContext } from '../../contexts'; -import { Meeting, Period } from '../../types'; -import { Section } from '../../data/beans'; +import { Period } from '../../types'; import './stylesheet.scss'; @@ -14,21 +13,29 @@ export interface TimeBlockPosition { rowIndex: number; rowSize: number; period: Period; - crn: string; } -export interface EventBlockPosition { - rowIndex: number; - rowSize: number; - period: Period; - id: string; -} +export type TimeBlockContent = { + className: string; + content: string; +}; + +export type TimeBlockPopover = { + name: string; + content?: string; +}; export type SizeInfo = Record>; export type TimeBlocksProps = { className?: string; - crn: string; + id: string; + meetingIndex: number; + period: Period; + days: string[] | readonly string[]; + contentHeader: TimeBlockContent[]; + contentBody: TimeBlockContent[]; + popover: TimeBlockPopover[]; overlay?: boolean; capture: boolean; includeDetailsPopover: boolean; @@ -57,7 +64,13 @@ export function makeSizeInfoKey(period: Period): string { export default function TimeBlocks({ className, - crn, + id, + meetingIndex, + period, + days, + contentHeader, + contentBody, + popover, overlay = false, capture, sizeInfo, @@ -68,12 +81,9 @@ export default function TimeBlocks({ selectedMeeting, onSelectMeeting, }: TimeBlocksProps): React.ReactElement | null { - const [{ oscar, colorMap }] = useContext(ScheduleContext); - - const section = oscar.findSection(crn); - if (section == null) return null; - - const color = colorMap[section.course.id]; + const [{ colorMap }] = useContext(ScheduleContext); + const color = colorMap[id]; + const sizeInfoKey = makeSizeInfoKey(period); return (
- {section.meetings.map((meeting, i) => { - const { period } = meeting; - if (period == null) return; - - const sizeInfoKey = makeSizeInfoKey(period); - return meeting.days.map((day, j) => { - const sizeInfoDay = sizeInfo[day]; - if (sizeInfoDay == null) return; - const sizeInfoPeriodDay = sizeInfoDay[sizeInfoKey]; - if (sizeInfoPeriodDay == null) return; + {days.map((day, i) => { + const sizeInfoDay = sizeInfo[day]; + if (sizeInfoDay == null) return; + const sizeInfoPeriodDay = sizeInfoDay[sizeInfoKey]; + if (sizeInfoPeriodDay == null) return; - return ( - { + if (onSelectMeeting == null) return; + if (newIsSelected) { + onSelectMeeting([meetingIndex, day]); + } else { + onSelectMeeting(null); } - onSelect={(newIsSelected: boolean): void => { - if (onSelectMeeting == null) return; - if (newIsSelected) { - onSelectMeeting([i, day]); - } else { - onSelectMeeting(null); - } - }} - key={`${day}-${sizeInfoKey}`} - deviceHasHover={deviceHasHover} - // Only the first day for a meeting can be tab focused - canBeTabFocused={canBeTabFocused && j === 0} - /> - ); - }); + }} + key={`${day}-${sizeInfoKey}`} + deviceHasHover={deviceHasHover} + // Only the first day for a meeting can be tab focused + canBeTabFocused={canBeTabFocused && i === 0} + /> + ); })}
); @@ -134,8 +139,9 @@ type MeetingDayBlockProps = { color: string | undefined; day: string; period: Period; - section: Section; - meeting: Meeting; + contentHeader: TimeBlockContent[]; + contentBody: TimeBlockContent[]; + popover: TimeBlockPopover[]; sizeInfo: TimeBlockPosition; canBeTabFocused: boolean; includeDetailsPopover: boolean; @@ -149,8 +155,9 @@ function MeetingDayBlock({ color, day, period, - section, - meeting, + contentHeader, + contentBody, + popover, sizeInfo, canBeTabFocused = false, includeDetailsPopover, @@ -183,7 +190,9 @@ function MeetingDayBlock({ )} style={{ top: `${((period.start - OPEN) / (CLOSE - OPEN)) * 100}%`, - height: `${((period.end - period.start) / (CLOSE - OPEN)) * 100}%`, + height: `${ + (Math.max(15, period.end - period.start) / (CLOSE - OPEN)) * 100 + }%`, width: `${20 / sizeInfo.rowSize}%`, left: `${ DAYS.indexOf(day) * 20 + sizeInfo.rowIndex * (20 / sizeInfo.rowSize) @@ -218,14 +227,19 @@ function MeetingDayBlock({ {includeContent && (
- {section.course.id} -  {section.id} + {contentHeader.map((content) => { + return ( + + {content.content}  + + ); + })}
- {periodToString(period)} - {meeting.where} - - {meeting.instructors.join(', ')} - + {contentBody.map((content) => { + return ( + {content.content} + ); + })}
)} @@ -260,14 +274,7 @@ function MeetingDayBlock({ // "selected" styles stop applying while the tooltip is still open. events={deviceHasHover ? ['hover'] : []} > - + )}
@@ -275,63 +282,25 @@ function MeetingDayBlock({ } type DetailsPopoverContentProps = { - title: string; - instructors: string[]; - location: string; - crn: string; - credits: number; - deliveryMode: string | null; + popover: TimeBlockPopover[]; }; function DetailsPopoverContent({ - title, - instructors, - location, - crn, - credits, - deliveryMode, + popover, }: DetailsPopoverContentProps): React.ReactElement { return ( - +
- - - - - - - - - - - - - - - - - - - - - {deliveryMode && ( - - - - - )} + {popover.map((popoverInfo) => { + return popoverInfo.content ? ( + + + + + ) : undefined; + })}
- Course Name - {title}
- Instructors - {instructors.join(', ')}
- Location - {location}
- CRN - {crn}
- Credit Hours - {credits}
- Delivery Type - {deliveryMode}
+ {popoverInfo.name} + {popoverInfo.content}
); diff --git a/src/components/TimeBlocks/stylesheet.scss b/src/components/TimeBlocks/stylesheet.scss index a52b2b71..2c3e8da7 100644 --- a/src/components/TimeBlocks/stylesheet.scss +++ b/src/components/TimeBlocks/stylesheet.scss @@ -131,21 +131,6 @@ span { white-space: normal; } - - .ids { - .course-id { - flex: 1; - } - - .section-id { - display: none; - } - } - - .where, - .instructors { - display: none; - } } } } diff --git a/src/components/index.ts b/src/components/index.ts index fa051a78..47d8b7ab 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -22,6 +22,8 @@ export { default as Section } from './Section'; export { default as Select } from './Select'; export { default as Tab } from './Tab'; export { default as TimeBlocks } from './TimeBlocks'; +export { default as SectionBlocks } from './SectionBlocks'; +export { default as EventBlocks } from './EventBlocks'; export { default as Attribution } from './Attribution'; export { default as Event } from './Event'; export { default as CourseNavMenu } from './CourseNavMenu'; diff --git a/src/constants.ts b/src/constants.ts index 1f0fd954..9938b06f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,5 @@ +import { firebaseConfig } from './data/firebase'; + const OPEN = 8 * 60; const CLOSE = 21 * 60; const DAYS = ['M', 'T', 'W', 'R', 'F']; @@ -69,6 +71,7 @@ const CAMPUSES: Record = { }; const BACKEND_BASE_URL = 'https://gt-scheduler.azurewebsites.net'; +const FIREBASE_PROJECT_ID = firebaseConfig.projectId || `gt-scheduler-web-dev`; const LARGE_DESKTOP_BREAKPOINT = 1200; const DESKTOP_BREAKPOINT = 1024; @@ -84,6 +87,7 @@ export { DELIVERY_MODES, CAMPUSES, BACKEND_BASE_URL, + FIREBASE_PROJECT_ID, DESKTOP_BREAKPOINT, LARGE_MOBILE_BREAKPOINT, LARGE_DESKTOP_BREAKPOINT, diff --git a/src/data/beans/Course.ts b/src/data/beans/Course.ts index 9ceeb724..b265763e 100644 --- a/src/data/beans/Course.ts +++ b/src/data/beans/Course.ts @@ -14,14 +14,14 @@ import { isAxiosNetworkError, } from '../../utils/misc'; import { ErrorWithFields, softError } from '../../log'; +import { FIREBASE_PROJECT_ID } from '../../constants'; // This is actually a transparent read-through cache // in front of the Course Critique API's course data endpoint, // but it should behave the same as the real API. // See the implementation at: // https://github.com/gt-scheduler/firebase-conf/blob/main/functions/src/course_critique_cache.ts -const COURSE_CRITIQUE_API_URL = - 'https://us-central1-gt-scheduler-web-prod.cloudfunctions.net/getCourseDataFromCourseCritique'; +const COURSE_CRITIQUE_API_URL = `https://us-central1-${FIREBASE_PROJECT_ID}.cloudfunctions.net/getCourseDataFromCourseCritique`; const GPA_CACHE_LOCAL_STORAGE_KEY = 'course-gpa-cache-2'; const GPA_CACHE_EXPIRATION_DURATION_DAYS = 7; diff --git a/src/data/firebase.ts b/src/data/firebase.ts index 213f9408..18fb21ab 100644 --- a/src/data/firebase.ts +++ b/src/data/firebase.ts @@ -7,7 +7,7 @@ import { AnyScheduleData } from './types'; // This data is not secret; it is included in the application bundle. // Supply these environment variables when developing locally. -const firebaseConfig = { +export const firebaseConfig = { apiKey: process.env['REACT_APP_FIREBASE_API_KEY'], authDomain: process.env['REACT_APP_FIREBASE_AUTH_DOMAIN'], projectId: process.env['REACT_APP_FIREBASE_PROJECT_ID'], @@ -17,10 +17,7 @@ const firebaseConfig = { measurementId: process.env['REACT_APP_FIREBASE_MEASUREMENT_ID'], }; -const SCHEDULE_COLLECTION = - process.env.NODE_ENV === 'production' && !process.env['REACT_APP_PREVIEW'] - ? 'schedules' - : 'schedules-dev'; +const SCHEDULE_COLLECTION = 'schedules'; /** * Whether Firebase authentication is enabled in this environment. diff --git a/src/utils/misc.tsx b/src/utils/misc.tsx index b545f324..fcb1c745 100644 --- a/src/utils/misc.tsx +++ b/src/utils/misc.tsx @@ -55,6 +55,11 @@ export const periodToString = (period: Period | undefined): string => ? `${timeToString(period.start, false)} - ${timeToString(period.end)}` : 'TBA'; +export const daysToString = (days: readonly string[] | string[]): string => { + const set = new Set(days); + return DAYS.filter((day) => set.has(day)).join(''); +}; + export const getRandomColor = (): string => { const colors = PALETTE.flat(); const index = (Math.random() * colors.length) | 0; From efac4361667fe4feb462c0afa9b2b3903b20066a Mon Sep 17 00:00:00 2001 From: Nathan Gong Date: Thu, 23 Feb 2023 02:28:51 -0800 Subject: [PATCH 13/87] Update sort algorithm for "most compact" (#167) ### Summary Resolves #160 Updates the "most compact" sorting option to account for custom blocks ### Checklist - [x] Combinations are sorted appropriately for each setting. - [x] Related components (if any) are updated if this change affects them (`Calendar` is an example). -- I don't think anything was impacted by this change ### How to Test Create a few custom blocks, check how the ordering of the combinations changes as a result --------- Co-authored-by: Nghi Ho <38119460+nhatnghiho@users.noreply.github.com> --- src/components/CombinationContainer/index.tsx | 4 +-- src/data/beans/Oscar.ts | 34 ++++++++++++++++--- src/data/beans/SortingOption.ts | 11 ++++-- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/components/CombinationContainer/index.tsx b/src/components/CombinationContainer/index.tsx index 0a8adb2a..2e9bd0dd 100644 --- a/src/components/CombinationContainer/index.tsx +++ b/src/components/CombinationContainer/index.tsx @@ -45,8 +45,8 @@ export default function CombinationContainer(): React.ReactElement { [oscar, desiredCourses, pinnedCrns, excludedCrns, events] ); const sortedCombinations = useMemo( - () => oscar.sortCombinations(combinations, sortingOptionIndex), - [oscar, combinations, sortingOptionIndex] + () => oscar.sortCombinations(combinations, sortingOptionIndex, events), + [oscar, combinations, sortingOptionIndex, events] ); return ( diff --git a/src/data/beans/Oscar.ts b/src/data/beans/Oscar.ts index 4e3cd158..3d8309f6 100644 --- a/src/data/beans/Oscar.ts +++ b/src/data/beans/Oscar.ts @@ -173,12 +173,35 @@ export default class Oscar { }); this.sortingOptions = [ - new SortingOption('Most Compact', (combination) => { + new SortingOption('Most Compact', (combination, events) => { const { startMap, endMap } = combination; + + const eventStartMap = new Map(); + const eventEndMap = new Map(); + events.forEach((event) => { + const { start, end } = event.period; + for (const day of event.days) { + if (!eventStartMap.has(day)) { + eventStartMap.set(day, start); + } + eventStartMap.set( + day, + Math.min(start, eventStartMap.get(day) ?? Infinity) + ); + + if (!eventEndMap.has(day)) { + eventEndMap.set(day, end); + } + eventEndMap.set(day, Math.max(end, eventEndMap.get(day) ?? -1)); + } + }); const diffs = Object.keys(startMap).map((day) => { - const end = endMap[day]; - const start = startMap[day]; + let end = endMap[day]; + let start = startMap[day]; if (end == null || start == null) return 0; + end = Math.max(end, eventEndMap.get(day) ?? -1); + start = Math.min(start, eventStartMap.get(day) ?? Infinity); + return end - start; }); const sum = diffs.reduce((tot, min) => tot + min, 0); @@ -312,7 +335,8 @@ export default class Oscar { sortCombinations( combinations: Combination[], - sortingOptionIndex: number + sortingOptionIndex: number, + events: Immutable ): Combination[] { const sortingOption = this.sortingOptions[sortingOptionIndex]; if (sortingOption === undefined) { @@ -329,7 +353,7 @@ export default class Oscar { return combinations .map((combination) => ({ ...combination, - factor: sortingOption.calculateFactor(combination), + factor: sortingOption.calculateFactor(combination, events), })) .sort((a, b) => a.factor - b.factor); } diff --git a/src/data/beans/SortingOption.ts b/src/data/beans/SortingOption.ts index 1ca19371..b61c6b7a 100644 --- a/src/data/beans/SortingOption.ts +++ b/src/data/beans/SortingOption.ts @@ -1,11 +1,16 @@ -import { Combination } from '../../types'; +import { Immutable } from 'immer'; + +import { Combination, Event } from '../../types'; export default class SortingOption { label: string; - calculateFactor: (combo: Combination) => number; + calculateFactor: (combo: Combination, events: Immutable) => number; - constructor(label: string, calculateFactor: (combo: Combination) => number) { + constructor( + label: string, + calculateFactor: (combo: Combination, events: Immutable) => number + ) { this.label = label; this.calculateFactor = calculateFactor; } From 0fa5e8fdfde3930471616fdbfd2bdf235026ac76 Mon Sep 17 00:00:00 2001 From: Yatharth Bhargava <52095139+yatharth-b@users.noreply.github.com> Date: Thu, 23 Feb 2023 21:33:35 -0500 Subject: [PATCH 14/87] Add Initial Friends Schema (#168) ### Summary Resolves #164, Firebase Function PR: https://github.com/gt-scheduler/firebase-conf/pull/1 - Added schema structure through types in src/data/types.ts. - Created hooks similar to useRawScheduleDataFromFirebase and useRawScheduleDataFromStorage for retrieving and changing data from firestore or local storage. - Created a cloud function for retrieving the versions of friends the user has access to. - NOTE: I was not able to test if the cloud function properly checks the validity of the IdToken provided by frontend authentication while making the request. The function retrieves friend versions as expected. ### Checklist - [x] A new schema Friends is created. - [x] A function exists for adding a friend's schedule to a user's list of accessible schedules (C/U in CRUD) - [x] A Cloud Function exists for fetching friends' schedules. Preferably, the algorithm should be able to fetch schedules in batch to reduce the number of API hits (R in CRUD) - [x] A function exists for removing a friend's schedule from a user's list of accessible schedules (D in CRUD) ### How to Test This is the expected structure of the body of the request made to the firebase function: ``` { "friends": { "friend id": ["version id"] }, "term": "term", "IDToken": "token id provided by frontend auth" } ``` For testing purposes, comment out code from the function that verifies the user's IdToken. --------- Co-authored-by: Hailey Ho Co-authored-by: Nghi Ho <38119460+nhatnghiho@users.noreply.github.com> --- src/data/firebase.ts | 11 +- .../hooks/useRawFriendDataFromFirebase.ts | 191 ++++++++++++++++++ src/data/types.ts | 12 ++ 3 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 src/data/hooks/useRawFriendDataFromFirebase.ts diff --git a/src/data/firebase.ts b/src/data/firebase.ts index 18fb21ab..f4d3a2ef 100644 --- a/src/data/firebase.ts +++ b/src/data/firebase.ts @@ -3,7 +3,7 @@ import 'firebase/auth'; import 'firebase/firestore'; import { ErrorWithFields, softError } from '../log'; -import { AnyScheduleData } from './types'; +import { AnyScheduleData, FriendData } from './types'; // This data is not secret; it is included in the application bundle. // Supply these environment variables when developing locally. @@ -18,6 +18,7 @@ export const firebaseConfig = { }; const SCHEDULE_COLLECTION = 'schedules'; +const FRIEND_COLLECTION = 'friends'; /** * Whether Firebase authentication is enabled in this environment. @@ -32,8 +33,12 @@ let db: firebase.firestore.Firestore = null as unknown as firebase.firestore.Firestore; type SchedulesCollection = firebase.firestore.CollectionReference; +type FriendsCollection = firebase.firestore.CollectionReference; let schedulesCollection: SchedulesCollection = null as unknown as SchedulesCollection; + +let friendsCollection: FriendsCollection = null as unknown as FriendsCollection; + /* eslint-enable import/no-mutable-exports */ if (isAuthEnabled) { const app = firebase.initializeApp(firebaseConfig); @@ -44,6 +49,8 @@ if (isAuthEnabled) { SCHEDULE_COLLECTION ) as SchedulesCollection; + friendsCollection = db.collection(FRIEND_COLLECTION) as FriendsCollection; + auth.setPersistence(firebase.auth.Auth.Persistence.LOCAL).catch((err) => { softError( new ErrorWithFields({ @@ -54,7 +61,7 @@ if (isAuthEnabled) { }); } -export { auth, db, schedulesCollection }; +export { auth, db, schedulesCollection, friendsCollection }; export { firebase }; // Configure the enabled auth providers that firebase UI displays as options diff --git a/src/data/hooks/useRawFriendDataFromFirebase.ts b/src/data/hooks/useRawFriendDataFromFirebase.ts new file mode 100644 index 00000000..ee7dde91 --- /dev/null +++ b/src/data/hooks/useRawFriendDataFromFirebase.ts @@ -0,0 +1,191 @@ +import { Immutable, castImmutable, castDraft } from 'immer'; +import { useCallback, useEffect, useState } from 'react'; + +import { SignedIn } from '../../contexts/account'; +import { ErrorWithFields, softError } from '../../log'; +import { + LoadingState, + LoadingStateCustom, + LoadingStateError, +} from '../../types'; +import { db, isAuthEnabled, friendsCollection } from '../firebase'; +import { FriendData, defaultFriendData } from '../types'; + +type HookResult = { + rawFriendData: Immutable | null; + setFriendScheduleData: ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ) => void; +}; + +type FriendDataState = Loading | NonExistant | FriendDataExists; + +type Loading = { + type: 'loading'; +}; +type NonExistant = { + type: 'nonExistant'; +}; + +type FriendDataExists = { + type: 'exists'; + data: FriendData; +}; + +/** + * Gets the current schedule data from Firebase. + * Do not call this function in a non-root component; + * it should only be called once in a root component (i.e. ). + */ +export default function useRawFriendDataFromFirebase( + account: SignedIn +): LoadingState { + const [friendData, setFriendData] = useState({ + type: 'loading', + }); + + const [permanentError, setPermanentError] = useState< + LoadingStateError | LoadingStateCustom | null + >(null); + useEffect(() => { + if (!isAuthEnabled) return undefined; + + const removeFriendsSnapshotListener = friendsCollection + .doc(account.id) + .onSnapshot( + { + // Ignore metadata changes + includeMetadataChanges: false, + }, + (doc) => { + const data = doc.data(); + if (data == null) { + setFriendData({ type: 'nonExistant' }); + } else { + setFriendData({ + type: 'exists', + data: doc.data() as FriendData, + }); + } + } + ); + return (): void => { + removeFriendsSnapshotListener(); + }; + }, [account.id]); + + const setFriendDataPersistent = useCallback( + ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ): void => { + let nextFriendData; + if (typeof next === 'function') { + let currentFriendData; + if (friendData.type === 'exists') { + currentFriendData = friendData.data; + } else { + currentFriendData = null; + } + nextFriendData = next(currentFriendData); + } else { + nextFriendData = next; + } + if (nextFriendData === null) return; + + // Eagerly set the friend data here as well. + // It would be okay to wait until Firebase updates the state for us, + // (which it will do, even before the network calls are made), + // but this allows a window where state can react based on stale state. + setFriendData({ type: 'exists', data: nextFriendData }); + + friendsCollection + .doc(account.id) + .set(nextFriendData) + .catch((err) => { + softError( + new ErrorWithFields({ + message: 'error when updating remote document', + source: err, + fields: { + accountId: account.id, + }, + }) + ); + }); + }, + [account.id, friendData] + ); + + // Perform a transaction if the type is non-existent, + // trying to pull existing data from local storage + // and storing it in Firebase. + // This serves to provide the initial account data. + useEffect(() => { + if (!isAuthEnabled) return; + + if (friendData.type === 'nonExistant') { + // Imperatively get the latest migrated data + const currentFriendData: Immutable = defaultFriendData; + + // Start the transaction + db.runTransaction(async (transaction) => { + const currentDoc = await transaction.get( + friendsCollection.doc(account.id) + ); + if (currentDoc.exists) return; + transaction.set( + friendsCollection.doc(account.id), + castDraft(currentFriendData) + ); + }).catch((err) => { + // Send the error to Sentry + const error = new ErrorWithFields({ + message: 'an error occurred while initializing account friend data', + source: err, + fields: { + account: account.id, + }, + }); + softError(error); + + // Report the error to the user + setPermanentError({ + type: 'error', + error, + stillLoading: false, + overview: String(err), + }); + }); + } + }, [account.id, friendData.type]); + + // If this hook is running and auth is not enabled, + // then something is wrong with the state. + // Show an error. + if (!isAuthEnabled) { + return { + type: 'error', + error: new ErrorWithFields({ + message: 'cannot obtain data from firebase: authentication is disabled', + }), + stillLoading: false, + overview: 'authentication is not enabled', + }; + } + + if (permanentError !== null) { + return permanentError; + } + + if (friendData.type === 'loading' || friendData.type === 'nonExistant') { + return { type: 'loading' }; + } + + return { + type: 'loaded', + result: { + rawFriendData: castImmutable(friendData.data), + setFriendScheduleData: setFriendDataPersistent, + }, + }; +} diff --git a/src/data/types.ts b/src/data/types.ts index 6f955933..d1af52b1 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -39,6 +39,10 @@ export const defaultScheduleData: Immutable = { version: 3, }; +export const defaultFriendData: Immutable = { + terms: {}, +}; + export const defaultTermScheduleData: Immutable = { versions: {}, }; @@ -142,3 +146,11 @@ export interface Version3Schedule { colorMap: Record; sortingOptionIndex: number; } + +export interface FriendData { + terms: Record; +} + +export interface FriendTermData { + accessibleSchedules: Record; +} From 97c5ae980d2cc5a0e3b6021a9c1c915ac6831d4c Mon Sep 17 00:00:00 2001 From: EmilyAL001 <70612063+EmilyAL001@users.noreply.github.com> Date: Thu, 23 Feb 2023 23:06:03 -0500 Subject: [PATCH 15/87] Emily/162 send invitation modal (#169) ### Summary Resolves #162 Adds Invitation Modal to export drop down that allows users to share their schedule ### Checklist - [x] The "Share/Export" dropdown has a button that opens up the modal. - [x] The modal has two sections: 1) email invitation and 2) list of friends with access to the user's active schedule. - [x] Feedback is displayed to users when an email invite is successfully sent or when they try to invite a non-existent user. - [x] An autocomplete dropdown exists (this will be used for memorizing recently invited friends). - [x] The list of invited friends is scrollable. ### How to Test Click "share schedule" in "share/export" dropdown This opens a modal where the user can share their schedule Email can be input, if it exists, the share is successful, otherwise the user is told that the email is invalid Invited users can be viewed at the bottom of the modal and their status can be viewed on hover --------- Co-authored-by: Nghi Ho <38119460+nhatnghiho@users.noreply.github.com> Co-authored-by: Hailey Ho Co-authored-by: Hailey Ho Co-authored-by: Hailey Ho --- src/components/CourseAdd/stylesheet.scss | 2 +- src/components/HeaderActionBar/index.tsx | 14 +- src/components/InvitationModal/index.tsx | 239 ++++++++++++++++ .../InvitationModal/stylesheet.scss | 258 ++++++++++++++++++ src/components/index.ts | 1 + 5 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 src/components/InvitationModal/index.tsx create mode 100644 src/components/InvitationModal/stylesheet.scss diff --git a/src/components/CourseAdd/stylesheet.scss b/src/components/CourseAdd/stylesheet.scss index d7e9070f..82937869 100644 --- a/src/components/CourseAdd/stylesheet.scss +++ b/src/components/CourseAdd/stylesheet.scss @@ -73,4 +73,4 @@ padding: 4px; font-size: .8em; } -} +} \ No newline at end of file diff --git a/src/components/HeaderActionBar/index.tsx b/src/components/HeaderActionBar/index.tsx index 14196fd0..b27fadfc 100644 --- a/src/components/HeaderActionBar/index.tsx +++ b/src/components/HeaderActionBar/index.tsx @@ -5,9 +5,10 @@ import { faPaste, faAdjust, faCaretDown, + faShare, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import React, { useCallback, useContext } from 'react'; +import React, { useCallback, useContext, useState } from 'react'; import { Button } from '..'; import { @@ -20,6 +21,7 @@ import { AccountContextValue } from '../../contexts/account'; import { classes } from '../../utils/misc'; import { DropdownMenu, DropdownMenuAction } from '../Select'; import AccountDropdown from '../AccountDropdown'; +import InvitationModal from '../InvitationModal'; import './stylesheet.scss'; @@ -58,6 +60,8 @@ export default function HeaderActionBar({ const newTheme = theme === 'light' ? 'dark' : 'light'; setTheme(newTheme); }, [theme, setTheme]); + const [invitationOpen, setInvitationOpen] = useState(false); + const hideInvitation = useCallback(() => setInvitationOpen(false), []); // Coalesce the export options into the props for a single const enableExport = @@ -84,6 +88,13 @@ export default function HeaderActionBar({ onClick: onCopyCrns, }); } + exportActions.push({ + label: 'Share Schedule', + icon: faShare, + onClick: (): void => { + setInvitationOpen(true); + }, + }); // On small mobile screens and on large desktop, // left-anchor the "Export" dropdown. @@ -112,6 +123,7 @@ export default function HeaderActionBar({
+ + + +
+
+

+ Users Invited to View Primary +

+
+ {emails.map((element) => ( +
+
+ {element[0]} + + + Status: {element[1]} + +
+
+ ))} +
+
+ + ); +} + +export type InvitationModalProps = { + show: boolean; + onHide: () => void; +}; + +/** + * Component that can be used to show the invitaion modal. + */ +export default function InvitationModal({ + show, + onHide, +}: InvitationModalProps): React.ReactElement { + return ( + onHide(), cancel: true }, + ]} + width={550} + > + + + ); +} diff --git a/src/components/InvitationModal/stylesheet.scss b/src/components/InvitationModal/stylesheet.scss new file mode 100644 index 00000000..97e2aad5 --- /dev/null +++ b/src/components/InvitationModal/stylesheet.scss @@ -0,0 +1,258 @@ +@import "../../variables"; +.invitation-modal-content { + text-align: center; + + @include light { + background-color: $theme-light-background; + color: $modal-foreground-color-light; + } + + @include dark { + background-color: $theme-dark-background; + color: white; + } + + //Style for email input + .top-block { + display: flex; + flex-direction: column; + justify-content: center; + padding: 10px 15px 5px 15px; + + p { + font-size: .9em; + } + + .email-input-block { + display: flex; + flex-direction: row; + justify-content: space-evenly; + padding-top: 5px; + padding-bottom: 5px; + + .email-input { + display: flex; + flex-direction: column; + position: relative; + + .valid-email { + text-align: start; + color: rgba(34, 181, 49, 0.75); + font-size: 12px; + font-weight: bold; + padding-top: 3px; + opacity: 1; + + } + + .invalid-email { + text-align: start; + color: rgba(255, 0, 0, 0.5); + font-size: 12px; + font-weight: bold; + padding-top: 3px; + opacity: 1; + } + + .email:has(+.invalid-email) { + border: 1px solid rgba(255, 0, 0, 0.5); + } + } + + input[type=email] { + width: 282px; + height: 24px; + padding: 15px; + border-radius: 6px; + background-color: rgb(35, 35, 35); + border: 1px solid rgb(35, 35, 35); + outline: none; + + &:focus { + border-color: rgba(255, 255, 255, 0.5); + } + + @include light { + background-color: rgba($color-neutral, 0.5); + border: 1px solid rgba($color-neutral, 0.5); + } + } + + .send-button { + width: 86px; + height: 24px; + font-size: 14px; + color: white; + border-radius: 6px; + background-color: #C56E5B; + border: none; + margin-top: 4px; + + &:hover { + background-color: #E2944B; + } + } + + } + } + + //Style for search dropdown + + #recent-invites { + width: 282px; + max-height: 96px; + overflow-y: scroll; + border-radius: 4px; + background-color: rgb(51, 51, 51); + text-align: start; + position: absolute; + z-index: 2; + margin-top: 30px; + + @include light { + background-color: $color-background-light; + } + + .search-option { + width: 282px; + height: 32px; + align-content: flex-start; + padding: 5px; + padding-left: 15px; + position: relative; + + &.active { + background-color: $color-neutral; + } + + &:hover { + background-color: $color-border; + } + } + + } + + .divider { + height: 1.5px; + display: flex; + align-items: stretch !important; + border: none; + background-color: rgb(80, 80, 80); + } + + //Style for Invited Users + + .invited-users { + padding-top: 15px; + justify-content: center; + } + + .shared-emails { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + padding-left: 25px; + padding-right: 25px; + height: 100px; + overflow-y: scroll; + overflow-x: hidden; + + @include light { + color: #333333; + } + + .individual-shared-email { + @include card; + background-color: rgb(67, 67, 67); + padding: 4px 8px; + height: 26px; + display: flex; + flex-direction: row; + justify-content: center; + border-radius: 16px; + width: max-content; + max-width: 100%; + min-width: min-content; + font-size: .9em; + cursor: pointer; + + @include light { + background-color: rgba($color-neutral, 0.5); + opacity: 0.75; + } + } + } + + .email-and-status { + display: flex; + flex-direction: column; + } + + .status-tooltip { + background-color: rgba(0, 0, 0, 1); + border-radius: 4px; + } + + .email-text { + padding-top: 0.5px; + } + + .button-remove { + width: 10px; + height: 10px; + background-color: transparent; + position: relative; + align-self: center; + + .circle { + width: 12px; + height: 12px; + padding: 10px; + position: absolute; + position: absolute; + color: white; + + @include light { + color: rgba(128, 128, 128, 0.5); + } + } + + .remove { + width: 10px; + height: 10px; + padding: 15px; + color: rgb(67, 67, 67); + position: relative; + display: inline-flex; + + @include light { + color: #e5e5e5; + } + } + + &:hover { + .circle { + color: rgba(255, 255, 255, 0.25); + + @include light { + color: #333333; + } + } + + background-color: transparent; + } + } + + #Pending { + border-color: rgba(205, 165, 24, 0.5); + border-style: solid; + border-width: 1.5px; + } + + #Accepted { + border-color: rgba(34, 181, 49, 0.5); + border-style: solid; + border-width: 1.5px; + } +} \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index 47d8b7ab..1e29e3ab 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -27,3 +27,4 @@ export { default as EventBlocks } from './EventBlocks'; export { default as Attribution } from './Attribution'; export { default as Event } from './Event'; export { default as CourseNavMenu } from './CourseNavMenu'; +export { default as InvitationModal } from './InvitationModal'; From 75d4eea1684b87cbb941be5b13f6debf25f51caf Mon Sep 17 00:00:00 2001 From: Samarth Chandna <57265280+samarth52@users.noreply.github.com> Date: Fri, 24 Feb 2023 17:50:43 -0500 Subject: [PATCH 16/87] Send Email Invitation to Friends (#170) ### Summary Resolves #163 , Firebase Function PR: https://github.com/gt-scheduler/firebase-conf/pull/2 - Added a new firebase collection (`friend-invites`) that keeps track of pending schedule invites (information about senderId, friendId, term, version). - Created a firebase cloud function that checks arguments, creates a new invite record, and sends an email with a backlink generated from the new record's id (`gt-scheduler.org/invite/`). - Setup nodemailer to send emails - Added react-dom-node to handle dynamic routes to support the invite backlink - Created a firebase cloud function that deletes the invite record when the backlink is clicked. It is called by the dynamic route. This cloud function or the function in the dynamic route can be extended to perform the actual schedule-sharing updates ### Checklist - [x] An email is sent to the right user. If that user does not exist in the database, return an error. - [x] The email must have a message (such as, who sent them the invitation? to view which schedule? of which semester? etc.) - [x] The email should contain a click-back link (Note: we will implement what this click-back link would do later but right now just know that we'll use this email to confirm/establish the "friendship") ### New Cloud Functions New Cloud Functions expect the following in the request body: - create_friend_invitation: ``` { IDToken: string, // Firebase auth token of current user ( await auth.currentUser?.getIdToken() ), friendEmail: string, term: string, // can be found in ScheduleContext version: string, // schedule version id; can be found in ScheduleContext } ``` - handle_friend_invitation: ``` { inviteId: string // id of valid record in friend-invites collections } ``` ### How to Test - Follow the steps in the `gt-scheduler/firebase-conf` repo to run the firebase emulator - Sign in with email (have at least 2 accs) - Use the input box at the bottom of the Course Search tab to enter the second email and then press the button. Check the emulator logs and db updates - Check your email for the backlink and press it. Monitor the logs and db updates again --------- Co-authored-by: Nghi Ho <38119460+nhatnghiho@users.noreply.github.com> Co-authored-by: Hailey Ho --- .eslintignore | 2 +- package.json | 3 ++- src/components/CourseNavMenu/index.tsx | 1 + src/components/InviteBackLink/index.tsx | 33 ++++++++++++++++++++++++ src/components/RouterComponent/index.tsx | 17 ++++++++++++ src/components/index.ts | 2 ++ src/constants.ts | 3 ++- src/data/beans/Course.ts | 4 +-- src/index.tsx | 4 +-- yarn.lock | 20 ++++++++++++++ 10 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 src/components/InviteBackLink/index.tsx create mode 100644 src/components/RouterComponent/index.tsx diff --git a/.eslintignore b/.eslintignore index d568d086..08643bac 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,4 +8,4 @@ /tests/fixtures/** /tests/performance/** /tmp/** -/src/vendor/** +/src/vendor/** \ No newline at end of file diff --git a/package.json b/package.json index 16e4a302..34ad39c9 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react-map-gl": "^5.2.10", "react-overlays": "^5.1.1", "react-resize-panel": "^0.3.5", + "react-router-dom": "^6.8.1", "react-scripts": "5.0.1", "react-tooltip": "^5.5.1", "react-transition-group": "^4.4.2", @@ -82,7 +83,7 @@ "secrets:linux": "echo Enter Bitwarden Password: && read BW_PASSWORD && (bw logout || exit 0) && export BW_SESSION=`bw login product@bitsofgood.org $BW_PASSWORD --raw` && npm run secrets:get", "secrets:windows": "set /p BW_PASSWORD=Enter Bitwarden Password:&& (bw logout || VER>NUL) && npm run secrets:login", "secrets:login": "FOR /F %a IN ('bw login product@bitsofgood.org %BW_PASSWORD% --raw') DO SET BW_SESSION=%a && npm run secrets:get", - "secrets:get": "bw sync && bw get item gt-scheduler/.env.development.local | fx .notes > \".env\"" + "secrets:get": "bw sync && bw get item gt-scheduler/website/.env.development.local | fx .notes > \".env\"" }, "eslintConfig": { "extends": "react-app" diff --git a/src/components/CourseNavMenu/index.tsx b/src/components/CourseNavMenu/index.tsx index 07a74b70..1596bbaa 100644 --- a/src/components/CourseNavMenu/index.tsx +++ b/src/components/CourseNavMenu/index.tsx @@ -20,6 +20,7 @@ export default function CourseNavMenu({
{items.map((item, idx) => ( onChangeItem(idx)} diff --git a/src/components/InviteBackLink/index.tsx b/src/components/InviteBackLink/index.tsx new file mode 100644 index 00000000..c14a4984 --- /dev/null +++ b/src/components/InviteBackLink/index.tsx @@ -0,0 +1,33 @@ +import React, { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import axios from 'axios'; + +import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; + +const handleInvite = async (inviteId: string | undefined): Promise => + // The link should be changed to prod link, or we can choose the link based + // on environment + axios.post( + `${CLOUD_FUNCTION_BASE_URL}/handleFriendInvitation`, + { inviteId }, + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + +export default function InviteBackLink(): React.ReactElement { + const navigate = useNavigate(); + const { id } = useParams(); + + useEffect(() => { + if (id && navigate) { + handleInvite(id) + .then(() => navigate('/')) + .catch(() => navigate('/')); + } + }, [id, navigate]); + + return
; +} diff --git a/src/components/RouterComponent/index.tsx b/src/components/RouterComponent/index.tsx new file mode 100644 index 00000000..c7c7099a --- /dev/null +++ b/src/components/RouterComponent/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; + +import App from '../App'; +import InviteBackLink from '../InviteBackLink'; + +export default function RouterComponent(): React.ReactElement { + return ( + + + } /> + } /> + } /> + + + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 1e29e3ab..50953e9b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -27,4 +27,6 @@ export { default as EventBlocks } from './EventBlocks'; export { default as Attribution } from './Attribution'; export { default as Event } from './Event'; export { default as CourseNavMenu } from './CourseNavMenu'; +export { default as InviteBackLink } from './InviteBackLink'; +export { default as RouterComponent } from './RouterComponent'; export { default as InvitationModal } from './InvitationModal'; diff --git a/src/constants.ts b/src/constants.ts index 9938b06f..7d627a53 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -72,6 +72,7 @@ const CAMPUSES: Record = { const BACKEND_BASE_URL = 'https://gt-scheduler.azurewebsites.net'; const FIREBASE_PROJECT_ID = firebaseConfig.projectId || `gt-scheduler-web-dev`; +const CLOUD_FUNCTION_BASE_URL = `https://us-central1-${FIREBASE_PROJECT_ID}.cloudfunctions.net`; const LARGE_DESKTOP_BREAKPOINT = 1200; const DESKTOP_BREAKPOINT = 1024; @@ -87,7 +88,7 @@ export { DELIVERY_MODES, CAMPUSES, BACKEND_BASE_URL, - FIREBASE_PROJECT_ID, + CLOUD_FUNCTION_BASE_URL, DESKTOP_BREAKPOINT, LARGE_MOBILE_BREAKPOINT, LARGE_DESKTOP_BREAKPOINT, diff --git a/src/data/beans/Course.ts b/src/data/beans/Course.ts index b265763e..7357e3b9 100644 --- a/src/data/beans/Course.ts +++ b/src/data/beans/Course.ts @@ -14,14 +14,14 @@ import { isAxiosNetworkError, } from '../../utils/misc'; import { ErrorWithFields, softError } from '../../log'; -import { FIREBASE_PROJECT_ID } from '../../constants'; +import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; // This is actually a transparent read-through cache // in front of the Course Critique API's course data endpoint, // but it should behave the same as the real API. // See the implementation at: // https://github.com/gt-scheduler/firebase-conf/blob/main/functions/src/course_critique_cache.ts -const COURSE_CRITIQUE_API_URL = `https://us-central1-${FIREBASE_PROJECT_ID}.cloudfunctions.net/getCourseDataFromCourseCritique`; +const COURSE_CRITIQUE_API_URL = `${CLOUD_FUNCTION_BASE_URL}/getCourseDataFromCourseCritique`; const GPA_CACHE_LOCAL_STORAGE_KEY = 'course-gpa-cache-2'; const GPA_CACHE_EXPIRATION_DURATION_DAYS = 7; diff --git a/src/index.tsx b/src/index.tsx index eee92b7f..003c1795 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client'; import * as Sentry from '@sentry/react'; import { Integrations } from '@sentry/tracing'; -import App from './components/App'; +import RouterComponent from './components/RouterComponent'; import { ErrorWithFields } from './log'; import 'normalize.css'; @@ -31,4 +31,4 @@ if (container === null) { }); } const root = createRoot(container); -root.render(); +root.render(); diff --git a/yarn.lock b/yarn.lock index d0e589cf..02874b35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2071,6 +2071,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@remix-run/router@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.3.2.tgz#58cd2bd25df2acc16c628e1b6f6150ea6c7455bc" + integrity sha512-t54ONhl/h75X94SWsHGQ4G/ZrCEguKSRQr7DrjTciJXW0YU1QhlwYeycvK5JgkzlxmvrK7wq1NB/PLtHxoiDcA== + "@restart/hooks@^0.4.7": version "0.4.7" resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.7.tgz#d79ca6472c01ce04389fc73d4a79af1b5e33cd39" @@ -9729,6 +9734,21 @@ react-resize-panel@^0.3.5: lodash.debounce "^4.0.8" react-draggable "^4.0.3" +react-router-dom@^6.8.1: + version "6.8.1" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.8.1.tgz#7e136b67d9866f55999e9a8482c7008e3c575ac9" + integrity sha512-67EXNfkQgf34P7+PSb6VlBuaacGhkKn3kpE51+P6zYSG2kiRoumXEL6e27zTa9+PGF2MNXbgIUHTVlleLbIcHQ== + dependencies: + "@remix-run/router" "1.3.2" + react-router "6.8.1" + +react-router@6.8.1: + version "6.8.1" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.8.1.tgz#e362caf93958a747c649be1b47cd505cf28ca63e" + integrity sha512-Jgi8BzAJQ8MkPt8ipXnR73rnD7EmZ0HFFb7jdQU24TynGW1Ooqin2KVDN9voSC+7xhqbbCd2cjGUepb6RObnyg== + dependencies: + "@remix-run/router" "1.3.2" + react-scripts@5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-5.0.1.tgz#6285dbd65a8ba6e49ca8d651ce30645a6d980003" From d19cd13a1ef041f48da07598dba3e0f700d5dc7b Mon Sep 17 00:00:00 2001 From: Yatharth Bhargava Date: Thu, 2 Mar 2023 03:23:01 -0500 Subject: [PATCH 17/87] lint --- src/components/AppDataLoader/index.tsx | 21 ++-- src/components/AppDataLoader/stages.tsx | 1 - src/components/HeaderActionBar/index.tsx | 16 +-- src/components/InvitationModal/index.tsx | 101 +++++++++++++----- src/components/InviteBackLink/index.tsx | 20 ++-- src/contexts/account.ts | 1 + src/contexts/schedule.ts | 20 +++- src/data/hooks/useExtractScheduleVersion.ts | 1 + src/data/hooks/useFirebaseAuth.ts | 20 ++++ src/data/hooks/useMigrateScheduleData.test.ts | 2 + src/data/hooks/useVersionActions.ts | 41 ++++++- src/data/migrations/2to3.ts | 1 + src/data/types.ts | 6 ++ 13 files changed, 199 insertions(+), 52 deletions(-) diff --git a/src/components/AppDataLoader/index.tsx b/src/components/AppDataLoader/index.tsx index 302d9ec1..96e362fe 100644 --- a/src/components/AppDataLoader/index.tsx +++ b/src/components/AppDataLoader/index.tsx @@ -286,12 +286,17 @@ function ContextProvider({ }, [termScheduleData.versions]); // Get all version-related actions - const { addNewVersion, deleteVersion, renameVersion, cloneVersion } = - useVersionActions({ - updateTermScheduleData, - setVersion, - currentVersion, - }); + const { + addNewVersion, + deleteVersion, + renameVersion, + cloneVersion, + updateFriends, + } = useVersionActions({ + updateTermScheduleData, + setVersion, + currentVersion, + }); // Memoize the context values so that they are stable const scheduleContextValue = useMemo( @@ -301,6 +306,7 @@ function ContextProvider({ oscar, currentVersion, allVersionNames, + currentFriends: scheduleVersion.friends, ...castDraft(scheduleVersion.schedule), }, { @@ -310,6 +316,7 @@ function ContextProvider({ setCurrentVersion: setVersion, addNewVersion, deleteVersion, + updateFriends, renameVersion, cloneVersion, }, @@ -319,12 +326,14 @@ function ContextProvider({ oscar, currentVersion, allVersionNames, + scheduleVersion.friends, scheduleVersion.schedule, setTerm, patchSchedule, updateSchedule, setVersion, addNewVersion, + updateFriends, deleteVersion, renameVersion, cloneVersion, diff --git a/src/components/AppDataLoader/stages.tsx b/src/components/AppDataLoader/stages.tsx index 9fdd11c9..213e0b7f 100644 --- a/src/components/AppDataLoader/stages.tsx +++ b/src/components/AppDataLoader/stages.tsx @@ -256,7 +256,6 @@ export function StageLoadRawScheduleDataFromFirebase({ children, }: StageLoadRawScheduleDataFromFirebaseProps): React.ReactElement { const loadingState = useRawScheduleDataFromFirebase(accountState); - if (loadingState.type !== 'loaded') { return ( diff --git a/src/components/HeaderActionBar/index.tsx b/src/components/HeaderActionBar/index.tsx index b27fadfc..606e798d 100644 --- a/src/components/HeaderActionBar/index.tsx +++ b/src/components/HeaderActionBar/index.tsx @@ -88,13 +88,15 @@ export default function HeaderActionBar({ onClick: onCopyCrns, }); } - exportActions.push({ - label: 'Share Schedule', - icon: faShare, - onClick: (): void => { - setInvitationOpen(true); - }, - }); + if (accountState.type === 'signedIn') { + exportActions.push({ + label: 'Share Schedule', + icon: faShare, + onClick: (): void => { + setInvitationOpen(true); + }, + }); + } // On small mobile screens and on large desktop, // left-anchor the "Export" dropdown. diff --git a/src/components/InvitationModal/index.tsx b/src/components/InvitationModal/index.tsx index 3d2df628..458d1736 100644 --- a/src/components/InvitationModal/index.tsx +++ b/src/components/InvitationModal/index.tsx @@ -3,15 +3,20 @@ import React, { KeyboardEvent, useCallback, useMemo, + useContext, useState, } from 'react'; import { Tooltip as ReactTooltip } from 'react-tooltip'; import { faCircle, faClose } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { castDraft } from 'immer'; +import axios, { AxiosError } from 'axios'; +import { ScheduleContext } from '../../contexts'; import { classes } from '../../utils/misc'; import Modal from '../Modal'; import Button from '../Button'; +import { AccountContext, SignedIn } from '../../contexts/account'; import './stylesheet.scss'; @@ -19,26 +24,18 @@ import './stylesheet.scss'; * Inner content of the invitation modal. */ export function InvitationModalContent(): React.ReactElement { - // Array for testing style of shared emails - // eslint-disable-next-line - const [emails, setEmails] = useState([ - ['user1@example.com', 'Pending'], - ['user2@example.com', 'Accepted'], - ['ReallyLongNameThatWillNotFitInRowAbove@example.com', 'Accepted'], - ['goodEmail@gmail.com', 'Accepted'], - ['user12@example.com', 'Pending'], - ['user22@example.com', 'Accepted'], - ['2ReallyLongNameThatWillNotFitInRowAbove@example.com', 'Accepted'], - ['2goodEmail@gmail.com', 'Accepted'], - ]); + const [{ currentFriends, currentVersion, term }, { updateFriends }] = + useContext(ScheduleContext); + const accountContext = useContext(AccountContext); + + const emails = Object.keys(currentFriends ?? {}).map((friend: string) => { + return [ + currentFriends[friend]!.email, + currentFriends[friend]!.status, + friend, + ]; + }); - // Array to test invalid email - const validUsers = [ - 'user1@example.com', - 'user2@example.com', - 'ReallyLongNameThatWillNotFitInRowAbove@example.com', - 'goodEmail@gmail.com', - ]; const [input, setInput] = useState(''); const [validMessage, setValidMessage] = useState(''); const [validClassName, setValidClassName] = useState(''); @@ -122,16 +119,61 @@ export function InvitationModalContent(): React.ReactElement { [searchResults] ); - function verifyUser(): void { - if (validUsers.includes(input)) { - setValidMessage('Successfully sent!'); - setValidClassName('valid-email'); - setInput(''); + const sendInvitation = async (): Promise => { + const IdToken = await (accountContext as SignedIn).getToken(); + axios + .post( + 'http://127.0.0.1:5001/gt-scheduler-web-dev/us-central1/createFriendInvitation', + { + term, + friendEmail: input, + IDToken: IdToken, + version: currentVersion, + } + ) + .then((res) => { + setValidMessage('Successfully sent!'); + setValidClassName('valid-email'); + }) + .catch((err: AxiosError) => { + setValidClassName('invalid-email'); + // if (err.response && err.response.data.message) { + // setValidMessage(err.response.data.message); + // return; + // } + + setValidMessage('Error sending invitation. Please try again later.'); + }); + }; + + // verify email with a regex and send invitation if valid + const verifyEmail = (): void => { + if ( + // eslint-disable-next-line + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + input + ) + ) { + sendInvitation() + .then(() => { + setInput(''); + }) + .catch((err) => { + setValidMessage('Error sending invitation. Please try again later.'); + setValidClassName('invalid-email'); + }); } else { setValidMessage('Invalid Email'); setValidClassName('invalid-email'); } - } + }; + + // delete friend from record of friends + const handleDelete = (friendId: string): void => { + const newFriends = castDraft(currentFriends); + delete newFriends[friendId]; + updateFriends(currentVersion, newFriends); + }; return (
@@ -174,7 +216,7 @@ export function InvitationModalContent(): React.ReactElement { )} {validMessage}
-
@@ -189,7 +231,12 @@ export function InvitationModalContent(): React.ReactElement {
{element[0]} - diff --git a/src/components/InviteBackLink/index.tsx b/src/components/InviteBackLink/index.tsx index c14a4984..58fe15e7 100644 --- a/src/components/InviteBackLink/index.tsx +++ b/src/components/InviteBackLink/index.tsx @@ -7,15 +7,17 @@ import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; const handleInvite = async (inviteId: string | undefined): Promise => // The link should be changed to prod link, or we can choose the link based // on environment - axios.post( - `${CLOUD_FUNCTION_BASE_URL}/handleFriendInvitation`, - { inviteId }, - { - headers: { - 'Content-Type': 'application/json', - }, - } - ); + axios + .post( + `http://127.0.0.1:5001/gt-scheduler-web-dev/us-central1/handleFriendInvitation`, + { inviteId } + ) + .then((res) => { + console.log(res); + }) + .catch((err) => { + console.error(err); + }); export default function InviteBackLink(): React.ReactElement { const navigate = useNavigate(); diff --git a/src/contexts/account.ts b/src/contexts/account.ts index a98beed7..5bccfd99 100644 --- a/src/contexts/account.ts +++ b/src/contexts/account.ts @@ -7,6 +7,7 @@ export type SignedOut = { export type SignedIn = { type: 'signedIn'; signOut: () => void; + getToken: () => Promise; name: string | null; provider: string | null; email: string | null; diff --git a/src/contexts/schedule.ts b/src/contexts/schedule.ts index 12b0a8d5..d0d0e5e8 100644 --- a/src/contexts/schedule.ts +++ b/src/contexts/schedule.ts @@ -3,12 +3,13 @@ import { Draft, Immutable } from 'immer'; import { Oscar } from '../data/beans'; import { EMPTY_OSCAR } from '../data/beans/Oscar'; -import { defaultSchedule, Schedule } from '../data/types'; +import { defaultSchedule, FriendShareData, Schedule } from '../data/types'; import { ErrorWithFields } from '../log'; type ExtraData = { term: string; currentVersion: string; + currentFriends: Record; allVersionNames: { id: string; name: string }[]; // `oscar` is included below as a separate type }; @@ -28,6 +29,10 @@ export type ScheduleContextSetters = { deleteVersion: (id: string) => void; renameVersion: (id: string, newName: string) => void; cloneVersion: (id: string, newName: string) => void; + updateFriends: ( + versionId: string, + newFriends: Record + ) => void; }; export type ScheduleContextValue = [ ScheduleContextData, @@ -37,6 +42,7 @@ export const ScheduleContext = React.createContext([ { term: '', currentVersion: '', + currentFriends: {}, allVersionNames: [], oscar: EMPTY_OSCAR, ...defaultSchedule, @@ -71,6 +77,18 @@ export const ScheduleContext = React.createContext([ }, }); }, + updateFriends: ( + versionId: string, + newFriends: Record + ): void => { + throw new ErrorWithFields({ + message: 'empty ScheduleContext.deleteFriendRecord value being used', + fields: { + versionId, + newFriends, + }, + }); + }, addNewVersion: (name: string, select?: boolean): string => { throw new ErrorWithFields({ message: 'empty ScheduleContext.addNewVersion value being used', diff --git a/src/data/hooks/useExtractScheduleVersion.ts b/src/data/hooks/useExtractScheduleVersion.ts index 434337c9..e34a497a 100644 --- a/src/data/hooks/useExtractScheduleVersion.ts +++ b/src/data/hooks/useExtractScheduleVersion.ts @@ -62,6 +62,7 @@ export default function useExtractScheduleVersion({ const id = generateScheduleVersionId(); draft.versions[id] = { name: 'Primary', + friends: {}, createdAt: new Date().toISOString(), schedule: castDraft(defaultSchedule), }; diff --git a/src/data/hooks/useFirebaseAuth.ts b/src/data/hooks/useFirebaseAuth.ts index 054e0022..6e66893c 100644 --- a/src/data/hooks/useFirebaseAuth.ts +++ b/src/data/hooks/useFirebaseAuth.ts @@ -32,6 +32,26 @@ export default function useFirebaseAuth(): LoadingState { name: user.displayName, email: user.email, id: user.uid, + getToken: (): Promise => { + if (firebase.auth().currentUser === null) { + return Promise.reject( + new ErrorWithFields({ + message: 'firebase.auth().currentUser is null', + }) + ); + } + return firebase + .auth() + .currentUser!.getIdToken() + .catch((err) => { + softError( + new ErrorWithFields({ + message: 'call to firebase.auth().getIdToken() failed', + source: err, + }) + ); + }); + }, provider, signOut: () => { firebase diff --git a/src/data/hooks/useMigrateScheduleData.test.ts b/src/data/hooks/useMigrateScheduleData.test.ts index 7cb66cf4..1568cb3e 100644 --- a/src/data/hooks/useMigrateScheduleData.test.ts +++ b/src/data/hooks/useMigrateScheduleData.test.ts @@ -83,6 +83,7 @@ describe('useMigrateScheduleData', () => { name: 'Primary', // January 1, 1970 at 0 seconds createdAt: '1970-01-01T00:00:00.000Z', + friends: {}, schedule: { desiredCourses: ['CS 1100', 'CS 1331'], pinnedCrns: [ @@ -140,6 +141,7 @@ describe('useMigrateScheduleData', () => { sv_48RC7kqO7YDiBK66qXOd: { name: 'Primary', createdAt: '2021-09-16T00:00:46.191Z', + friends: {}, schedule: { desiredCourses: ['CS 1100', 'CS 1331'], pinnedCrns: [ diff --git a/src/data/hooks/useVersionActions.ts b/src/data/hooks/useVersionActions.ts index 19a1ea44..c3a771d4 100644 --- a/src/data/hooks/useVersionActions.ts +++ b/src/data/hooks/useVersionActions.ts @@ -4,6 +4,7 @@ import { useCallback } from 'react'; import { softError, ErrorWithFields } from '../../log'; import { defaultSchedule, + FriendShareData, generateScheduleVersionId, TermScheduleData, } from '../types'; @@ -13,6 +14,10 @@ export type HookResult = { deleteVersion: (id: string) => void; renameVersion: (id: string, newName: string) => void; cloneVersion: (id: string, newName: string) => void; + updateFriends: ( + versionId: string, + newFriends: Record + ) => void; }; /** @@ -40,6 +45,7 @@ export default function useVersionActions({ updateTermScheduleData((draft) => { draft.versions[id] = { name, + friends: {}, schedule: castDraft(defaultSchedule), createdAt: new Date().toISOString(), }; @@ -90,6 +96,7 @@ export default function useVersionActions({ const newId = generateScheduleVersionId(); draft.versions[newId] = { name: 'Primary', + friends: {}, createdAt: new Date().toISOString(), schedule: castDraft(defaultSchedule), }; @@ -181,5 +188,37 @@ export default function useVersionActions({ [updateTermScheduleData, addNewVersion] ); - return { addNewVersion, deleteVersion, renameVersion, cloneVersion }; + const updateFriends = useCallback( + (versionId: string, newFriends: Record): void => { + updateTermScheduleData((draft) => { + const existingDraft = draft.versions[versionId]; + if (existingDraft === undefined) { + softError( + new ErrorWithFields({ + message: + "deleteFriendRecord called with version name that doesn't exist; ignoring", + fields: { + allVersionNames: Object.entries(draft.versions).map( + ([versionId_, { name }]) => ({ id: versionId_, name }) + ), + versionId, + versionCount: Object.keys(draft.versions).length, + }, + }) + ); + return; + } + existingDraft.friends = castDraft(newFriends); + }); + }, + [updateTermScheduleData] + ); + + return { + addNewVersion, + deleteVersion, + renameVersion, + cloneVersion, + updateFriends, + }; } diff --git a/src/data/migrations/2to3.ts b/src/data/migrations/2to3.ts index 1e5f5e30..291a8048 100644 --- a/src/data/migrations/2to3.ts +++ b/src/data/migrations/2to3.ts @@ -37,6 +37,7 @@ export default function migrate2To3( const version3ScheduleVersion: Version3ScheduleVersion = { name: version2ScheduleVersion.name, createdAt: version2ScheduleVersion.createdAt, + friends: {}, schedule: { ...version2ScheduleVersion.schedule, ...newFields, diff --git a/src/data/types.ts b/src/data/types.ts index d1af52b1..055ed213 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -134,10 +134,16 @@ export interface Version3TermScheduleData { export interface Version3ScheduleVersion { name: string; + friends: Record; createdAt: string; schedule: Version3Schedule; } +export interface FriendShareData { + status: 'Pending' | 'Accepted'; + email: string; +} + export interface Version3Schedule { desiredCourses: string[]; pinnedCrns: string[]; From 490718bed3da28443b39f6ba0e802806b28d9954 Mon Sep 17 00:00:00 2001 From: Yatharth Bhargava Date: Thu, 2 Mar 2023 15:59:00 -0500 Subject: [PATCH 18/87] lint --- src/components/InvitationModal/index.tsx | 46 +++++++++++------------- src/components/InviteBackLink/index.tsx | 23 ++++++------ src/data/hooks/useFirebaseAuth.ts | 22 ++++++------ src/data/types.ts | 4 +++ 4 files changed, 46 insertions(+), 49 deletions(-) diff --git a/src/components/InvitationModal/index.tsx b/src/components/InvitationModal/index.tsx index 458d1736..3eeb2d7e 100644 --- a/src/components/InvitationModal/index.tsx +++ b/src/components/InvitationModal/index.tsx @@ -12,6 +12,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { castDraft } from 'immer'; import axios, { AxiosError } from 'axios'; +import { ApiErrorResponse } from '../../data/types'; import { ScheduleContext } from '../../contexts'; import { classes } from '../../utils/misc'; import Modal from '../Modal'; @@ -28,14 +29,6 @@ export function InvitationModalContent(): React.ReactElement { useContext(ScheduleContext); const accountContext = useContext(AccountContext); - const emails = Object.keys(currentFriends ?? {}).map((friend: string) => { - return [ - currentFriends[friend]!.email, - currentFriends[friend]!.status, - friend, - ]; - }); - const [input, setInput] = useState(''); const [validMessage, setValidMessage] = useState(''); const [validClassName, setValidClassName] = useState(''); @@ -131,16 +124,18 @@ export function InvitationModalContent(): React.ReactElement { version: currentVersion, } ) - .then((res) => { + .then(() => { setValidMessage('Successfully sent!'); setValidClassName('valid-email'); }) - .catch((err: AxiosError) => { + .catch((err) => { setValidClassName('invalid-email'); - // if (err.response && err.response.data.message) { - // setValidMessage(err.response.data.message); - // return; - // } + const error = err as AxiosError; + if (error.response) { + const apiError = error.response.data as ApiErrorResponse; + setValidMessage(apiError.message); + return; + } setValidMessage('Error sending invitation. Please try again later.'); }); @@ -150,15 +145,13 @@ export function InvitationModalContent(): React.ReactElement { const verifyEmail = (): void => { if ( // eslint-disable-next-line - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - input - ) + /^\S+@\S+\.\S+$/.test(input) ) { sendInvitation() .then(() => { setInput(''); }) - .catch((err) => { + .catch(() => { setValidMessage('Error sending invitation. Please try again later.'); setValidClassName('invalid-email'); }); @@ -227,27 +220,30 @@ export function InvitationModalContent(): React.ReactElement { Users Invited to View Primary

- {emails.map((element) => ( -
-
- {element[0]} + {Object.keys(currentFriends).map((friend) => ( +
+
+ {currentFriends[friend]?.email} - Status: {element[1]} + Status: {currentFriends[friend]?.status}
diff --git a/src/components/InviteBackLink/index.tsx b/src/components/InviteBackLink/index.tsx index 58fe15e7..e05d8a02 100644 --- a/src/components/InviteBackLink/index.tsx +++ b/src/components/InviteBackLink/index.tsx @@ -2,22 +2,21 @@ import React, { useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import axios from 'axios'; -import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; +// import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; const handleInvite = async (inviteId: string | undefined): Promise => // The link should be changed to prod link, or we can choose the link based // on environment - axios - .post( - `http://127.0.0.1:5001/gt-scheduler-web-dev/us-central1/handleFriendInvitation`, - { inviteId } - ) - .then((res) => { - console.log(res); - }) - .catch((err) => { - console.error(err); - }); + axios.post( + `http://127.0.0.1:5001/gt-scheduler-web-dev/us-central1/handleFriendInvitation`, + { inviteId } + ); +// .then((res) => { +// console.log(res); +// }) +// .catch((err) => { +// console.error(err); +// }); export default function InviteBackLink(): React.ReactElement { const navigate = useNavigate(); diff --git a/src/data/hooks/useFirebaseAuth.ts b/src/data/hooks/useFirebaseAuth.ts index 6e66893c..ede940c7 100644 --- a/src/data/hooks/useFirebaseAuth.ts +++ b/src/data/hooks/useFirebaseAuth.ts @@ -33,24 +33,22 @@ export default function useFirebaseAuth(): LoadingState { email: user.email, id: user.uid, getToken: (): Promise => { - if (firebase.auth().currentUser === null) { + const { currentUser } = firebase.auth(); + if (!currentUser) { return Promise.reject( new ErrorWithFields({ message: 'firebase.auth().currentUser is null', }) ); } - return firebase - .auth() - .currentUser!.getIdToken() - .catch((err) => { - softError( - new ErrorWithFields({ - message: 'call to firebase.auth().getIdToken() failed', - source: err, - }) - ); - }); + return currentUser.getIdToken().catch((err) => { + softError( + new ErrorWithFields({ + message: 'call to firebase.auth().getIdToken() failed', + source: err, + }) + ); + }); }, provider, signOut: () => { diff --git a/src/data/types.ts b/src/data/types.ts index 055ed213..9766eaa6 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -160,3 +160,7 @@ export interface FriendData { export interface FriendTermData { accessibleSchedules: Record; } + +export type ApiErrorResponse = { + message: string; +}; From 5aa39ca2e7c54c257ff14ac6a1e4c3eb0cb7c6a8 Mon Sep 17 00:00:00 2001 From: Yatharth Bhargava Date: Mon, 27 Mar 2023 11:46:11 -0400 Subject: [PATCH 19/87] fix tests --- src/data/hooks/useMigrateScheduleData.test.ts | 1 + src/data/migrations/2to3.test.ts | 3 +++ src/data/migrations/index.test.ts | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/src/data/hooks/useMigrateScheduleData.test.ts b/src/data/hooks/useMigrateScheduleData.test.ts index 1568cb3e..8362ede3 100644 --- a/src/data/hooks/useMigrateScheduleData.test.ts +++ b/src/data/hooks/useMigrateScheduleData.test.ts @@ -188,6 +188,7 @@ describe('useMigrateScheduleData', () => { colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, + friends: {}, }, }, }, diff --git a/src/data/migrations/2to3.test.ts b/src/data/migrations/2to3.test.ts index b30fc690..992504c6 100644 --- a/src/data/migrations/2to3.test.ts +++ b/src/data/migrations/2to3.test.ts @@ -127,6 +127,7 @@ describe('migrate2to3', () => { colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, + friends: {}, }, sv_00000000000000000001: { name: 'Secondary', @@ -140,6 +141,7 @@ describe('migrate2to3', () => { colorMap: {}, sortingOptionIndex: 0, }, + friends: {}, }, sv_00000000000000000002: { name: 'Tertiary', @@ -153,6 +155,7 @@ describe('migrate2to3', () => { colorMap: {}, sortingOptionIndex: 0, }, + friends: {}, }, }, }, diff --git a/src/data/migrations/index.test.ts b/src/data/migrations/index.test.ts index 48c0684d..9c157b74 100644 --- a/src/data/migrations/index.test.ts +++ b/src/data/migrations/index.test.ts @@ -62,6 +62,7 @@ describe('migrateScheduleData', () => { colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, + friends: {}, }, }, }, @@ -155,6 +156,7 @@ describe('migrateScheduleData', () => { colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, + friends: {}, }, }, }, @@ -222,6 +224,7 @@ describe('migrateScheduleData', () => { colorMap: {}, sortingOptionIndex: 0, }, + friends: {}, }, }, }, @@ -245,6 +248,7 @@ describe('migrateScheduleData', () => { colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, + friends: {}, }, }, }, From 7ed7ad893121077c27d9d2b796b15d06929f9c96 Mon Sep 17 00:00:00 2001 From: Yatharth Bhargava Date: Mon, 27 Mar 2023 11:56:12 -0400 Subject: [PATCH 20/87] fix all tests --- src/components/AppDataLoader/index.tsx | 2 +- src/data/migrations/index.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/AppDataLoader/index.tsx b/src/components/AppDataLoader/index.tsx index 96e362fe..b42bcfca 100644 --- a/src/components/AppDataLoader/index.tsx +++ b/src/components/AppDataLoader/index.tsx @@ -306,7 +306,7 @@ function ContextProvider({ oscar, currentVersion, allVersionNames, - currentFriends: scheduleVersion.friends, + currentFriends: scheduleVersion.friends ?? {}, ...castDraft(scheduleVersion.schedule), }, { diff --git a/src/data/migrations/index.test.ts b/src/data/migrations/index.test.ts index 9c157b74..6336bcde 100644 --- a/src/data/migrations/index.test.ts +++ b/src/data/migrations/index.test.ts @@ -132,6 +132,7 @@ describe('migrateScheduleData', () => { colorMap: {}, sortingOptionIndex: 0, }, + friends: {}, }, }, }, From f46af2cb1b3a96bb2ea31aa8d4e0adf0d8c03422 Mon Sep 17 00:00:00 2001 From: Yatharth Bhargava Date: Mon, 27 Mar 2023 20:12:16 -0400 Subject: [PATCH 21/87] make changes --- src/components/AppDataLoader/index.tsx | 6 +- src/components/InvitationModal/index.tsx | 177 ++----- .../InvitationModal/stylesheet.scss | 441 +++++++++--------- src/contexts/schedule.ts | 12 +- src/data/hooks/useFirebaseAuth.ts | 11 +- src/data/hooks/useVersionActions.ts | 26 +- 6 files changed, 294 insertions(+), 379 deletions(-) diff --git a/src/components/AppDataLoader/index.tsx b/src/components/AppDataLoader/index.tsx index b42bcfca..0647a48f 100644 --- a/src/components/AppDataLoader/index.tsx +++ b/src/components/AppDataLoader/index.tsx @@ -291,7 +291,7 @@ function ContextProvider({ deleteVersion, renameVersion, cloneVersion, - updateFriends, + deleteFriendRecord, } = useVersionActions({ updateTermScheduleData, setVersion, @@ -316,7 +316,7 @@ function ContextProvider({ setCurrentVersion: setVersion, addNewVersion, deleteVersion, - updateFriends, + deleteFriendRecord, renameVersion, cloneVersion, }, @@ -333,7 +333,7 @@ function ContextProvider({ updateSchedule, setVersion, addNewVersion, - updateFriends, + deleteFriendRecord, deleteVersion, renameVersion, cloneVersion, diff --git a/src/components/InvitationModal/index.tsx b/src/components/InvitationModal/index.tsx index 3eeb2d7e..d2b8ec42 100644 --- a/src/components/InvitationModal/index.tsx +++ b/src/components/InvitationModal/index.tsx @@ -2,9 +2,9 @@ import React, { ChangeEvent, KeyboardEvent, useCallback, - useMemo, useContext, useState, + useRef, } from 'react'; import { Tooltip as ReactTooltip } from 'react-tooltip'; import { faCircle, faClose } from '@fortawesome/free-solid-svg-icons'; @@ -25,135 +25,69 @@ import './stylesheet.scss'; * Inner content of the invitation modal. */ export function InvitationModalContent(): React.ReactElement { - const [{ currentFriends, currentVersion, term }, { updateFriends }] = + const [{ currentFriends, currentVersion, term }, { deleteFriendRecord }] = useContext(ScheduleContext); const accountContext = useContext(AccountContext); - const [input, setInput] = useState(''); + const input = useRef(null); const [validMessage, setValidMessage] = useState(''); const [validClassName, setValidClassName] = useState(''); - // Boolean to hide and open search dropdown - const [hidden, setHidden] = useState(true); - - // Array for testing dropdown of recent invites - // eslint-disable-next-line - const [recentInvites, setRecentInvites] = useState([ - 'user1@example.com', - 'user2@example.com', - ]); - const [activeIndex, setActiveIndex] = useState(-1); - const handleChangeSearch = useCallback((e: ChangeEvent) => { - let search = e.target.value.trim(); - const results = /^([A-Z]+)(\d.*)$/i.exec(search); - if (results != null) { - const [, email, number] = results as unknown as [string, string, string]; - search = `${email}${number}`; - } - setHidden(false); - setInput(search); setValidMessage(''); - setActiveIndex(-1); + setValidClassName(''); }, []); - const searchResults = useMemo(() => { - if (!input) return recentInvites; - const results = /^([A-Z]+) ?((\d.*)?)$/i.exec(input?.toUpperCase()); - if (!results) { - return []; + const handleKeyDown = useCallback((e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + verifyEmail(); + break; + default: + return; } - - return recentInvites.filter((invite) => { - const searchMatch = recentInvites.includes(invite); - return searchMatch; - }); - }, [input, recentInvites]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - setHidden(false); - setInput( - searchResults[ - Math.min(activeIndex + 1, searchResults.length - 1) - ] as string - ); - setActiveIndex(Math.min(activeIndex + 1, searchResults.length - 1)); - break; - case 'ArrowUp': - setHidden(false); - setInput(searchResults[Math.max(activeIndex - 1, 0)] as string); - setActiveIndex(Math.max(activeIndex - 1, 0)); - break; - case 'Escape': - setHidden(true); - break; - case 'Enter': - setHidden(true); - break; - default: - return; - } - e.preventDefault(); - }, - [searchResults, activeIndex] - ); - - const handleCloseDropdown = useCallback( - (index?: number) => { - if (index !== undefined) { - setInput(searchResults[index] as string); - setActiveIndex(index); - } - setHidden(true); - }, - [searchResults] - ); + e.preventDefault(); + }, []); const sendInvitation = async (): Promise => { const IdToken = await (accountContext as SignedIn).getToken(); - axios - .post( - 'http://127.0.0.1:5001/gt-scheduler-web-dev/us-central1/createFriendInvitation', - { - term, - friendEmail: input, - IDToken: IdToken, - version: currentVersion, - } - ) - .then(() => { - setValidMessage('Successfully sent!'); - setValidClassName('valid-email'); - }) - .catch((err) => { - setValidClassName('invalid-email'); - const error = err as AxiosError; - if (error.response) { - const apiError = error.response.data as ApiErrorResponse; - setValidMessage(apiError.message); - return; - } - - setValidMessage('Error sending invitation. Please try again later.'); - }); + return axios.post( + 'http://127.0.0.1:5001/gt-scheduler-web-dev/us-central1/createFriendInvitation', + { + term, + friendEmail: input.current?.value, + IDToken: IdToken, + version: currentVersion, + } + ); }; // verify email with a regex and send invitation if valid const verifyEmail = (): void => { + console.log(input.current?.value); if ( // eslint-disable-next-line - /^\S+@\S+\.\S+$/.test(input) + input.current && + /^\S+@\S+\.\S+$/.test(input.current.value) ) { sendInvitation() .then(() => { - setInput(''); + setValidMessage('Successfully sent!'); + setValidClassName('valid-email'); + if (input.current) { + input.current.value = ''; + } }) - .catch(() => { - setValidMessage('Error sending invitation. Please try again later.'); + .catch((err) => { + console.log(err); setValidClassName('invalid-email'); + const error = err as AxiosError; + if (error.response) { + const apiError = error.response.data as ApiErrorResponse; + setValidMessage(apiError.message); + return; + } + setValidMessage('Error sending invitation. Please try again later.'); }); } else { setValidMessage('Invalid Email'); @@ -163,9 +97,7 @@ export function InvitationModalContent(): React.ReactElement { // delete friend from record of friends const handleDelete = (friendId: string): void => { - const newFriends = castDraft(currentFriends); - delete newFriends[friendId]; - updateFriends(currentVersion, newFriends); + deleteFriendRecord(currentVersion, friendId); }; return ( @@ -183,30 +115,14 @@ export function InvitationModalContent(): React.ReactElement { type="email" id="email" key="email" - value={input} + ref={input} className="email" placeholder="recipient@example.com" list="recent-invites" - onChange={handleChangeSearch} onFocus={handleChangeSearch} onKeyDown={handleKeyDown} - onBlur={(): void => handleCloseDropdown()} + onChange={handleChangeSearch} /> - {!hidden && ( -
- {searchResults.map((element, index) => ( -
handleCloseDropdown(index)} - > - {element} -
- ))} -
- )} {validMessage}