Skip to content

Redesign My Assignments page #1283

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jan 29, 2025
139 changes: 80 additions & 59 deletions src/app/components/elements/Assignments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,26 @@ import React, {useMemo, useState} from "react";
import {Button, Col, Collapse, Label, Row} from "reactstrap";
import {Link} from "react-router-dom";
import {
above,
determineGameboardStagesAndDifficulties,
determineGameboardSubjects,
difficultyShortLabelMap,
extractTeacherName,
generateGameboardSubjectHexagons,
HUMAN_SUBJECTS,
isDefined,
PATHS,
siteSpecific,
stageLabelMap,
TAG_ID,
tags
tags,
useDeviceSize
} from "../../services";
import {formatDate} from "./DateString";
import {formatDate, FRIENDLY_DATE, getFriendlyDaysUntil} from "./DateString";
import {Circle} from "./svg/Circle";
import { PhyHexIcon } from "./svg/PhyHexIcon";
import { Subject } from "../../../IsaacAppTypes";
import { HoverableTooltip } from "./HoverableTooltip";

const midnightOf = (date: Date | number) => {
const d = new Date(date);
Expand All @@ -41,6 +47,8 @@ const PhyAssignmentCard = ({assignment}: {assignment: AssignmentDTO}) => {
const now = new Date();
const boardStagesAndDifficulties = useMemo(() => determineGameboardStagesAndDifficulties(assignment.gameboard), [assignment.gameboard]);

const deviceSize = useDeviceSize();

const topics = tags.getTopicTags(Array.from((assignment.gameboard?.contents || []).reduce((a, c) => {
if (isDefined(c.tags) && c.tags.length > 0) {
return new Set([...Array.from(a), ...c.tags.map(id => id as TAG_ID)]);
Expand All @@ -49,71 +57,84 @@ const PhyAssignmentCard = ({assignment}: {assignment: AssignmentDTO}) => {
}, new Set<TAG_ID>())).filter(tag => isDefined(tag))).map(tag => tag.title).sort();
const assignmentStartDate = assignment.scheduledStartDate ?? assignment.creationDate;

return <>
<hr />
<Row className="board-card" data-testid="my-assignment">
<Col xs={8} sm={10} md={8}>
<Link to={`${PATHS.GAMEBOARD}#${assignment.gameboardId}`}>
<h4 className="text-break">{isDefined(assignment.gameboard) && assignment.gameboard.title}</h4>
</Link>
{isDefined(assignmentStartDate) &&
<p className="mb-0" data-testid={"gameboard-assigned"}><strong>Assigned:</strong> {formatDate(assignmentStartDate)}</p>
}
{isDefined(assignment.dueDate) && isDefined(assignment.gameboard) && now > midnightOf(assignment.dueDate) && assignment.gameboard.percentageAttempted !== 100
? <p className="mb-0"><strong className="overdue">Overdue:</strong> {formatDate(assignment.dueDate)}</p>
: <>{assignment.dueDate && <p className="mb-0"><strong>Due:</strong> {formatDate(assignment.dueDate)}</p>}</>
}
{isDefined(assignment.groupName) &&
<p className="mb-0"><strong>Group:</strong> {assignment.groupName}</p>
}
{isDefined(assignment.assignerSummary) &&
<p className="mb-0"><strong>By:</strong> {extractTeacherName(assignment.assignerSummary)}</p>
}
const boardSubjects = determineGameboardSubjects(assignment.gameboard);

return <Link className="w-100 assignments-card px-3 py-2 mb-3" to={`${PATHS.GAMEBOARD}#${assignment.gameboardId}`}>
<Row data-testid="my-assignment">
<Col xs={8}>
<div className="d-flex align-items-center">
<div className="d-flex justify-content-center board-subject-hexagon-size me-4 my-2">
<div className="board-subject-hexagon-container justify-content-center">
{generateGameboardSubjectHexagons(boardSubjects)}
</div>
<PhyHexIcon icon="page-icon-question-pack" subject={boardSubjects[0] as Subject} className="assignment-hex ps-3"/>
</div>
<div className="d-flex flex-column flex-grow-1">
<h4 className="text-break m-0">{isDefined(assignment.gameboard) && assignment.gameboard.title}</h4>
{above['md'](deviceSize) && boardSubjects.length > 0 && <div className="d-flex align-items-center mb-2">
{boardSubjects.map((subject) => <span key={subject} className="badge rounded-pill bg-theme me-1" data-bs-theme={subject}>{HUMAN_SUBJECTS[subject]}</span>)}
</div>}
</div>
</div>

<Row>
<Col>
{isDefined(assignmentStartDate) &&
<p className="mb-0" data-testid={"gameboard-assigned"}>Assigned <HoverableTooltip tooltip={formatDate(assignmentStartDate, FRIENDLY_DATE)}>
<strong>{getFriendlyDaysUntil(assignmentStartDate)}</strong>
</HoverableTooltip></p>
}
{isDefined(assignment.dueDate) && isDefined(assignment.gameboard) && now > midnightOf(assignment.dueDate) && assignment.gameboard.percentageAttempted !== 100
? <p className="mb-0"><strong className="overdue">Overdue!</strong> <span className="small text-muted">(due {formatDate(assignment.dueDate)})</span></p>
: <>{assignment.dueDate && <p className="mb-0">Due <strong>{getFriendlyDaysUntil(assignment.dueDate)}</strong></p>}</>
}
</Col>
{above['md'](deviceSize) && <Col>
{isDefined(assignment.groupName) &&
<p className="mb-0"><strong>Group:</strong> {assignment.groupName}</p>
}
{isDefined(assignment.assignerSummary) &&
<p className="mb-0"><strong>By:</strong> {extractTeacherName(assignment.assignerSummary)}</p>
}
</Col>}
</Row>

{isDefined(assignment.notes) && <p className="mb-0"><strong>Notes:</strong> {assignment.notes}</p>}
<Button className="my-2 btn-underline" color="link" onClick={() => setShowMore(!showMore)}>
<Button className="my-2 btn-underline" color="link" onClick={(e) => {e.preventDefault(); setShowMore(!showMore);}}>
{showMore ? "Show less" : "Show more"}
</Button>
</Col>

<Col xs={4} sm={2} md={4}>
<Row className="justify-content-end me-0 me-md-1">
<Col md="auto">
<Label className="d-block w-100 text-center text-nowrap">
Attempted
<div className="d-flex w-100 justify-content-center board-subject-hexagon-size">
<div className="board-subject-hexagon-container justify-content-center">
{isDefined(assignment.gameboard) && ((assignment.gameboard.percentageAttempted === 100) ?
<span className="board-subject-hexagon subject-complete"/> :
<>
{generateGameboardSubjectHexagons(determineGameboardSubjects(assignment.gameboard))}
<div className="board-percent-completed">{assignment.gameboard.percentageAttempted ?? 0}</div>
</>
)}
</div>
</div>
</Label>
</Col>
<Col md="auto">
<Label className="d-block w-100 text-center text-nowrap">
Correct
<div className="d-flex w-100 justify-content-center board-subject-hexagon-size">
<div className="board-subject-hexagon-container justify-content-center">
{isDefined(assignment.gameboard) && ((assignment.gameboard.percentageCorrect === 100) ?
<span className="board-subject-hexagon subject-complete"/> :
<>
{generateGameboardSubjectHexagons(determineGameboardSubjects(assignment.gameboard))}
<div className="board-percent-completed">{assignment.gameboard.percentageCorrect ?? 0}</div>
</>
)}
</div>
</div>
</Label>
</Col>
</Row>
<Col xs={4}>
<div className="d-flex flex-wrap justify-content-center justify-content-md-end justify-content-lg-center justify-content-xl-end column-gap-4">
<Label className="d-block w-max-content text-center text-nowrap pt-3">
{isDefined(assignment.gameboard) && ((assignment.gameboard.percentageAttempted === 100) ?
<span className="board-subject-hexagon subject-complete"/> :
<div className="board-percent-completed">{assignment.gameboard.percentageAttempted ?? 0}</div>
)}
Attempted
</Label>
<Label className="d-block w-max-content text-center text-nowrap pt-3">
{isDefined(assignment.gameboard) && ((assignment.gameboard.percentageCorrect === 100) ?
<span className="board-subject-hexagon subject-complete"/> :
<div className="board-percent-completed">{assignment.gameboard.percentageCorrect ?? 0}</div>
)}
Correct
</Label>
</div>
</Col>
</Row>
<Collapse isOpen={showMore} className="w-100">
<Row>
{!above['md'](deviceSize) && <Col xs={12}>
{isDefined(assignment.groupName) &&
<p className="mb-0"><strong>Group:</strong> {assignment.groupName}</p>
}
{isDefined(assignment.assignerSummary) &&
<p className="mb-0"><strong>By:</strong> {extractTeacherName(assignment.assignerSummary)}</p>
}
</Col>}

<Col xs={12} md={8} className="mt-sm-2">
<p className="mb-0"><strong>Questions:</strong> {assignment.gameboard?.contents?.length || "0"}</p>
{isDefined(topics) && topics.length > 0 && <p className="mb-0">
Expand Down Expand Up @@ -149,7 +170,7 @@ const PhyAssignmentCard = ({assignment}: {assignment: AssignmentDTO}) => {
</Col>
</Row>
</Collapse>
</>;
</Link>;
};

const CSAssignmentCard = ({assignment}: {assignment: AssignmentDTO}) => {
Expand Down
37 changes: 35 additions & 2 deletions src/app/components/elements/DateString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ export const NUMERIC_DATE_AND_TIME = new Intl.DateTimeFormat(dateLocale, {year:
// Wed, 22 Jan 2020, 13:00
export const FRIENDLY_DATE_AND_TIME = new Intl.DateTimeFormat(dateLocale, {weekday: "short", day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit", hour12: false});

export function formatDate(date: number | Date | undefined) {
export function formatDate(date: number | Date | undefined, formatter=NUMERIC_DATE) {
if (!date) return "Unknown";
const dateObject = new Date(date);
return NUMERIC_DATE.format(dateObject);
return formatter.format(dateObject);
}

// 2020-01-22
Expand All @@ -31,3 +31,36 @@ export const DateString = ({children, defaultValue, formatter=FRIENDLY_DATE_AND_
return fallback;
}
};

/**
* Calculates and returns a friendly string representing the number of days until a given date.
* If the date is in the future, return "today" if today, "tomorrow" if tomorrow, "in x days" if within a week, "on <date>" if later.
* If the date is in the past, return "yesterday" if yesterday, "x days ago" if within a week, "on <date>" if earlier.
* @param date the date to compare against
* @returns the friendly string.
*/
export function getFriendlyDaysUntil(date: number | Date) : string {
const today = new Date();

// const daysUntil = Math.ceil(((typeof date === "number" ? date : date.getTime()) - today.getTime()) / 86400000); // 1000*60*60*24

const getStartOfDay = (date: number | Date): Date => {
const dateObject = new Date(date);
dateObject.setHours(0, 0, 0, 0);
return dateObject;
};

const daysUntil = (getStartOfDay(date).getTime() - getStartOfDay(today).getTime()) / 86400000;

if (daysUntil < 0) {
// in the past
if (daysUntil === -1) return "yesterday";
if (daysUntil && daysUntil > -7) return `${-daysUntil} days ago`;
} else {
// in the future
if (daysUntil === 0) return "today";
if (daysUntil === 1) return "tomorrow";
if (daysUntil && daysUntil < 7) return `in ${daysUntil} days`;
}
return `on ${FRIENDLY_DATE.format(new Date(date))}`;
}
17 changes: 17 additions & 0 deletions src/app/components/elements/HoverableTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React, { useRef } from "react";
import { UncontrolledTooltip } from "reactstrap";

interface HoverablePopupProps {
tooltip: string;
}

export const HoverableTooltip = ({tooltip, children}: React.PropsWithChildren<HoverablePopupProps>) => {
const ref = useRef<HTMLDivElement>(null);

return <div ref={ref} className="hoverable-tooltip">
<UncontrolledTooltip target={ref} placement="top">
{tooltip}
</UncontrolledTooltip>
{children}
</div>;
};
96 changes: 94 additions & 2 deletions src/app/components/elements/layout/SidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import React, { ChangeEvent, ReactNode, RefObject, useEffect, useRef, useState }
import { Col, ColProps, RowProps, Input, Label, Offcanvas, OffcanvasBody, OffcanvasHeader, Row } from "reactstrap";
import partition from "lodash/partition";
import classNames from "classnames";
import { ContentSummaryDTO, IsaacConceptPageDTO, QuestionDTO } from "../../../../IsaacApiTypes";
import { above, AUDIENCE_DISPLAY_FIELDS, determineAudienceViews, filterAudienceViewsByProperties, getThemeFromContextAndTags, isAda, isDefined, siteSpecific, stageLabelMap, useDeviceSize } from "../../../services";
import { AssignmentDTO, ContentSummaryDTO, IsaacConceptPageDTO, QuestionDTO } from "../../../../IsaacApiTypes";
import { above, AUDIENCE_DISPLAY_FIELDS, determineAudienceViews, filterAssignmentsByStatus, filterAudienceViewsByProperties, getDistinctAssignmentGroups, getDistinctAssignmentSetters, getThemeFromContextAndTags, isAda, isDefined, siteSpecific, stageLabelMap, useDeviceSize } from "../../../services";
import { StageAndDifficultySummaryIcons } from "../StageAndDifficultySummaryIcons";
import { selectors, useAppSelector } from "../../../state";
import { Link } from "react-router-dom";
import { Tag } from "../../../../IsaacAppTypes";
import { AffixButton } from "../AffixButton";
import { getHumanContext } from "../../../services/pageContext";
import { AssignmentState } from "../../pages/MyAssignments";
import { ShowLoadingQuery } from "../../handlers/ShowLoadingQuery";

export const SidebarLayout = (props: RowProps) => {
const { className, ...rest } = props;
Expand Down Expand Up @@ -299,3 +301,93 @@ export const LessonsAndRevisionSidebar = (props: SidebarProps) => {
// TODO
return <ContentSidebar {...props}/>;
};

interface AssignmentStatusCheckboxProps extends React.HTMLAttributes<HTMLLabelElement> {
status: AssignmentState;
statusFilter: AssignmentState[];
setStatusFilter: React.Dispatch<React.SetStateAction<AssignmentState[]>>;
count?: number;
}

const AssignmentStatusCheckbox = (props: AssignmentStatusCheckboxProps) => {
const {status, statusFilter, setStatusFilter, count, ...rest} = props;
return <FilterCheckboxBase
id={status ?? ""} filterTitle={status}
onInputChange={() => !statusFilter.includes(status) ? setStatusFilter(c => [...c.filter(s => s !== AssignmentState.ALL), status]) : setStatusFilter(c => c.filter(s => s !== status))}
checked={statusFilter.includes(status)}
count={count} {...rest}
/>;
};

const AssignmentStatusAllCheckbox = (props: Omit<AssignmentStatusCheckboxProps, "status">) => {
const { statusFilter, setStatusFilter, count, ...rest } = props;
const [previousFilters, setPreviousFilters] = useState<AssignmentState[]>([]);
return <FilterCheckboxBase
id="all" filterTitle="All"
onInputChange={(e) => {
if (e.target.checked) {
setPreviousFilters(statusFilter);
setStatusFilter([AssignmentState.ALL]);
} else {
setStatusFilter(previousFilters);
}
}}
checked={statusFilter.includes(AssignmentState.ALL)}
count={count} {...rest}
/>;
};

interface MyAssignmentsSidebarProps extends SidebarProps {
statusFilter: AssignmentState[];
setStatusFilter: React.Dispatch<React.SetStateAction<AssignmentState[]>>;
titleFilter: string;
setTitleFilter: React.Dispatch<React.SetStateAction<string>>;
groupFilter: string;
setGroupFilter: React.Dispatch<React.SetStateAction<string>>;
setByFilter: string;
setSetByFilter: React.Dispatch<React.SetStateAction<string>>;
assignmentQuery: any;
}

export const MyAssignmentsSidebar = (props: MyAssignmentsSidebarProps) => {
const { statusFilter, setStatusFilter, titleFilter, setTitleFilter, groupFilter, setGroupFilter, setByFilter, setSetByFilter, assignmentQuery, ...rest } = props;

useEffect(() => {
if (statusFilter.length === 0) {
setStatusFilter([AssignmentState.ALL]);
}
}, [statusFilter, setStatusFilter]);

return <ContentSidebar {...rest}>
<ShowLoadingQuery query={assignmentQuery} defaultErrorTitle="" thenRender={(assignments: AssignmentDTO[]) => {
const myAssignments = filterAssignmentsByStatus(assignments);
const assignmentCountByStatus = myAssignments && Object.fromEntries(Object.entries(myAssignments).map(([key, value]) => [key, value.length]));
return <>
<div className="section-divider"/>
<h5>Search assignments</h5>
<Input
className='search--filter-input my-4'
type="search" value={titleFilter || ""}
placeholder="e.g. Forces"
onChange={(e: ChangeEvent<HTMLInputElement>) => setTitleFilter(e.target.value)}
/>
<div className="section-divider"/>
<h5 className="mb-4">Filter by status</h5>
<AssignmentStatusAllCheckbox statusFilter={statusFilter} setStatusFilter={setStatusFilter} count={assignmentCountByStatus?.[AssignmentState.ALL]}/>
<div className="section-divider-small"/>
{Object.values(AssignmentState).filter(s => s !== AssignmentState.ALL).map(state => <AssignmentStatusCheckbox
key={state} status={state} count={assignmentCountByStatus?.[state]}
statusFilter={statusFilter} setStatusFilter={setStatusFilter}
/>)}
<h5 className="mt-4 mb-3">Filter by group</h5>
<Input type="select" value={groupFilter} onChange={e => setGroupFilter(e.target.value)}>
{["All", ...getDistinctAssignmentGroups(assignments)].map(group => <option key={group} value={group}>{group}</option>)}
</Input>
<h5 className="mt-4 mb-3">Filter by assigner</h5>
<Input type="select" value={setByFilter} onChange={e => setSetByFilter(e.target.value)}>
{["All", ...getDistinctAssignmentSetters(assignments)].map(setter => <option key={setter} value={setter}>{setter}</option>)}
</Input>
</>;
}}/>
</ContentSidebar>;
};
Loading
Loading