diff --git a/backend/middlewares/validators/courseValidators.ts b/backend/middlewares/validators/courseValidators.ts index 1c1d5d3c..eab20c10 100644 --- a/backend/middlewares/validators/courseValidators.ts +++ b/backend/middlewares/validators/courseValidators.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { getApiValidationError, validatePrimitive } from "./util"; import CourseUnitService from "../../services/implementations/courseUnitService"; +import CourseModuleService from "../../services/implementations/courseModuleService"; /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ export const createCourseUnitDtoValidator = async ( @@ -36,6 +37,28 @@ export const updateCourseUnitDtoValidator = async ( return next(); }; +export const coursePageDtoValidator = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + if (!validatePrimitive(req.body.type, "string")) { + return res.status(400).send(getApiValidationError("type", "string")); + } + if (req.body.type === "Lesson") { + if (!validatePrimitive(req.body.source, "string")) { + return res.status(400).send(getApiValidationError("source", "string")); + } + } else if (req.body.type === "Activity") { + if (!req.body.elements) { + return res.status(400).send("Elements field missing for Activity Page"); + } + } else { + return res.status(400).send(`Invalid page type "${req.body.type}"`); + } + return next(); +}; + export const moduleBelongsToUnitValidator = async ( req: Request, res: Response, @@ -56,3 +79,25 @@ export const moduleBelongsToUnitValidator = async ( } return next(); }; + +export const pageBelongsToModuleValidator = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + const { moduleId, pageId } = req.params; + + const courseModuleService: CourseModuleService = new CourseModuleService(); + + const courseModule = await courseModuleService.getCourseModule(moduleId); + + if (!courseModule.pages.includes(pageId)) { + return res + .status(404) + .send( + `Page with ID ${pageId} is not found in module with ID ${moduleId}`, + ); + } + + return next(); +}; diff --git a/backend/models/courseelement.mgmodel.ts b/backend/models/courseelement.mgmodel.ts index 9b3848af..3d204594 100644 --- a/backend/models/courseelement.mgmodel.ts +++ b/backend/models/courseelement.mgmodel.ts @@ -21,19 +21,19 @@ const CourseElementSchema: Schema = new Schema({ "MultipleChoice", "Matching", "Table", - "RichText", + "Text", "Image", ], }, }); -export interface RichTextElement extends CourseElement { - type: DisplayElementType.RichText; +export interface TextElement extends CourseElement { + type: DisplayElementType.Text; content: string; backgroundColor?: string; } -const RichTextElementSchema: Schema = new Schema({ +const TextElementSchema: Schema = new Schema({ content: { type: String, required: true, @@ -173,9 +173,9 @@ const CourseElementModel = mongoose.model( CourseElementSchema, ); -export const RichTextElementModel = CourseElementModel.discriminator( - "RichTextElement", - RichTextElementSchema, +export const TextElementModel = CourseElementModel.discriminator( + "TextElement", + TextElementSchema, ); export const ImageElementModel = CourseElementModel.discriminator( diff --git a/backend/models/coursemodule.mgmodel.ts b/backend/models/coursemodule.mgmodel.ts index 8c4feac6..bd0b6234 100644 --- a/backend/models/coursemodule.mgmodel.ts +++ b/backend/models/coursemodule.mgmodel.ts @@ -28,6 +28,7 @@ export const CourseModuleSchema: Schema = new Schema({ CourseModuleSchema.set("toObject", { virtuals: true, versionKey: false, + flattenObjectIds: true, transform: (_doc: Document, ret: Record) => { // eslint-disable-next-line no-underscore-dangle delete ret._id; diff --git a/backend/models/coursepage.mgmodel.ts b/backend/models/coursepage.mgmodel.ts index c1973bac..e78cf462 100644 --- a/backend/models/coursepage.mgmodel.ts +++ b/backend/models/coursepage.mgmodel.ts @@ -1,43 +1,42 @@ import mongoose, { Schema, Document } from "mongoose"; +import { Element, PageType } from "../types/courseTypes"; +import { validateElementData } from "../utilities/courseUtils"; -export type ElementSkeleton = { - id: string; - x: number; - y: number; - w: number; - h: number; - content: string; -}; - -const ElementSkeletonSchema: Schema = new Schema({ - x: { - type: Number, - required: true, - }, - y: { - type: Number, - required: true, - }, - w: { - type: Number, - required: true, - }, - h: { - type: Number, - required: true, - }, - content: { - type: String, - required: true, +const ElementSchema: Schema = new Schema( + { + x: { + type: Number, + required: true, + }, + y: { + type: Number, + required: true, + }, + w: { + type: Number, + required: true, + }, + h: { + type: Number, + required: true, + }, + data: { + type: Schema.Types.Mixed, + required: true, + }, }, -}); + { _id: false }, +); -export type PageType = "Lesson" | "Activity"; +ElementSchema.path("data").validate((value) => { + if (!value || !value.type) { + return false; + } + return validateElementData(value); +}); export interface CoursePage extends Document { id: string; - title: string; - displayIndex: number; type: PageType; } @@ -46,7 +45,7 @@ export interface LessonPage extends CoursePage { } export interface ActivityPage extends CoursePage { - layout: [ElementSkeleton]; + elements: Element[]; } const baseOptions = { @@ -55,14 +54,6 @@ const baseOptions = { export const CoursePageSchema: Schema = new Schema( { - title: { - type: String, - required: true, - }, - displayIndex: { - type: Number, - required: true, - }, type: { type: String, required: true, @@ -80,8 +71,8 @@ const LessonPageSchema: Schema = new Schema({ }); const ActivityPageSchema: Schema = new Schema({ - layout: { - type: [ElementSkeletonSchema], + elements: { + type: [ElementSchema], required: true, }, }); diff --git a/backend/models/courseunit.mgmodel.ts b/backend/models/courseunit.mgmodel.ts index 68c2238a..8e44f084 100644 --- a/backend/models/courseunit.mgmodel.ts +++ b/backend/models/courseunit.mgmodel.ts @@ -28,6 +28,7 @@ const CourseUnitSchema: Schema = new Schema({ CourseUnitSchema.set("toObject", { virtuals: true, versionKey: false, + flattenObjectIds: true, transform: (_doc: Document, ret: Record) => { // eslint-disable-next-line no-underscore-dangle delete ret._id; diff --git a/backend/rest/courseRoutes.ts b/backend/rest/courseRoutes.ts index 5e6b7e6d..424ea3bf 100644 --- a/backend/rest/courseRoutes.ts +++ b/backend/rest/courseRoutes.ts @@ -2,16 +2,20 @@ import { Router } from "express"; import CourseUnitService from "../services/implementations/courseUnitService"; import { getErrorMessage } from "../utilities/errorUtils"; import { + coursePageDtoValidator, createCourseUnitDtoValidator, moduleBelongsToUnitValidator, + pageBelongsToModuleValidator, updateCourseUnitDtoValidator, } from "../middlewares/validators/courseValidators"; import { isAuthorizedByRole } from "../middlewares/auth"; import CourseModuleService from "../services/implementations/courseModuleService"; +import CoursePageService from "../services/implementations/coursePageService"; const courseRouter: Router = Router(); const courseUnitService: CourseUnitService = new CourseUnitService(); const courseModuleService: CourseModuleService = new CourseModuleService(); +const coursePageService: CoursePageService = new CoursePageService(); courseRouter.get( "/", @@ -148,4 +152,72 @@ courseRouter.delete( }, ); +courseRouter.get( + "/:unitId/:moduleId", + isAuthorizedByRole(new Set(["Administrator", "Facilitator", "Learner"])), + moduleBelongsToUnitValidator, + async (req, res) => { + try { + const coursePages = await coursePageService.getCoursePages( + req.params.moduleId, + ); + res.status(200).json(coursePages); + } catch (e: unknown) { + res.status(500).send(getErrorMessage(e)); + } + }, +); + +courseRouter.post( + "/:unitId/:moduleId", + isAuthorizedByRole(new Set(["Administrator"])), + moduleBelongsToUnitValidator, + coursePageDtoValidator, + async (req, res) => { + try { + const newCoursePage = await coursePageService.createCoursePage( + req.params.moduleId, + req.body, + ); + res.status(200).json(newCoursePage); + } catch (e: unknown) { + res.status(500).send(getErrorMessage(e)); + } + }, +); + +courseRouter.put( + "/:unitId/:moduleId/:pageId", + isAuthorizedByRole(new Set(["Administrator"])), + moduleBelongsToUnitValidator, + pageBelongsToModuleValidator, + coursePageDtoValidator, + async (req, res) => { + try { + const updatedCoursePage = await coursePageService.updateCoursePage( + req.params.pageId, + req.body, + ); + res.status(200).json(updatedCoursePage); + } catch (e: unknown) { + res.status(500).send(getErrorMessage(e)); + } + }, +); + +courseRouter.delete( + "/:unitId/:moduleId/:pageId", + isAuthorizedByRole(new Set(["Administrator"])), + async (req, res) => { + try { + const deletedCoursePageId = await coursePageService.deleteCoursePage( + req.params.pageId, + ); + res.status(200).json({ id: deletedCoursePageId }); + } catch (e: unknown) { + res.status(500).send(getErrorMessage(e)); + } + }, +); + export default courseRouter; diff --git a/backend/services/implementations/courseModuleService.ts b/backend/services/implementations/courseModuleService.ts index 07a1662e..c9b49b1e 100644 --- a/backend/services/implementations/courseModuleService.ts +++ b/backend/services/implementations/courseModuleService.ts @@ -32,7 +32,7 @@ class CourseModuleService implements ICourseModuleService { _id: { $in: courseUnit.modules }, }); - return courseModules; + return courseModules.map((courseModule) => courseModule.toObject()); } catch (error) { Logger.error( `Failed to get course modules for course unit with id: ${courseUnitId}. Reason = ${getErrorMessage( @@ -43,6 +43,27 @@ class CourseModuleService implements ICourseModuleService { } } + async getCourseModule(courseModuleId: string): Promise { + try { + const courseModule: CourseModule | null = await MgCourseModule.findById( + courseModuleId, + ); + + if (!courseModule) { + throw new Error(`id ${courseModuleId} not found.`); + } + + return courseModule.toObject(); + } catch (error) { + Logger.error( + `Failed to get course module with id: ${courseModuleId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } + async createCourseModule( courseUnitId: string, courseModuleDTO: CreateCourseModuleDTO, diff --git a/backend/services/implementations/coursePageService.ts b/backend/services/implementations/coursePageService.ts new file mode 100644 index 00000000..708dabc0 --- /dev/null +++ b/backend/services/implementations/coursePageService.ts @@ -0,0 +1,146 @@ +/* eslint-disable class-methods-use-this */ +import { startSession } from "mongoose"; +import { + CoursePageDTO, + CreateCoursePageDTO, + UpdateCoursePageDTO, +} from "../../types/courseTypes"; +import logger from "../../utilities/logger"; +import ICoursePageService from "../interfaces/coursePageService"; +import MgCourseModule, { + CourseModule, +} from "../../models/coursemodule.mgmodel"; +import MgCoursePage from "../../models/coursepage.mgmodel"; +import { getErrorMessage } from "../../utilities/errorUtils"; + +const Logger = logger(__filename); + +class CoursePageService implements ICoursePageService { + async getCoursePages(courseModuleId: string): Promise> { + try { + const courseModule: CourseModule | null = await MgCourseModule.findById( + courseModuleId, + ); + + if (!courseModule) { + throw new Error(`Course module with id ${courseModuleId} not found.`); + } + + const coursePages = await MgCoursePage.find({ + _id: { $in: courseModule.pages }, + }); + + const coursePageDtos = await Promise.all( + coursePages.map(async (coursePage) => coursePage.toObject()), + ); + + return coursePageDtos; + } catch (error) { + Logger.error( + `Failed to get course pages in course module with id: ${courseModuleId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } + + async getCoursePage(coursePageId: string): Promise { + try { + const coursePage = await MgCoursePage.findById(coursePageId); + + if (!coursePage) { + throw new Error(`id ${coursePageId} not found.`); + } + + return coursePage; + } catch (error) { + Logger.error( + `Failed to get course page with id: ${coursePageId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } + + async createCoursePage( + courseModuleId: string, + coursePageDTO: CreateCoursePageDTO, + ): Promise { + const session = await startSession(); + session.startTransaction(); + try { + const newCoursePage = await MgCoursePage.create({ + ...coursePageDTO, + session, + }); + + await MgCourseModule.findByIdAndUpdate(courseModuleId, { + $push: { pages: newCoursePage.id }, + }).session(session); + + await session.commitTransaction(); + + return newCoursePage.toObject(); + } catch (error) { + Logger.error( + `Failed to create new course page for module with id ${courseModuleId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } finally { + session.endSession(); + } + } + + async updateCoursePage( + coursePageId: string, + coursePageDTO: UpdateCoursePageDTO, + ): Promise { + try { + const updatedCoursePage = await MgCoursePage.findByIdAndUpdate( + coursePageId, + coursePageDTO, + { runValidators: true, new: true }, + ); + + if (!updatedCoursePage) { + throw new Error(`Course page with id ${coursePageId} not found.`); + } + + return updatedCoursePage.toObject(); + } catch (error) { + Logger.error( + `Failed to update course page with id ${coursePageId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } + + async deleteCoursePage(coursePageId: string): Promise { + try { + const deletedCoursePage = await MgCoursePage.findByIdAndDelete( + coursePageId, + ); + + if (!deletedCoursePage) { + throw new Error(`Course page with id ${coursePageId} not found.`); + } + + return deletedCoursePage.id; + } catch (error) { + Logger.error( + `Failed to delete course page with id ${coursePageId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } +} + +export default CoursePageService; diff --git a/backend/services/implementations/courseUnitService.ts b/backend/services/implementations/courseUnitService.ts index 07612905..87673eb8 100644 --- a/backend/services/implementations/courseUnitService.ts +++ b/backend/services/implementations/courseUnitService.ts @@ -17,20 +17,14 @@ class CourseUnitService implements ICourseUnitService { const courseUnits: Array = await MgCourseUnit.find().sort( "displayIndex", ); - return courseUnits.map((courseUnit) => ({ - id: courseUnit.id, - displayIndex: courseUnit.displayIndex, - title: courseUnit.title, - })); + return courseUnits.map((courseUnit) => courseUnit.toObject()); } catch (error) { Logger.error(`Failed to get courses. Reason = ${getErrorMessage(error)}`); throw error; } } - async getCourseUnit( - unitId: string, - ): Promise { + async getCourseUnit(unitId: string): Promise { try { const courseUnit: CourseUnit | null = await MgCourseUnit.findById(unitId); @@ -38,11 +32,7 @@ class CourseUnitService implements ICourseUnitService { throw new Error(`Course unit with id ${unitId} not found.`); } - const courseModuleIds = courseUnit.modules.map((id) => { - return id.toString(); - }); - - return { ...(courseUnit as CourseUnitDTO), modules: courseModuleIds }; + return courseUnit.toObject(); } catch (error) { Logger.error( `Failed to get course with id ${unitId}. Reason = ${getErrorMessage( @@ -69,41 +59,33 @@ class CourseUnitService implements ICourseUnitService { ); throw error; } - return { - id: newCourseUnit.id, - displayIndex: newCourseUnit.displayIndex, - title: newCourseUnit.title, - }; + return newCourseUnit.toObject(); } async updateCourseUnit( id: string, courseUnit: UpdateCourseUnitDTO, ): Promise { - let oldCourse: CourseUnit | null; try { - oldCourse = await MgCourseUnit.findByIdAndUpdate( - id, - { - title: courseUnit.title, - }, - { runValidators: true }, - ); + const updatedCourseUnit: CourseUnit | null = + await MgCourseUnit.findByIdAndUpdate( + id, + { + title: courseUnit.title, + }, + { runValidators: true, new: true }, + ); - if (!oldCourse) { + if (!updatedCourseUnit) { throw new Error(`Course unit with id ${id} not found.`); } + return updatedCourseUnit.toObject(); } catch (error) { Logger.error( `Failed to update course unit. Reason = ${getErrorMessage(error)}`, ); throw error; } - return { - id, - title: courseUnit.title, - displayIndex: oldCourse.displayIndex, - }; } async deleteCourseUnit(id: string): Promise { diff --git a/backend/services/implementations/helpRequestService.ts b/backend/services/implementations/helpRequestService.ts index e04d7e80..2f253027 100644 --- a/backend/services/implementations/helpRequestService.ts +++ b/backend/services/implementations/helpRequestService.ts @@ -44,7 +44,7 @@ export class HelpRequestService implements IHelpRequestService { .populate("learner", "firstName lastName") .populate("unit", "title displayIndex") .populate("module", "title displayIndex") - .populate("page", "title displayIndex") + .populate("page", "title") .sort({ createdAt: -1, }); @@ -83,7 +83,7 @@ export class HelpRequestService implements IHelpRequestService { .populate("learner", "firstName lastName") .populate("unit", "title displayIndex") .populate("module", "title displayIndex") - .populate("page", "title displayIndex"); + .populate("page", "title"); if (!oldHelpRequest) { throw new Error(`Help Request with id ${requestId} not found.`); diff --git a/backend/services/interfaces/courseModuleService.ts b/backend/services/interfaces/courseModuleService.ts index 7fae257c..a9041217 100644 --- a/backend/services/interfaces/courseModuleService.ts +++ b/backend/services/interfaces/courseModuleService.ts @@ -12,6 +12,12 @@ interface ICourseModuleService { */ getCourseModules(courseUnitId: string): Promise>; + /** + * Returns course module based on module id + * @throwsError if course module was not successfully fetched or not found + */ + getCourseModule(courseModuleId: string): Promise; + /** * Creates a course module * @param courseUnitId the id of the unit that we want to create the new module under diff --git a/backend/services/interfaces/coursePageService.ts b/backend/services/interfaces/coursePageService.ts new file mode 100644 index 00000000..ef764728 --- /dev/null +++ b/backend/services/interfaces/coursePageService.ts @@ -0,0 +1,52 @@ +import { + CoursePageDTO, + CreateCoursePageDTO, + UpdateCoursePageDTO, +} from "../../types/courseTypes"; + +interface ICoursePageService { + /** + * Returns all course pages belonging to a module + * @param courseModuleId the id of the module we want to fetch the pages of + * @throws Error if course pages were not successfully fetched + */ + getCoursePages(courseModuleId: string): Promise>; + + /** + * Returns 1 course page + * @param coursePageId the id of the page we want to fetch + * @throwsError if course page was not successfully fetched or not found + */ + getCoursePage(coursePageId: string): Promise; + + /** + * Creates a course page, appended as the last page in the module (for now) + * @param courseModuleId the id of the module that we want to create the new page under + * @param coursePageDTO the info about the course page that we want to create + * @throws Error if course page was not successfully created + */ + createCoursePage( + courseModuleId: string, + coursePageDTO: CreateCoursePageDTO, + ): Promise; + + /** + * Updates 1 specific course page + * @param coursePageId the id of the course page we want to update + * @param coursePageDTO the info about course page we want to update + * @throws Error if the course page failed to update + */ + updateCoursePage( + coursePageId: string, + coursePageDTO: UpdateCoursePageDTO, + ): Promise; + + /** + * Deletes 1 course page + * @param coursePageId the id of the course page we want to delete + * @throws Error if the page id does't exist in the database + */ + deleteCoursePage(coursePageId: string): Promise; +} + +export default ICoursePageService; diff --git a/backend/types/courseElementTypes.ts b/backend/types/courseElementTypes.ts new file mode 100644 index 00000000..27ad64b1 --- /dev/null +++ b/backend/types/courseElementTypes.ts @@ -0,0 +1,61 @@ +export enum DisplayElementType { + Text = "Text", + Image = "Image", +} + +export enum InteractiveElementType { + TextInput = "TextInput", + NumberInput = "NumberInput", + CheckboxInput = "CheckboxInput", + MultipleChoice = "MultipleChoice", + Matching = "Matching", +} + +export enum HybridElementType { + Table = "Table", +} + +export type ElementType = + | DisplayElementType + | InteractiveElementType + | HybridElementType; + +export interface ElementPosition { + x: number; + y: number; + w: number; + h: number; +} + +export interface ElementData { + type: ElementType; +} + +// Text element +export type FontSize = "Large" | "Medium" | "Small"; +export function isFontSize(fontSize: string): fontSize is FontSize { + return ["Large", "Medium", "Small"].includes(fontSize); +} + +export type FontWeight = "Normal" | "Bold"; +export function isFontWeight(fontWeight: string): fontWeight is FontWeight { + return ["Normal", "Bold"].includes(fontWeight); +} + +export type TextAlign = "Left" | "Center" | "Right"; +export function isTextAlign(textAlign: string): textAlign is TextAlign { + return ["Left", "Center", "Right"].includes(textAlign); +} + +export interface TextElementData extends ElementData { + text: string; + fontSize: FontSize; + fontWeight: FontWeight; + textAlign: TextAlign; +} + +export function isTextElementData( + elementData: ElementData, +): elementData is TextElementData { + return elementData.type === DisplayElementType.Text; +} diff --git a/backend/types/courseTypes.ts b/backend/types/courseTypes.ts index a82e71c1..91efebc0 100644 --- a/backend/types/courseTypes.ts +++ b/backend/types/courseTypes.ts @@ -1,40 +1,53 @@ +import { ElementData } from "./courseElementTypes"; + export type CourseUnitDTO = { id: string; displayIndex: number; title: string; + modules: string[]; }; export type CreateCourseUnitDTO = Pick; - export type UpdateCourseUnitDTO = Pick; export type CourseModuleDTO = { id: string; displayIndex: number; title: string; + pages: string[]; }; export type CreateCourseModuleDTO = Pick; export type UpdateCourseModuleDTO = Pick; -export enum InteractiveElementType { - TextInput = "TextInput", - NumberInput = "NumberInput", - CheckboxInput = "CheckboxInput", - MultipleChoice = "MultipleChoice", - Matching = "Matching", -} - -export enum HybridElementType { - Table = "Table", -} - -export enum DisplayElementType { - RichText = "RichText", - Image = "Image", -} - -export type ElementType = - | DisplayElementType - | InteractiveElementType - | HybridElementType; +export type PageType = "Lesson" | "Activity"; + +export type CoursePageDTO = { + id: string; + type: PageType; +}; + +export type LessonPageDTO = CoursePageDTO & { + source: string; +}; + +export type Element = { + i: string; + x: number; + y: number; + w: number; + h: number; + data: ElementData; +}; +export type ActivityPageDTO = CoursePageDTO & { + elements: Element[]; +}; + +export type CreateCoursePageDTO = + | Pick + | Pick + | Pick; +export type UpdateCoursePageDTO = + | Pick + | Pick + | Pick; diff --git a/backend/utilities/courseUtils.ts b/backend/utilities/courseUtils.ts new file mode 100644 index 00000000..d3a04120 --- /dev/null +++ b/backend/utilities/courseUtils.ts @@ -0,0 +1,17 @@ +import { DisplayElementType, ElementData } from "../types/courseElementTypes"; + +function validateTextElementData(value: ElementData) { + return ( + "text" in value && + "fontSize" in value && + "fontWeight" in value && + "textAlign" in value + ); +} + +export function validateElementData(value: ElementData) { + return ( + value.type === DisplayElementType.Text && validateTextElementData(value) + // TODO - add rest of types + ); +} diff --git a/frontend/package.json b/frontend/package.json index ab1932d9..dd7be282 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@types/react-table": "^7.0.29", "@types/react-window": "^1.8.8", "@types/react-window-infinite-loader": "^1.0.9", + "@types/uuid": "^10.0.0", "autoprefixer": "10.4.5", "axios": "^0.28.0", "bootstrap": "^5.0.0", @@ -51,6 +52,7 @@ "styled-components": "^6.1.11", "tailwind-merge": "^2.3.0", "typescript": "^4.1.2", + "uuid": "^11.0.4", "web-vitals": "^1.0.1" }, "scripts": { diff --git a/frontend/src/APIClients/CourseAPIClient.ts b/frontend/src/APIClients/CourseAPIClient.ts index ba2f38c9..8a4ac1aa 100644 --- a/frontend/src/APIClients/CourseAPIClient.ts +++ b/frontend/src/APIClients/CourseAPIClient.ts @@ -1,5 +1,8 @@ +import { LayoutItem } from "../components/course_authoring/activity/grid/layoutReducer"; import AUTHENTICATED_USER_KEY from "../constants/AuthConstants"; -import { CourseUnit } from "../types/CourseTypes"; +import { ElementData } from "../types/CourseElementTypes"; +import { ActivityPage, CoursePage, CourseUnit } from "../types/CourseTypes"; +import { synthesizeActivityPage } from "../utils/CourseUtils"; import { getLocalStorageObjProperty } from "../utils/LocalStorageUtils"; import baseAPIClient from "./BaseAPIClient"; @@ -18,4 +21,40 @@ const getUnits = async (): Promise => { } }; -export default { getUnits }; +const saveActivityPage = async ( + unitId: string, + moduleId: string, + activePage: ActivityPage, + layout: LayoutItem[], + elements: Map, +): Promise => { + const bearerToken = `Bearer ${getLocalStorageObjProperty( + AUTHENTICATED_USER_KEY, + "accessToken", + )}`; + const page = synthesizeActivityPage(activePage, layout, elements); + + // Existing page id => update page + if (activePage.id) { + const { data } = await baseAPIClient.put( + `/course/${unitId}/${moduleId}/${activePage.id}`, + page, + { + headers: { Authorization: bearerToken }, + }, + ); + return data; + } + + // Missing page id => create page + const { data } = await baseAPIClient.post( + `/course/${unitId}/${moduleId}`, + page, + { + headers: { Authorization: bearerToken }, + }, + ); + return data; +}; + +export default { getUnits, saveActivityPage }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d3b9aa51..7add3b8d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,14 +2,14 @@ import "bootstrap/dist/css/bootstrap.min.css"; import { CssBaseline } from "@mui/material"; import React, { useState, useReducer, useEffect } from "react"; import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; -import Welcome from "./components/pages/Welcome"; -import Signup from "./components/pages/Signup"; +import Welcome from "./components/auth/WelcomePage"; +import Signup from "./components/auth/SignupPage"; import PrivateRoute from "./components/auth/PrivateRoute"; import Default from "./components/pages/Default"; -import CreateModulePage from "./components/pages/CreateModulePage"; +import CreateModulePage from "./components/course_authoring/prototype/CreateModulePage"; import NotFound from "./components/pages/NotFound"; import NotAuthorized from "./components/pages/NotAuthorized"; -import MyAccount from "./components/pages/MyAccount"; +import MyAccount from "./components/pages/MyAccountPage"; import AUTHENTICATED_USER_KEY from "./constants/AuthConstants"; import AuthContext from "./contexts/AuthContext"; import { getLocalStorageObj } from "./utils/LocalStorageUtils"; @@ -27,9 +27,10 @@ import ManageUserPage from "./components/pages/ManageUserPage"; import MakeHelpRequestPage from "./components/pages/MakeHelpRequestPage"; import ViewHelpRequestsPage from "./components/pages/ViewHelpRequestsPage"; import HelpRequestPage from "./components/pages/HelpRequestPage"; -import CreatePasswordPage from "./components/pages/CreatePasswordPage"; +import CreatePasswordPage from "./components/auth/CreatePasswordPage"; import ForgotPasswordPage from "./components/auth/forgot_password/ForgotPasswordPage"; -import CourseUnitsPage from "./components/pages/courses/CourseUnitsPage"; +import CourseViewingPage from "./components/course_viewing/CourseViewingPage"; +import CourseAuthoringPage from "./components/course_authoring/CourseAuthoringPage"; const App = (): React.ReactElement => { const currentUser: AuthenticatedUser | null = @@ -132,10 +133,16 @@ const App = (): React.ReactElement => { /> + diff --git a/frontend/src/components/pages/CreatePasswordPage.tsx b/frontend/src/components/auth/CreatePasswordPage.tsx similarity index 100% rename from frontend/src/components/pages/CreatePasswordPage.tsx rename to frontend/src/components/auth/CreatePasswordPage.tsx diff --git a/frontend/src/components/auth/Logout.tsx b/frontend/src/components/auth/Logout.tsx index 49899ad1..2d25dc9f 100644 --- a/frontend/src/components/auth/Logout.tsx +++ b/frontend/src/components/auth/Logout.tsx @@ -1,5 +1,6 @@ import React, { useContext } from "react"; +import { Button } from "@mui/material"; import authAPIClient from "../../APIClients/AuthAPIClient"; import AuthContext from "../../contexts/AuthContext"; @@ -13,11 +14,7 @@ const Logout = (): React.ReactElement => { } }; - return ( - - ); + return ; }; export default Logout; diff --git a/frontend/src/components/auth/MyAccountButton.tsx b/frontend/src/components/auth/MyAccountButton.tsx index 6246cb6a..6f80f79b 100644 --- a/frontend/src/components/auth/MyAccountButton.tsx +++ b/frontend/src/components/auth/MyAccountButton.tsx @@ -1,17 +1,14 @@ import React from "react"; import { useHistory } from "react-router-dom"; +import { Button } from "@mui/material"; import * as Routes from "../../constants/Routes"; const MyAccountButton = (): React.ReactElement => { const history = useHistory(); return ( - + ); }; diff --git a/frontend/src/components/auth/PrivateRoute.tsx b/frontend/src/components/auth/PrivateRoute.tsx index d88ddc20..68741240 100644 --- a/frontend/src/components/auth/PrivateRoute.tsx +++ b/frontend/src/components/auth/PrivateRoute.tsx @@ -1,6 +1,6 @@ import React, { useContext } from "react"; import { Route, Redirect } from "react-router-dom"; - +import { Box } from "@mui/material"; import AuthContext from "../../contexts/AuthContext"; import { CREATE_PASSWORD_PAGE, @@ -8,6 +8,7 @@ import { WELCOME_PAGE, } from "../../constants/Routes"; import { Role } from "../../types/AuthTypes"; +import Navbar from "../common/navbar/Navbar"; type PrivateRouteProps = { component: React.FC; @@ -17,7 +18,7 @@ type PrivateRouteProps = { }; const PrivateRoute: React.FC = ({ - component, + component: Component, exact, path, allowedRoles, @@ -31,8 +32,19 @@ const PrivateRoute: React.FC = ({ ) { return ; } + + const NavbarWrappedComponent: React.FC = () => { + return ( + + + + + + + ); + }; return allowedRoles.includes(authenticatedUser.role) ? ( - + ) : ( ); diff --git a/frontend/src/components/auth/RefreshCredentials.tsx b/frontend/src/components/auth/RefreshCredentials.tsx index abbc8ecb..51ff59f5 100644 --- a/frontend/src/components/auth/RefreshCredentials.tsx +++ b/frontend/src/components/auth/RefreshCredentials.tsx @@ -1,5 +1,6 @@ import React, { useContext } from "react"; +import { Button } from "@mui/material"; import authAPIClient from "../../APIClients/AuthAPIClient"; import AuthContext from "../../contexts/AuthContext"; @@ -13,11 +14,7 @@ const RefreshCredentials = (): React.ReactElement => { } }; - return ( - - ); + return ; }; export default RefreshCredentials; diff --git a/frontend/src/components/auth/ResetPassword.tsx b/frontend/src/components/auth/ResetPassword.tsx index 5ccbca79..025ea9da 100644 --- a/frontend/src/components/auth/ResetPassword.tsx +++ b/frontend/src/components/auth/ResetPassword.tsx @@ -1,5 +1,6 @@ import React, { useContext } from "react"; +import { Button } from "@mui/material"; import authAPIClient from "../../APIClients/AuthAPIClient"; import AuthContext from "../../contexts/AuthContext"; @@ -13,15 +14,7 @@ const ResetPassword = (): React.ReactElement => { await authAPIClient.resetPassword(authenticatedUser.email); }; - return ( - - ); + return ; }; export default ResetPassword; diff --git a/frontend/src/components/pages/Signup.tsx b/frontend/src/components/auth/SignupPage.tsx similarity index 98% rename from frontend/src/components/pages/Signup.tsx rename to frontend/src/components/auth/SignupPage.tsx index 2a02b650..0535ffc6 100644 --- a/frontend/src/components/pages/Signup.tsx +++ b/frontend/src/components/auth/SignupPage.tsx @@ -24,8 +24,8 @@ import { HOME_PAGE, WELCOME_PAGE } from "../../constants/Routes"; import AuthContext from "../../contexts/AuthContext"; import { AuthenticatedUser } from "../../types/AuthTypes"; import logo from "../assets/logoColoured.png"; -import { getSignUpPath, getSignUpPrompt } from "./Welcome"; -import Login from "../auth/Login"; +import { getSignUpPath, getSignUpPrompt } from "./WelcomePage"; +import Login from "./Login"; const Signup = (): React.ReactElement => { const { authenticatedUser } = useContext(AuthContext); diff --git a/frontend/src/components/pages/Welcome.tsx b/frontend/src/components/auth/WelcomePage.tsx similarity index 99% rename from frontend/src/components/pages/Welcome.tsx rename to frontend/src/components/auth/WelcomePage.tsx index a2e525aa..ef9e536a 100644 --- a/frontend/src/components/pages/Welcome.tsx +++ b/frontend/src/components/auth/WelcomePage.tsx @@ -18,7 +18,7 @@ import { HOME_PAGE, SIGNUP_PAGE } from "../../constants/Routes"; import AuthContext from "../../contexts/AuthContext"; import background from "../assets/backgroundImage.png"; import logo from "../assets/logoWhite.png"; -import Login from "../auth/Login"; +import Login from "./Login"; import { Role } from "../../types/AuthTypes"; export const getSignUpPrompt = (role: Role): string | undefined => { diff --git a/frontend/src/components/common/navbar/Navbar.tsx b/frontend/src/components/common/navbar/Navbar.tsx index dfc25d75..bf1a83f4 100644 --- a/frontend/src/components/common/navbar/Navbar.tsx +++ b/frontend/src/components/common/navbar/Navbar.tsx @@ -8,16 +8,21 @@ import Typography from "@mui/material/Typography"; import Badge from "@mui/material/Badge"; import NotificationsIcon from "@mui/icons-material/Notifications"; import Button from "@mui/material/Button/Button"; +import { Link } from "react-router-dom"; +import { useTheme } from "@mui/material"; import NotificationsList from "../../notification/NotificationsList"; import NotificationAPIClient from "../../../APIClients/NotificationAPIClient"; import { useUser } from "../../../hooks/useUser"; import { Notification } from "../../../types/NotificationTypes"; import { useSocket } from "../../../contexts/SocketContext"; import UserButton from "./UserButton"; +import { HOME_PAGE } from "../../../constants/Routes"; +import eafLogo from "../../assets/logoColoured.png"; export default function Navbar() { const user = useUser(); const socket = useSocket(); + const theme = useTheme(); const NUMBER_OF_NOTIFICATIONS_TO_LOAD = 10; const [notifications, setNotifications] = useState([]); @@ -78,23 +83,29 @@ export default function Navbar() { return ( - - - - Extend a family - + + + + Extend-A-Family logo + diff --git a/frontend/src/components/common/navbar/UserButton.tsx b/frontend/src/components/common/navbar/UserButton.tsx index 4a324c81..af60aa10 100644 --- a/frontend/src/components/common/navbar/UserButton.tsx +++ b/frontend/src/components/common/navbar/UserButton.tsx @@ -1,6 +1,6 @@ import React from "react"; import { AccountCircle } from "@mui/icons-material"; -import { Box, IconButton, Popover } from "@mui/material"; +import { Box, IconButton, Popover, useTheme } from "@mui/material"; import RefreshCredentials from "../../auth/RefreshCredentials"; import ResetPassword from "../../auth/ResetPassword"; import Logout from "../../auth/Logout"; @@ -10,6 +10,7 @@ const UserButton = () => { const [anchorEl, setAnchorEl] = React.useState( null, ); + const theme = useTheme(); const open = Boolean(anchorEl); const id = open ? "simple-popover" : undefined; @@ -27,8 +28,8 @@ const UserButton = () => { edge="end" aria-label="account of current user" aria-haspopup="true" - color="inherit" onClick={handleClick} + sx={{ color: theme.palette.Neutral[400] }} > diff --git a/frontend/src/components/course_authoring/BottomToolbar.tsx b/frontend/src/components/course_authoring/BottomToolbar.tsx new file mode 100644 index 00000000..03196a12 --- /dev/null +++ b/frontend/src/components/course_authoring/BottomToolbar.tsx @@ -0,0 +1,161 @@ +import React, { useContext } from "react"; +import { Box, Button, Stack, Theme, Typography, useTheme } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import FileUploadIcon from "@mui/icons-material/FileUpload"; +import RemoveRedEyeOutlinedIcon from "@mui/icons-material/RemoveRedEyeOutlined"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import DoneIcon from "@mui/icons-material/Done"; +import styled from "@emotion/styled"; +import { useParams } from "react-router-dom"; +import { + ActivityPage, + isActivityPage, + PageType, +} from "../../types/CourseTypes"; +import CourseAuthoringContext from "../../contexts/CourseAuthoringContext"; +import CourseAPIClient from "../../APIClients/CourseAPIClient"; +import { getErrorMessage } from "../../utils/ErrorUtils"; +import { ActivityDataContext } from "../../contexts/ActivityDataContext"; +import { ActivityLayoutContext } from "../../contexts/ActivityLayoutContext"; + +const StyledButton = styled(Button)` + height: 40px; + border-radius: 4px; +`; + +const outlinedButtonStyle = (theme: Theme) => ({ + color: theme.palette.Administrator.Default, + borderColor: theme.palette.Neutral[500], + "&:hover": { + bgcolor: theme.palette.Administrator.Light, + borderColor: theme.palette.Neutral[500], + }, +}); + +const containedButtonStyle = (theme: Theme) => ({ + color: theme.palette.Neutral[100], + bgcolor: theme.palette.Administrator.Default, + "&:hover": { + bgcolor: theme.palette.Administrator.Default, + }, +}); + +type BottomToolbarProps = { + onBuilderEnter: () => void; + onBuilderExit: () => void; +}; + +type CourseAuthoringParams = { + unitId: string; + moduleId: string; +}; + +const BottomToolbar = ({ + onBuilderEnter, + onBuilderExit, +}: BottomToolbarProps) => { + const { unitId, moduleId } = useParams(); + const { activePage, setActivePage, previewMode, setPreviewMode } = useContext( + CourseAuthoringContext, + ); + const { layout, dispatchLayout } = useContext(ActivityLayoutContext); + const { elements, setElements } = useContext(ActivityDataContext); + const theme = useTheme(); + + function createPage(pageType: PageType) { + if (pageType === "Activity") { + const page: ActivityPage = { + id: "", // placeholder + type: pageType, + elements: [], + }; + setActivePage(page); + } + + onBuilderEnter(); + } + + async function savePage() { + if (!activePage) { + throw new Error("active page is null"); + } + + if (isActivityPage(activePage)) { + try { + await CourseAPIClient.saveActivityPage( + unitId, + moduleId, + activePage, + layout, + elements, + ); + // Reset page, layout, elements + setActivePage(null); + dispatchLayout({ type: "reset" }); + setElements(new Map()); + setPreviewMode(false); + onBuilderExit(); + } catch (e: unknown) { + alert(`Failed to save page: ${getErrorMessage(e)}`); + } + } + } + + function togglePreview() { + setPreviewMode(!previewMode); + } + + return ( + + {activePage ? ( + + } + onClick={() => savePage()} + > + Save + + : + } + onClick={() => togglePreview()} + > + + {previewMode ? "Edit" : "Preview"} + + + + ) : ( + + } + onClick={() => createPage("Lesson")} + disabled={!!activePage} + > + Upload page + + } + onClick={() => createPage("Activity")} + disabled={!!activePage} + > + Create activity + + + )} + + ); +}; + +export default BottomToolbar; diff --git a/frontend/src/components/course_authoring/CourseAuthoringPage.tsx b/frontend/src/components/course_authoring/CourseAuthoringPage.tsx new file mode 100644 index 00000000..c91669ef --- /dev/null +++ b/frontend/src/components/course_authoring/CourseAuthoringPage.tsx @@ -0,0 +1,39 @@ +import React, { useState } from "react"; +import { Box } from "@mui/material"; +import LeftSidebar from "./LeftSidebar"; +import RightSidebar from "./RightSidebar"; +import MainArea from "./MainArea"; +import CourseAuthoringContext from "../../contexts/CourseAuthoringContext"; +import { CoursePage } from "../../types/CourseTypes"; +import { ActivityDataContextProvider } from "../../contexts/ActivityDataContext"; +import { ActivityLayoutContextProvider } from "../../contexts/ActivityLayoutContext"; + +const CourseAuthoringPage = () => { + const [activePage, setActivePage] = useState(null); + const [previewMode, setPreviewMode] = useState(false); + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CourseAuthoringPage; diff --git a/frontend/src/components/course_authoring/LeftSidebar.tsx b/frontend/src/components/course_authoring/LeftSidebar.tsx new file mode 100644 index 00000000..98f23e4d --- /dev/null +++ b/frontend/src/components/course_authoring/LeftSidebar.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { Box, useTheme } from "@mui/material"; + +const LeftSidebar = () => { + const theme = useTheme(); + return ( + + ); +}; + +export default LeftSidebar; diff --git a/frontend/src/components/course_authoring/MainArea.tsx b/frontend/src/components/course_authoring/MainArea.tsx new file mode 100644 index 00000000..770319c8 --- /dev/null +++ b/frontend/src/components/course_authoring/MainArea.tsx @@ -0,0 +1,22 @@ +import React, { useState } from "react"; +import { Box } from "@mui/material"; +import BottomToolbar from "./BottomToolbar"; +import PageBuilder from "./PageBuilder"; +import PagePreview from "./PagePreview"; + +const MainArea = () => { + const [builder, setBuilder] = useState(false); + return ( + + + {builder ? : } + + setBuilder(true)} + onBuilderExit={() => setBuilder(false)} + /> + + ); +}; + +export default MainArea; diff --git a/frontend/src/components/course_authoring/PageBuilder.tsx b/frontend/src/components/course_authoring/PageBuilder.tsx new file mode 100644 index 00000000..a34804c8 --- /dev/null +++ b/frontend/src/components/course_authoring/PageBuilder.tsx @@ -0,0 +1,40 @@ +import React, { useContext } from "react"; +import { Box, useTheme } from "@mui/material"; +import CourseAuthoringContext from "../../contexts/CourseAuthoringContext"; +import ActivityGrid from "./activity/grid/ActivityGrid"; +import { ActivityLayoutContext } from "../../contexts/ActivityLayoutContext"; + +const PageBuilder = () => { + const theme = useTheme(); + const { activePage, previewMode } = useContext(CourseAuthoringContext); + const { targetRef } = useContext(ActivityLayoutContext); + + return ( + + {activePage && + (activePage.type === "Lesson" ? ( + uploaded stuff + ) : ( + + ))} + + ); +}; + +export default PageBuilder; diff --git a/frontend/src/components/course_authoring/PagePreview.tsx b/frontend/src/components/course_authoring/PagePreview.tsx new file mode 100644 index 00000000..7aaa737f --- /dev/null +++ b/frontend/src/components/course_authoring/PagePreview.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { Box, useTheme } from "@mui/material"; + +const PagePreview = () => { + const theme = useTheme(); + return ( + + ); +}; + +export default PagePreview; diff --git a/frontend/src/components/course_authoring/RightSidebar.tsx b/frontend/src/components/course_authoring/RightSidebar.tsx new file mode 100644 index 00000000..9e125d97 --- /dev/null +++ b/frontend/src/components/course_authoring/RightSidebar.tsx @@ -0,0 +1,44 @@ +import React, { useContext } from "react"; +import { Box, useTheme } from "@mui/material"; +import CourseAuthoringContext from "../../contexts/CourseAuthoringContext"; +import ElementMenu from "./activity/ElementMenu"; +import { ActivityDataContext } from "../../contexts/ActivityDataContext"; +import TextDataForm from "./activity/data_forms/TextDataForm"; +import { DisplayElementType } from "../../types/CourseElementTypes"; + +const RightSidebar = () => { + const theme = useTheme(); + const { activePage, previewMode } = useContext(CourseAuthoringContext); + const { elements, activeElementId } = useContext(ActivityDataContext); + + const DataForm = () => { + if (!activeElementId) { + return ; + } + const elementData = elements.get(activeElementId); + if (!elementData) { + return <>; + } + + const { type } = elementData; + if (type === DisplayElementType.Text) { + return ; + } + + return <>; + }; + + return ( + + {!previewMode && + activePage && + (activePage.type === "Lesson" ? ( + alt text form + ) : ( + + ))} + + ); +}; + +export default RightSidebar; diff --git a/frontend/src/components/course_authoring/activity/ElementMenu.tsx b/frontend/src/components/course_authoring/activity/ElementMenu.tsx new file mode 100644 index 00000000..b611602c --- /dev/null +++ b/frontend/src/components/course_authoring/activity/ElementMenu.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Box, Stack, Typography } from "@mui/material"; +import styled from "@emotion/styled"; +import DraggableSource from "./grid/DraggableSource"; +import { DisplayElementType } from "../../../types/CourseElementTypes"; + +const ElementBox = styled(Box)` + width: 150px; + height: 40px; + background-color: white; + border: 1px solid black; + border-radius: 8px; + display: flex; + text-align: center; + align-items: center; + justify-content: center; +`; + +const ElementMenu = () => { + return ( + + + Add an element + + Drag an element onto the page. + + + + + Text + + + + ); +}; + +export default ElementMenu; diff --git a/frontend/src/components/course_authoring/activity/data_forms/TextDataForm.tsx b/frontend/src/components/course_authoring/activity/data_forms/TextDataForm.tsx new file mode 100644 index 00000000..42525e00 --- /dev/null +++ b/frontend/src/components/course_authoring/activity/data_forms/TextDataForm.tsx @@ -0,0 +1,162 @@ +import React, { useContext, useEffect, useState } from "react"; +import { + Box, + Button, + FormControlLabel, + Radio, + RadioGroup, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { + DisplayElementType, + FontSize, + FontWeight, + isFontSize, + isFontWeight, + isTextAlign, + isTextElementData, + TextAlign, +} from "../../../../types/CourseElementTypes"; +import { ActivityDataContext } from "../../../../contexts/ActivityDataContext"; + +interface TextDataFormProps { + id: string; +} + +const TextDataForm: React.FC = ({ id }) => { + const { elements, setElements, setActiveElementId } = + useContext(ActivityDataContext); + const [text, setText] = useState(null); + const [fontSize, setFontSize] = useState(null); + const [fontWeight, setFontWeight] = useState(null); + const [textAlign, setTextAlign] = useState(null); + + useEffect(() => { + const currentData = elements.get(id); + if (currentData && isTextElementData(currentData)) { + setText(currentData.text); + setFontSize(currentData.fontSize); + setFontWeight(currentData.fontWeight); + setTextAlign(currentData.textAlign); + } + }, [elements, id]); + + const handleSubmit = () => { + const updatedData = new Map(elements); + const newData = { + type: DisplayElementType.Text, + text, + fontSize, + fontWeight, + textAlign, + }; + updatedData.set(id, newData); + setElements(updatedData); + setActiveElementId(null); + }; + + return ( + + + Text element + + Customize the selected text element. + + + + Content: + setText(e.target.value)} + /> + + + Font size: + + isFontSize(e.target.value) && setFontSize(e.target.value) + } + > + } + label="Small" + value="Small" + /> + } + label="Medium" + value="Medium" + /> + } + label="Large" + value="Large" + /> + + + + Font weight: + + isFontWeight(e.target.value) && setFontWeight(e.target.value) + } + > + } + label="Normal" + value="Normal" + /> + } + label="Bold" + value="Bold" + /> + + + + Text alignment: + + isTextAlign(e.target.value) && setTextAlign(e.target.value) + } + > + } + label="Left" + value="Left" + /> + } + label="Center" + value="Center" + /> + } + label="Right" + value="Right" + /> + + + + + ); +}; + +export default TextDataForm; diff --git a/frontend/src/components/course_authoring/activity/elements/BaseElement.tsx b/frontend/src/components/course_authoring/activity/elements/BaseElement.tsx new file mode 100644 index 00000000..b92ff7d4 --- /dev/null +++ b/frontend/src/components/course_authoring/activity/elements/BaseElement.tsx @@ -0,0 +1,28 @@ +import React, { useContext } from "react"; +import { ActivityDataContext } from "../../../../contexts/ActivityDataContext"; +import { isTextElementData } from "../../../../types/CourseElementTypes"; +import TextElement from "./TextElement"; + +interface BaseElementProps { + id: string; + elementType: string; +} + +const BaseElement: React.FC = ({ id, elementType }) => { + const { elements } = useContext(ActivityDataContext); + + const elementData = elements.get(id); + + if (!elementData) { + return <>; + } + + // Grid items + if (elementType === "Text" && isTextElementData(elementData)) { + return ; + } + + return <>; +}; + +export default BaseElement; diff --git a/frontend/src/components/course_authoring/activity/elements/TextElement.tsx b/frontend/src/components/course_authoring/activity/elements/TextElement.tsx new file mode 100644 index 00000000..93ad2a29 --- /dev/null +++ b/frontend/src/components/course_authoring/activity/elements/TextElement.tsx @@ -0,0 +1,40 @@ +import React, { useContext } from "react"; +import { Box, Typography } from "@mui/material"; +import { TextElementData } from "../../../../types/CourseElementTypes"; +import { ActivityDataContext } from "../../../../contexts/ActivityDataContext"; +import CourseAuthoringContext from "../../../../contexts/CourseAuthoringContext"; + +interface TextElementProps { + id: string; + elementData: TextElementData; +} + +const TextElement: React.FC = ({ id, elementData }) => { + const { previewMode } = useContext(CourseAuthoringContext); + const { setActiveElementId } = useContext(ActivityDataContext); + const { text, fontSize, fontWeight, textAlign } = elementData; + return ( + !previewMode && setActiveElementId(id)} + className="drag-handle" + sx={{ + width: "100%", + height: "100%", + padding: "8px", + textAlign: textAlign.toLowerCase(), + alignContent: "center", + justifyContent: "center", + overflow: "hidden", + }} + > + + {text} + + + ); +}; + +export default TextElement; diff --git a/frontend/src/components/course_authoring/activity/elements/defaultElements.ts b/frontend/src/components/course_authoring/activity/elements/defaultElements.ts new file mode 100644 index 00000000..7ee9b5bf --- /dev/null +++ b/frontend/src/components/course_authoring/activity/elements/defaultElements.ts @@ -0,0 +1,10 @@ +import { DisplayElementType } from "../../../../types/CourseElementTypes"; + +// eslint-disable-next-line import/prefer-default-export +export const defaultTextElement = { + type: DisplayElementType.Text, + text: "", + fontSize: "Medium", + fontWeight: "Normal", + textAlign: "Left", +}; diff --git a/frontend/src/components/course_authoring/activity/grid/ActivityGrid.tsx b/frontend/src/components/course_authoring/activity/grid/ActivityGrid.tsx new file mode 100644 index 00000000..e221e179 --- /dev/null +++ b/frontend/src/components/course_authoring/activity/grid/ActivityGrid.tsx @@ -0,0 +1,99 @@ +import React, { useContext } from "react"; +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; +import { useTheme } from "@mui/material"; +import GridLayout from "react-grid-layout"; +import GridElement from "./GridElement"; +import CourseAuthoringContext from "../../../../contexts/CourseAuthoringContext"; +import { ActivityLayoutContext } from "../../../../contexts/ActivityLayoutContext"; +import { ActivityDataContext } from "../../../../contexts/ActivityDataContext"; + +type ActivityGridProps = { + rows: number; + cols: number; +}; + +const ActivityGrid = ({ rows, cols }: ActivityGridProps) => { + const { previewMode } = useContext(CourseAuthoringContext); + const { layout, dispatchLayout, targetRef } = useContext( + ActivityLayoutContext, + ); + const { activeElementId } = useContext(ActivityDataContext); + const theme = useTheme(); + + const gridStyle = previewMode + ? { width: "100%", height: "100%" } + : { + width: "100%", + height: "100%", + backgroundSize: `${100 / cols}% ${100 / rows}%`, + backgroundImage: `linear-gradient(to right, ${theme.palette.Neutral[400]} 1px, transparent 1px), + linear-gradient(to bottom, ${theme.palette.Neutral[400]} 1px, transparent 1px)`, + }; + return ( + { + dispatchLayout({ type: "newLayout", layout: newLayout }); + }} + width={targetRef.current?.offsetWidth} + maxRows={rows} + cols={cols} + rowHeight={ + targetRef.current?.offsetHeight && + targetRef.current?.offsetHeight / rows + } + compactType={null} + preventCollision + containerPadding={[0, 0]} + margin={[0, 0]} + isDroppable + isDraggable={!previewMode} + isResizable={!previewMode} + > + {layout.map((item) => ( +
+ +
+ ))} +
+ ); +}; + +export default ActivityGrid; diff --git a/frontend/src/components/course_authoring/activity/grid/DraggableSource.tsx b/frontend/src/components/course_authoring/activity/grid/DraggableSource.tsx new file mode 100644 index 00000000..ea137bf7 --- /dev/null +++ b/frontend/src/components/course_authoring/activity/grid/DraggableSource.tsx @@ -0,0 +1,110 @@ +import React, { useState, ReactNode, useContext } from "react"; +import Draggable, { DraggableEventHandler } from "react-draggable"; +import { Box } from "@mui/material"; +import { ActivityLayoutContext } from "../../../../contexts/ActivityLayoutContext"; +import { ActivityDataContext } from "../../../../contexts/ActivityDataContext"; +import { defaultTextElement } from "../elements/defaultElements"; + +const elementIsInChain = ( + elementToTraverse: HTMLElement | null, + elementToFind: HTMLElement, +): HTMLElement | false => { + if (elementToTraverse === elementToFind) { + return elementToFind; + } + if (elementToTraverse?.parentElement) { + return elementIsInChain(elementToTraverse.parentElement, elementToFind); + } + return false; +}; + +const getMouseEvent = (e: MouseEvent | TouchEvent) => { + if (e instanceof MouseEvent) { + return { clientX: e.clientX, clientY: e.clientY }; + } + if (e instanceof TouchEvent && e.touches.length > 0) { + const touch = e.touches[0]; + return { clientX: touch.clientX, clientY: touch.clientY }; + } + return null; +}; + +interface DraggableSourceProps { + onDrag?: DraggableEventHandler; + onStop?: DraggableEventHandler; + elementType: string; + children: ReactNode; +} + +const DraggableSource: React.FC = ({ + onDrag, + onStop, + elementType, + children, +}) => { + const { targetRef, layout, dispatchLayout } = useContext( + ActivityLayoutContext, + ); + const { elements, setElements, setActiveElementId } = + useContext(ActivityDataContext); + const [inserted, setInserted] = useState(false); + + const onDragOverwrite: DraggableEventHandler = (e, data) => { + if (onDrag) onDrag(e, data); + const target = elementIsInChain( + e.target as HTMLElement, + targetRef.current!, + ); + if (!target && inserted) { + dispatchLayout({ type: "clearTemp" }); + setInserted(false); + const placeHolder = document.querySelector( + ".react-grid-placeholder", + ) as HTMLElement; + if (placeHolder) placeHolder.style.transform = "translate(-8000px, 0px)"; + return; + } + if (target && !inserted) { + const mouseEvent = getMouseEvent(e as MouseEvent | TouchEvent); + if (mouseEvent) { + dispatchLayout({ type: "addTemp", mouseEvent, elementType }); + } + setInserted(true); + } + }; + + const onStopOverwrite: DraggableEventHandler = (e, data) => { + if (onStop) onStop(e, data); + if (inserted) { + dispatchLayout({ type: "finaliseTemporaryItem" }); + setInserted(false); + // Add new element to elements data object + const newLayoutItem = layout[layout.length - 1]; + const updatedElements = new Map(elements); + const newId = newLayoutItem.i; + updatedElements.set(newId, defaultTextElement); + setElements(updatedElements); + setActiveElementId(newId); + } else { + dispatchLayout({ type: "clearTemp" }); + } + }; + + return ( + + + {children} + + + ); +}; + +export default DraggableSource; diff --git a/frontend/src/components/course_authoring/activity/grid/GridElement.tsx b/frontend/src/components/course_authoring/activity/grid/GridElement.tsx new file mode 100644 index 00000000..d1516d96 --- /dev/null +++ b/frontend/src/components/course_authoring/activity/grid/GridElement.tsx @@ -0,0 +1,108 @@ +import React, { useRef, useEffect } from "react"; +import BaseElement from "../elements/BaseElement"; + +interface MouseEventLike { + clientX: number; + clientY: number; +} + +interface GridElementProps { + temp?: boolean; + id: string; + elementType?: string; + mouseEvent?: MouseEventLike; + style?: React.CSSProperties; + className?: string; + onMouseDown?: React.MouseEventHandler; + onMouseUp?: React.MouseEventHandler; + onTouchEnd?: React.TouchEventHandler; + onTouchStart?: React.TouchEventHandler; +} + +const createDragStartEvent = ( + element: HTMLElement, + mouseEvent: MouseEventLike, +) => { + const event = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + clientX: mouseEvent.clientX, + clientY: mouseEvent.clientY, + }); + + // Fake getBoundingClientRect for one call + // This way, we can influence where the drag action is started + const originalGetBoundingClientRect = + element.getBoundingClientRect.bind(element); + const modifiedGetBoundingClientRect = () => ({ + ...originalGetBoundingClientRect(), + left: mouseEvent.clientX, + top: mouseEvent.clientY, + }); + + Object.defineProperty(element, "getBoundingClientRect", { + value: modifiedGetBoundingClientRect, + configurable: true, + }); + + element.dispatchEvent(event); + + // Restore original getBoundingClientRect + Object.defineProperty(element, "getBoundingClientRect", { + value: originalGetBoundingClientRect, + }); +}; + +const GridElement: React.FC = ({ + temp, + id, + mouseEvent, + elementType, + style, + className, + onMouseDown, + onMouseUp, + onTouchEnd, + onTouchStart, +}) => { + const ref = useRef(null); + + // Fake the drag start event if it's a new element with property temp + useEffect(() => { + const refCur = ref.current; + if (refCur && temp && mouseEvent) { + createDragStartEvent(refCur, mouseEvent); + } + + return () => { + if (refCur && temp && mouseEvent) { + // TODO: Cannot initiate drag stop event because drag start event is not recognized + // createDragStopEvent(refCur); + } + }; + }, [ref, temp, mouseEvent]); + + return ( +
{ + if (onMouseDown) onMouseDown(e); + }} + onMouseUp={(e) => { + if (onMouseUp) onMouseUp(e); + }} + onTouchEnd={onTouchEnd} + onTouchStart={onTouchStart} + > + +
+ ); +}; + +GridElement.defaultProps = { + mouseEvent: { clientX: 0, clientY: 0 }, +}; + +export default GridElement; diff --git a/frontend/src/components/course_authoring/activity/grid/layoutReducer.ts b/frontend/src/components/course_authoring/activity/grid/layoutReducer.ts new file mode 100644 index 00000000..db0b2680 --- /dev/null +++ b/frontend/src/components/course_authoring/activity/grid/layoutReducer.ts @@ -0,0 +1,107 @@ +import { merge, keyBy } from "lodash"; +import { v4 as uuidv4 } from "uuid"; +import { ElementPosition } from "../../../../types/CourseElementTypes"; + +interface MouseEventLike { + clientX: number; + clientY: number; +} + +type ResizeHandle = "sw" | "nw" | "se" | "ne" | "n" | "s" | "w" | "e"; +export interface LayoutItem extends ElementPosition { + i: string; + elementType?: string; + temp?: boolean; + mouseEvent?: MouseEventLike; + resizeHandles?: ResizeHandle[]; + style?: React.CSSProperties; + className?: string; + onMouseDown?: React.MouseEventHandler; + onMouseUp?: React.MouseEventHandler; + onTouchEnd?: React.TouchEventHandler; + onTouchStart?: React.TouchEventHandler; +} + +export interface LayoutAction { + type: string; + h?: number; + w?: number; + elementType?: string; + mouseEvent?: MouseEventLike; + layout?: LayoutItem[]; + i?: string; + resizeHandles?: string[]; +} + +const calculateGridPosition = ( + mouseEvent: MouseEventLike | undefined, + cellSize: number, +) => { + if (!mouseEvent) { + return { cellX: 0, cellY: 0 }; + } + + const xPos = mouseEvent.clientX; + const yPos = mouseEvent.clientY; + + const cellX = Math.floor(xPos / cellSize); + const cellY = Math.floor(yPos / cellSize); + + return { cellX, cellY }; +}; + +const cellSize = 50; // Size of each cell + +const layoutReducer = ( + state: LayoutItem[], + action: LayoutAction, +): LayoutItem[] => { + switch (action.type) { + case "addTemp": { + if (state.findIndex((item) => item.temp) !== -1) { + return state; + } + + const { cellX, cellY } = calculateGridPosition( + action.mouseEvent, + cellSize, + ); + return [ + ...state, + { + x: Math.max(cellX, 0), + y: Math.max(cellY, 0), + h: 2, + w: 3, + elementType: action.elementType ?? "", + temp: true, + mouseEvent: action.mouseEvent, + i: uuidv4(), + resizeHandles: ["sw", "nw", "se", "ne"], + }, + ]; + } + case "clearTemp": + return state.filter((item) => !item.temp); + case "finaliseTemporaryItem": + return state.map((item) => ({ ...item, temp: false })); + case "newLayout": { + if (state.findIndex((item) => item.temp) !== -1) { + return state; + } + const merged = merge(keyBy(state, "i"), keyBy(action.layout, "i")); + return Object.values(merged) as LayoutItem[]; + } + case "deleteItem": { + const updatedState = state.filter((item) => item.i !== action.i); + return updatedState; + } + case "reset": { + return []; + } + default: + return state; + } +}; + +export default layoutReducer; diff --git a/frontend/src/components/course_authoring/BaseModule.tsx b/frontend/src/components/course_authoring/prototype/BaseModule.tsx similarity index 100% rename from frontend/src/components/course_authoring/BaseModule.tsx rename to frontend/src/components/course_authoring/prototype/BaseModule.tsx diff --git a/frontend/src/components/pages/CreateModulePage.tsx b/frontend/src/components/course_authoring/prototype/CreateModulePage.tsx similarity index 96% rename from frontend/src/components/pages/CreateModulePage.tsx rename to frontend/src/components/course_authoring/prototype/CreateModulePage.tsx index 33fadd6d..33ad66b1 100644 --- a/frontend/src/components/pages/CreateModulePage.tsx +++ b/frontend/src/components/course_authoring/prototype/CreateModulePage.tsx @@ -4,11 +4,11 @@ import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; import VisibilityIcon from "@mui/icons-material/Visibility"; import ModeEditIcon from "@mui/icons-material/ModeEdit"; -import DraggableSource from "../common/grid/DraggableSource"; -import GridElement from "../common/grid/GridElement"; -import layoutReducer from "../common/grid/layoutReducer"; -import BaseModule from "../course_authoring/BaseModule"; -import ConfirmationModal from "../common/modals/ConfirmationModal"; +import DraggableSource from "./DraggableSource"; +import GridElement from "./GridElement"; +import layoutReducer from "./layoutReducer"; +import BaseModule from "./BaseModule"; +import ConfirmationModal from "../../common/modals/ConfirmationModal"; const CreateModule = () => { const ref = useRef(null); @@ -17,7 +17,6 @@ const CreateModule = () => { const [editMode, setEditMode] = useState(true); const [componentData, setComponentData] = useState(new Map()); const [isModalOpen, setModalOpen] = useState(false); - const gridContainerStyle = { backgroundColor: "lightgray", borderRight: "1px solid black", diff --git a/frontend/src/components/common/grid/DraggableSource.tsx b/frontend/src/components/course_authoring/prototype/DraggableSource.tsx similarity index 100% rename from frontend/src/components/common/grid/DraggableSource.tsx rename to frontend/src/components/course_authoring/prototype/DraggableSource.tsx diff --git a/frontend/src/components/course_authoring/EditMatch.tsx b/frontend/src/components/course_authoring/prototype/EditMatch.tsx similarity index 100% rename from frontend/src/components/course_authoring/EditMatch.tsx rename to frontend/src/components/course_authoring/prototype/EditMatch.tsx diff --git a/frontend/src/components/course_authoring/EditTextBox.tsx b/frontend/src/components/course_authoring/prototype/EditTextBox.tsx similarity index 100% rename from frontend/src/components/course_authoring/EditTextBox.tsx rename to frontend/src/components/course_authoring/prototype/EditTextBox.tsx diff --git a/frontend/src/components/common/grid/GridElement.tsx b/frontend/src/components/course_authoring/prototype/GridElement.tsx similarity index 98% rename from frontend/src/components/common/grid/GridElement.tsx rename to frontend/src/components/course_authoring/prototype/GridElement.tsx index cd1ad3bb..b5862afe 100644 --- a/frontend/src/components/common/grid/GridElement.tsx +++ b/frontend/src/components/course_authoring/prototype/GridElement.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect } from "react"; -import BaseModule from "../../course_authoring/BaseModule"; +import BaseModule from "./BaseModule"; interface MouseEventLike { clientX: number; diff --git a/frontend/src/components/course_authoring/Match.tsx b/frontend/src/components/course_authoring/prototype/Match.tsx similarity index 100% rename from frontend/src/components/course_authoring/Match.tsx rename to frontend/src/components/course_authoring/prototype/Match.tsx diff --git a/frontend/src/components/course_authoring/TextBox.tsx b/frontend/src/components/course_authoring/prototype/TextBox.tsx similarity index 100% rename from frontend/src/components/course_authoring/TextBox.tsx rename to frontend/src/components/course_authoring/prototype/TextBox.tsx diff --git a/frontend/src/components/common/grid/layoutReducer.ts b/frontend/src/components/course_authoring/prototype/layoutReducer.ts similarity index 100% rename from frontend/src/components/common/grid/layoutReducer.ts rename to frontend/src/components/course_authoring/prototype/layoutReducer.ts diff --git a/frontend/src/components/pages/courses/CourseUnitsPage.tsx b/frontend/src/components/course_viewing/CourseViewingPage.tsx similarity index 93% rename from frontend/src/components/pages/courses/CourseUnitsPage.tsx rename to frontend/src/components/course_viewing/CourseViewingPage.tsx index 891e7017..85a017de 100644 --- a/frontend/src/components/pages/courses/CourseUnitsPage.tsx +++ b/frontend/src/components/course_viewing/CourseViewingPage.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from "react"; import { Box, Button, Typography, useTheme } from "@mui/material"; import MenuOpenIcon from "@mui/icons-material/MenuOpen"; -import UnitSidebar from "../../courses/UnitSidebar"; -import { CourseUnit } from "../../../types/CourseTypes"; -import CourseAPIClient from "../../../APIClients/CourseAPIClient"; +import UnitSidebar from "./UnitSidebar"; +import { CourseUnit } from "../../types/CourseTypes"; +import CourseAPIClient from "../../APIClients/CourseAPIClient"; export default function CourseUnitsPage() { const theme = useTheme(); diff --git a/frontend/src/components/courses/UnitSidebar.tsx b/frontend/src/components/course_viewing/UnitSidebar.tsx similarity index 89% rename from frontend/src/components/courses/UnitSidebar.tsx rename to frontend/src/components/course_viewing/UnitSidebar.tsx index b8e2fe50..1daf167a 100644 --- a/frontend/src/components/courses/UnitSidebar.tsx +++ b/frontend/src/components/course_viewing/UnitSidebar.tsx @@ -11,6 +11,7 @@ import { } from "@mui/material"; import MenuOpenIcon from "@mui/icons-material/MenuOpen"; import { CourseUnit } from "../../types/CourseTypes"; +import { useUser } from "../../hooks/useUser"; interface UnitSideBarProps { courseUnits: CourseUnit[]; @@ -20,6 +21,7 @@ interface UnitSideBarProps { export default function UnitSidebar(props: UnitSideBarProps) { const theme = useTheme(); + const user = useUser(); const { courseUnits, handleClose, open } = props; const [selectedIndex, setSelectedIndex] = useState(0); @@ -35,10 +37,11 @@ export default function UnitSidebar(props: UnitSideBarProps) { - + handleListItemClick(event, index)} diff --git a/frontend/src/components/pages/Default.tsx b/frontend/src/components/pages/Default.tsx index 7ccbdefc..bc5d60ee 100644 --- a/frontend/src/components/pages/Default.tsx +++ b/frontend/src/components/pages/Default.tsx @@ -1,6 +1,5 @@ import React, { useContext } from "react"; import SampleContext from "../../contexts/SampleContext"; -import Navbar from "../common/navbar/Navbar"; const TeamInfoDisplay = () => { const { teamName, numTerms, members, isActive } = useContext(SampleContext); @@ -23,7 +22,6 @@ const TeamInfoDisplay = () => { const Default = (): React.ReactElement => { return (
-

Default Page

diff --git a/frontend/src/components/pages/MyAccount.tsx b/frontend/src/components/pages/MyAccountPage.tsx similarity index 100% rename from frontend/src/components/pages/MyAccount.tsx rename to frontend/src/components/pages/MyAccountPage.tsx diff --git a/frontend/src/components/pages/ViewHelpRequestsPage.tsx b/frontend/src/components/pages/ViewHelpRequestsPage.tsx index bcbd75a1..625c951b 100644 --- a/frontend/src/components/pages/ViewHelpRequestsPage.tsx +++ b/frontend/src/components/pages/ViewHelpRequestsPage.tsx @@ -15,7 +15,6 @@ import { Checkbox, } from "@mui/material"; import TablePaginationActions from "@mui/material/TablePagination/TablePaginationActions"; -import Navbar from "../common/navbar/Navbar"; import { useFacilitator } from "../../hooks/useUser"; import HelpRequestAPIClient from "../../APIClients/HelpRequestAPIClient"; import { HelpRequest } from "../../types/HelpRequestType"; @@ -71,7 +70,6 @@ const ViewHelpRequestsPage = (): React.ReactElement => { return (
- diff --git a/frontend/src/constants/Routes.ts b/frontend/src/constants/Routes.ts index dffbca71..5e12b529 100644 --- a/frontend/src/constants/Routes.ts +++ b/frontend/src/constants/Routes.ts @@ -26,4 +26,6 @@ export const VIEW_HELP_REQUESTS_PAGE = "/help-requests"; export const FORGOT_PASSWORD_PAGE = "/forgot-password"; -export const COURSES_PAGE = "/course"; +export const COURSE_PAGE = "/course"; + +export const COURSE_AUTHORING_PAGE = "/course-authoring/:unitId/:moduleId"; diff --git a/frontend/src/contexts/ActivityDataContext.tsx b/frontend/src/contexts/ActivityDataContext.tsx new file mode 100644 index 00000000..12e258a2 --- /dev/null +++ b/frontend/src/contexts/ActivityDataContext.tsx @@ -0,0 +1,42 @@ +import React, { createContext, useState } from "react"; +import { ElementData } from "../types/CourseElementTypes"; + +type ActivityDataContextType = { + elements: Map; + setElements: (_elements: Map) => void; + activeElementId: string | null; + setActiveElementId: (_activeElementId: string | null) => void; +}; + +export const ActivityDataContext = createContext({ + elements: new Map(), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setElements: (_elements: Map): void => {}, + activeElementId: null, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setActiveElementId: (_activeElementId: string | null): void => {}, +}); + +type ActivityDataContextProviderProps = { + children: React.ReactNode; +}; + +export const ActivityDataContextProvider = ({ + children, +}: ActivityDataContextProviderProps) => { + const [elements, setElements] = useState(new Map()); + const [activeElementId, setActiveElementId] = useState(null); + return ( + + {children} + + ); +}; diff --git a/frontend/src/contexts/ActivityLayoutContext.tsx b/frontend/src/contexts/ActivityLayoutContext.tsx new file mode 100644 index 00000000..d03a2983 --- /dev/null +++ b/frontend/src/contexts/ActivityLayoutContext.tsx @@ -0,0 +1,39 @@ +import React, { createContext, createRef, useReducer, useRef } from "react"; +import layoutReducer, { + LayoutAction, + LayoutItem, +} from "../components/course_authoring/activity/grid/layoutReducer"; + +type ActivityLayoutContextType = { + layout: LayoutItem[]; + dispatchLayout: React.Dispatch; + targetRef: React.MutableRefObject; +}; + +export const ActivityLayoutContext = createContext({ + layout: [], + dispatchLayout: () => {}, + targetRef: createRef(), +}); + +type ActivityLayoutContextProviderProps = { + children: React.ReactNode; +}; + +export const ActivityLayoutContextProvider = ({ + children, +}: ActivityLayoutContextProviderProps) => { + const targetRef = useRef(null); + const [layout, dispatchLayout] = useReducer(layoutReducer, []); + return ( + + {children} + + ); +}; diff --git a/frontend/src/contexts/CourseAuthoringContext.ts b/frontend/src/contexts/CourseAuthoringContext.ts new file mode 100644 index 00000000..f48699de --- /dev/null +++ b/frontend/src/contexts/CourseAuthoringContext.ts @@ -0,0 +1,20 @@ +import { createContext } from "react"; +import { CoursePage } from "../types/CourseTypes"; + +type CourseAuthoringContextType = { + activePage: CoursePage | null; + setActivePage: (_activePage: CoursePage | null) => void; + previewMode: boolean; + setPreviewMode: (_previewMode: boolean) => void; +}; + +const CourseAuthoringContext = createContext({ + activePage: null, + /* eslint-disable @typescript-eslint/no-unused-vars */ + setActivePage: (_activePage: CoursePage | null): void => {}, + previewMode: false, + /* eslint-disable @typescript-eslint/no-unused-vars */ + setPreviewMode: () => {}, +}); + +export default CourseAuthoringContext; diff --git a/frontend/src/theme/buttonStyles.ts b/frontend/src/theme/buttonStyles.ts new file mode 100644 index 00000000..9ada162f --- /dev/null +++ b/frontend/src/theme/buttonStyles.ts @@ -0,0 +1,23 @@ +import palette from "./palette"; +import { Role } from "../types/AuthTypes"; + +const roles: Role[] = ["Administrator", "Facilitator", "Learner"]; + +const containedStyles = {}; +roles.forEach((role: Role) => { + Object.assign(containedStyles, { + [`&.MuiButton-contained${role}`]: { + color: palette.Neutral[100], + backgroundColor: palette[role].Default, + "&:hover": { + backgroundColor: palette[role].Default, + }, + }, + }); +}); + +const buttonStyles = { + contained: containedStyles, +}; + +export default buttonStyles; diff --git a/frontend/src/theme/palette.ts b/frontend/src/theme/palette.ts new file mode 100644 index 00000000..398ee01f --- /dev/null +++ b/frontend/src/theme/palette.ts @@ -0,0 +1,51 @@ +import { PaletteOptions } from "@mui/material/styles"; + +const palette: PaletteOptions = { + Learner: { + Light: "#F5FDFF", + Hover: "#CCF5FF", + Default: "#006877", + Pressed: "#00363F", + }, + Administrator: { + Light: "#FFF8F6", + Hover: "#FFD9CC", + Default: "#8F4C34", + Pressed: "#5C1900", + }, + Facilitator: { + Light: "#F9F5FF", + Hover: "#D6D6FF", + Default: "#555A92", + Pressed: "#1F257A", + }, + Error: { + Light: "#FFF2F0", + Hover: "#FFD1CC", + Default: "#BA1A1A", + Pressed: "#690005", + }, + Success: { + Light: "#FDFFF0", + Hover: "#FAFFCC", + Default: "#687021", + Pressed: "#444B04", + }, + Warning: { + Light: "#FFFBEF", + Hover: "#FFF2CC", + Default: "#775900", + Pressed: "#3F2F00", + }, + Neutral: { + 100: "#FFFFFF", + 200: "#F8FAFA", + 300: "#E4E5E5", + 400: "#CACCCC", + 500: "#6F797B", + 600: "#404B4D", + 700: "#111111", + }, +}; + +export default palette; diff --git a/frontend/src/theme/theme.ts b/frontend/src/theme/theme.ts index f5748acd..ebd80373 100644 --- a/frontend/src/theme/theme.ts +++ b/frontend/src/theme/theme.ts @@ -1,174 +1,9 @@ -import { - createTheme, - PaletteOptions, - TypographyOptions, -} from "@mui/material/styles"; +import { createTheme } from "@mui/material/styles"; +import palette from "./palette"; +import typography from "./typography"; +import buttonStyles from "./buttonStyles"; import "@fontsource/lexend-deca"; -const palette: PaletteOptions = { - Learner: { - Light: "#F5FDFF", - Hover: "#CCF5FF", - Default: "#006877", - Pressed: "#00363F", - }, - Administrator: { - Light: "#FFF8F6", - Hover: "#FFD9CC", - Default: "#8F4C34", - Pressed: "#5C1900", - }, - Facilitator: { - Light: "#F9F5FF", - Hover: "#D6D6FF", - Default: "#555A92", - Pressed: "#1F257A", - }, - Error: { - Light: "#FFF2F0", - Hover: "#FFD1CC", - Default: "#BA1A1A", - Pressed: "#690005", - }, - Success: { - Light: "#FDFFF0", - Hover: "#FAFFCC", - Default: "#687021", - Pressed: "#444B04", - }, - Warning: { - Light: "#FFFBEF", - Hover: "#FFF2CC", - Default: "#775900", - Pressed: "#3F2F00", - }, - Neutral: { - 100: "#FFFFFF", - 200: "#F8FAFA", - 300: "#E4E5E5", - 400: "#CACCCC", - 500: "#6F797B", - 600: "#404B4D", - 700: "#111111", - }, -}; - -const typography: TypographyOptions = { - displayLarge: { - fontSize: "46px", - fontWeight: 700, - lineHeight: "110%", - letterSpacing: "-0.2px", - textTransform: "none", - }, - displayMedium: { - fontSize: "36px", - fontWeight: 700, - lineHeight: "110%", - textTransform: "none", - }, - displaySmall: { - fontSize: "28px", - fontWeight: 700, - lineHeight: "110%", - textTransform: "none", - }, - headlineLarge: { - fontSize: "28px", - fontWeight: 600, - lineHeight: "120%", - textTransform: "none", - }, - headlineMedium: { - fontSize: "26px", - fontWeight: 600, - lineHeight: "120%", - textTransform: "none", - }, - headlineSmall: { - fontSize: "22px", - fontWeight: 600, - lineHeight: "120%", - textTransform: "none", - }, - titleLarge: { - fontSize: "24px", - fontWeight: 600, - lineHeight: "120%", - textTransform: "none", - }, - titleMedium: { - fontSize: "18px", - fontWeight: 600, - lineHeight: "120%", - letterSpacing: "0.12px", - textTransform: "none", - }, - titleSmall: { - fontSize: "16px", - fontWeight: 600, - lineHeight: "120%", - letterSpacing: "0.08px", - textTransform: "none", - }, - bodyLarge: { - fontSize: "18px", - fontWeight: 400, - lineHeight: "140%", - letterSpacing: "0.4px", - textTransform: "none", - }, - bodyMedium: { - fontSize: "16px", - fontWeight: 400, - lineHeight: "140%", - letterSpacing: "0.2px", - textTransform: "none", - }, - bodySmall: { - fontSize: "14px", - fontWeight: 400, - lineHeight: "140%", - letterSpacing: "0.32px", - textTransform: "none", - }, - labelLargeProminent: { - fontSize: "16px", - fontWeight: 600, - lineHeight: "120%", - letterSpacing: "0.08px", - textTransform: "none", - }, - labelLarge: { - fontSize: "14px", - fontWeight: 300, - lineHeight: "120%", - letterSpacing: "0.7px", - textTransform: "uppercase", - }, - labelMediumProminent: { - fontSize: "14px", - fontWeight: 600, - lineHeight: "120%", - letterSpacing: "0.4px", - textTransform: "none", - }, - labelMedium: { - fontSize: "14px", - fontWeight: 300, - lineHeight: "120%", - letterSpacing: "0.7px", - textTransform: "uppercase", - }, - labelSmall: { - fontSize: "12.5px", - fontWeight: 300, - lineHeight: "120%", - letterSpacing: "0.625px", - textTransform: "uppercase", - }, -}; - const getTheme = () => { const theme = createTheme({ typography: { @@ -187,6 +22,9 @@ const getTheme = () => { input: typography.bodyMedium, }, }, + MuiButton: { + styleOverrides: buttonStyles, + }, }, }); return theme; diff --git a/frontend/src/theme/typography.ts b/frontend/src/theme/typography.ts new file mode 100644 index 00000000..1cd3f397 --- /dev/null +++ b/frontend/src/theme/typography.ts @@ -0,0 +1,119 @@ +import { TypographyOptions } from "@mui/material/styles"; + +const typography: TypographyOptions = { + displayLarge: { + fontSize: "46px", + fontWeight: 700, + lineHeight: "110%", + letterSpacing: "-0.2px", + textTransform: "none", + }, + displayMedium: { + fontSize: "36px", + fontWeight: 700, + lineHeight: "110%", + textTransform: "none", + }, + displaySmall: { + fontSize: "28px", + fontWeight: 700, + lineHeight: "110%", + textTransform: "none", + }, + headlineLarge: { + fontSize: "28px", + fontWeight: 600, + lineHeight: "120%", + textTransform: "none", + }, + headlineMedium: { + fontSize: "26px", + fontWeight: 600, + lineHeight: "120%", + textTransform: "none", + }, + headlineSmall: { + fontSize: "22px", + fontWeight: 600, + lineHeight: "120%", + textTransform: "none", + }, + titleLarge: { + fontSize: "24px", + fontWeight: 600, + lineHeight: "120%", + textTransform: "none", + }, + titleMedium: { + fontSize: "18px", + fontWeight: 600, + lineHeight: "120%", + letterSpacing: "0.12px", + textTransform: "none", + }, + titleSmall: { + fontSize: "16px", + fontWeight: 600, + lineHeight: "120%", + letterSpacing: "0.08px", + textTransform: "none", + }, + bodyLarge: { + fontSize: "18px", + fontWeight: 400, + lineHeight: "140%", + letterSpacing: "0.4px", + textTransform: "none", + }, + bodyMedium: { + fontSize: "16px", + fontWeight: 400, + lineHeight: "140%", + letterSpacing: "0.2px", + textTransform: "none", + }, + bodySmall: { + fontSize: "14px", + fontWeight: 400, + lineHeight: "140%", + letterSpacing: "0.32px", + textTransform: "none", + }, + labelLargeProminent: { + fontSize: "16px", + fontWeight: 600, + lineHeight: "120%", + letterSpacing: "0.08px", + textTransform: "none", + }, + labelLarge: { + fontSize: "14px", + fontWeight: 300, + lineHeight: "120%", + letterSpacing: "0.7px", + textTransform: "uppercase", + }, + labelMediumProminent: { + fontSize: "14px", + fontWeight: 600, + lineHeight: "120%", + letterSpacing: "0.4px", + textTransform: "none", + }, + labelMedium: { + fontSize: "14px", + fontWeight: 300, + lineHeight: "120%", + letterSpacing: "0.7px", + textTransform: "uppercase", + }, + labelSmall: { + fontSize: "12.5px", + fontWeight: 300, + lineHeight: "120%", + letterSpacing: "0.625px", + textTransform: "uppercase", + }, +}; + +export default typography; diff --git a/frontend/src/types/CourseElementTypes.ts b/frontend/src/types/CourseElementTypes.ts new file mode 100644 index 00000000..8123d7f1 --- /dev/null +++ b/frontend/src/types/CourseElementTypes.ts @@ -0,0 +1,60 @@ +export enum DisplayElementType { + Text = "Text", + Image = "Image", +} + +export enum InteractiveElementType { + TextInput = "TextInput", + NumberInput = "NumberInput", + CheckboxInput = "CheckboxInput", + MultipleChoice = "MultipleChoice", + Matching = "Matching", +} + +export enum HybridElementType { + Table = "Table", +} + +export type ElementType = + | DisplayElementType + | InteractiveElementType + | HybridElementType; + +export interface ElementPosition { + x: number; + y: number; + w: number; + h: number; +} + +export interface ElementData { + type: ElementType; +} + +export type FontSize = "Large" | "Medium" | "Small"; +export function isFontSize(fontSize: string): fontSize is FontSize { + return ["Large", "Medium", "Small"].includes(fontSize); +} + +export type FontWeight = "Normal" | "Bold"; +export function isFontWeight(fontWeight: string): fontWeight is FontWeight { + return ["Normal", "Bold"].includes(fontWeight); +} + +export type TextAlign = "Left" | "Center" | "Right"; +export function isTextAlign(textAlign: string): textAlign is TextAlign { + return ["Left", "Center", "Right"].includes(textAlign); +} + +export interface TextElementData extends ElementData { + text: string; + fontSize: FontSize; + fontWeight: FontWeight; + textAlign: TextAlign; +} + +export function isTextElementData( + elementData: ElementData, +): elementData is TextElementData { + return elementData.type === DisplayElementType.Text; +} diff --git a/frontend/src/types/CourseTypes.ts b/frontend/src/types/CourseTypes.ts index dbb15d8d..042eb9fa 100644 --- a/frontend/src/types/CourseTypes.ts +++ b/frontend/src/types/CourseTypes.ts @@ -1,5 +1,34 @@ +import { ElementData, ElementPosition } from "./CourseElementTypes"; + export type CourseUnit = { id: string; displayIndex: number; title: string; }; + +export type PageType = "Lesson" | "Activity"; + +export type CoursePage = { + id: string; + type: PageType; +}; + +export type LessonPage = CoursePage & { + source: string; +}; + +export function isLessonPage(page: CoursePage): page is LessonPage { + return page.type === "Lesson"; +} + +export interface Element extends ElementPosition { + data: ElementData | undefined; +} + +export type ActivityPage = CoursePage & { + elements: Element[]; +}; + +export function isActivityPage(page: CoursePage): page is ActivityPage { + return page.type === "Activity"; +} diff --git a/frontend/src/types/HelpRequestType.ts b/frontend/src/types/HelpRequestType.ts index 38f081b8..616e94e0 100644 --- a/frontend/src/types/HelpRequestType.ts +++ b/frontend/src/types/HelpRequestType.ts @@ -17,7 +17,6 @@ export interface HelpRequest { }; page: { id: string; - displayIndex: number; title: string; }; completed: boolean; diff --git a/frontend/src/utils/CourseUtils.ts b/frontend/src/utils/CourseUtils.ts new file mode 100644 index 00000000..ad8de1c2 --- /dev/null +++ b/frontend/src/utils/CourseUtils.ts @@ -0,0 +1,22 @@ +import { LayoutItem } from "../components/course_authoring/activity/grid/layoutReducer"; +import { ElementData } from "../types/CourseElementTypes"; +import { ActivityPage, Element } from "../types/CourseTypes"; + +// eslint-disable-next-line import/prefer-default-export +export function synthesizeActivityPage( + targetPage: ActivityPage, + layout: LayoutItem[], + elements: Map, +): ActivityPage { + const synthesizedElements: Element[] = layout.map((layoutItem) => ({ + x: layoutItem.x, + y: layoutItem.y, + w: layoutItem.w, + h: layoutItem.h, + data: elements.get(layoutItem.i), + })); + return { + ...targetPage, + elements: synthesizedElements, + }; +} diff --git a/frontend/src/utils/ErrorUtils.ts b/frontend/src/utils/ErrorUtils.ts new file mode 100644 index 00000000..a5e59f0f --- /dev/null +++ b/frontend/src/utils/ErrorUtils.ts @@ -0,0 +1,4 @@ +/* eslint-disable-next-line import/prefer-default-export */ +export const getErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : "Unknown error occurred."; +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 39bf5ea3..5df7a6fe 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2738,6 +2738,11 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@types/uuid@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + "@types/warning@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.3.tgz#d1884c8cc4a426d1ac117ca2611bf333834c6798" @@ -10682,6 +10687,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== +uuid@^11.0.4: + version "11.0.4" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.4.tgz#37943977894ef806d2919a7ca3f89d6e23c60bac" + integrity sha512-IzL6VtTTYcAhA/oghbFJ1Dkmqev+FpQWnCBaKq/gUluLxliWvO8DPFWfIviRmYbtaavtSQe4WBL++rFjdcGWEg== + uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"