Skip to content

Commit 1cb1fa1

Browse files
authored
add card type and end date (#3504)
* Add card type text * remove chevron from org cards * add end date display and tests for it * fix separator rendering on compact cards * match by test id * gate end date section on hasEnded * Remove old start date countdown and consolidate with end date indicator into one "course date indicator" * consolidate date popover and date indicator into one component with the indicator as a trigger and reorganize utility functions from cardshared * fix mobile alignment of CTA * fix cta alignment in unenrolled course cards * add a threshold to formatCalendarDays that retuns a short date string from Intl if the day is more than 90 days away in either direction * fix courseDateText null check * switch date popover trigger to a button instead of link and add aria attributes * add screen reader readable aria labels to all CTA's in the dashboard * fix tests
1 parent e7e2016 commit 1cb1fa1

16 files changed

Lines changed: 674 additions & 417 deletions

frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -993,9 +993,9 @@ describe("ContractContent", () => {
993993
const { orgX, user, mitxOnlineUser } = setupOrgAndUser()
994994

995995
// Create courses with specific, predictable dates for the contract runs
996-
// Use a date that's guaranteed to be in the future relative to mocked time
997-
const specificStartDate = "2024-12-01T00:00:00Z"
998-
const specificEndDate = "2025-01-15T00:00:00Z"
996+
// Use a date within 90 days of frozen time so formatCalendarDays returns "in X days"
997+
const specificStartDate = "2024-02-15T00:00:00Z"
998+
const specificEndDate = "2024-03-31T00:00:00Z"
999999

10001000
const baseCourses = factories.courses.courses({ count: 3 }).results
10011001
const program = factories.programs.program({
@@ -1084,7 +1084,7 @@ describe("ContractContent", () => {
10841084
is_enrollable: true,
10851085
title: `CORRECT RUN - ${run.title}`,
10861086
courseware_url: "https://correct-run.example.com",
1087-
start_date: "2024-12-01T00:00:00Z", // Future date relative to mocked time
1087+
start_date: "2024-02-15T00:00:00Z", // Future date relative to mocked time, within 90-day threshold
10881088
}
10891089
}),
10901090
}))

frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/CardShared.tsx

Lines changed: 138 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import React from "react"
2-
import { Link, Stack, styled } from "ol-components"
2+
import { Link, Popover, Stack, styled, Typography } from "ol-components"
33
import NextLink from "next/link"
44
import { EnrollmentStatus } from "./helpers"
55
import { ActionButton, Button, ButtonLink } from "@mitodl/smoot-design"
6-
import {
7-
DashboardResource,
8-
DashboardType,
9-
getEnrollmentStatus,
10-
} from "./model/dashboardViewModel"
11-
import { calendarDaysUntil } from "ol-utilities"
6+
import { formatDate, getTimezone } from "ol-utilities"
7+
import { getCourseDateText, getRelativeDateContent } from "./courseDateUtils"
128

139
const CardRoot = styled.div<{
1410
screenSize: "desktop" | "mobile"
@@ -66,6 +62,11 @@ const CardRoot = styled.div<{
6662
},
6763
])
6864

65+
const CardTypeText = styled(Typography)(({ theme }) => ({
66+
...theme.typography.subtitle4,
67+
color: theme.custom.colors.silverGrayDark,
68+
}))
69+
6970
const TitleHeading = styled.h3(({ theme }) => ({
7071
margin: 0,
7172
[theme.breakpoints.down("md")]: {
@@ -134,21 +135,6 @@ const MenuButton = styled(ActionButton)<{
134135
},
135136
])
136137

137-
const CountdownRoot = styled.div<{ layout?: "default" | "compact" }>(
138-
({ theme, layout = "default" }) => ({
139-
width: layout === "compact" ? "88px" : "100%",
140-
paddingRight: layout === "compact" ? "0px" : "32px",
141-
display: "flex",
142-
justifyContent: "center",
143-
alignSelf: "end",
144-
whiteSpace: layout === "compact" ? "nowrap" : "normal",
145-
[theme.breakpoints.down("md")]: {
146-
marginRight: "0px",
147-
justifyContent: "flex-start",
148-
},
149-
}),
150-
)
151-
152138
const COURSEWARE_BUTTON_WIDTH = "88px"
153139

154140
// Thin vertical divider shown between the certificate/upgrade links and the
@@ -183,79 +169,157 @@ const CoursewareButtonLink = styled(ButtonLink)(({ theme, variant }) => ({
183169
}),
184170
}))
185171

186-
/**
187-
* Rewrites a raw mitxonline certificate link (`/certificate/{uuid}/`) to MIT
188-
* Learn's own certificate route (`/certificate/{certificateType}/{uuid}/`).
189-
*/
190-
const getCertificateLink = (
191-
link: string | null | undefined,
192-
certificateType: "course" | "program",
193-
): string | null => {
194-
if (!link) return null
195-
const pattern = /\/certificate\/([^/]+)\/?$/
196-
return link.replace(pattern, `/certificate/${certificateType}/$1/`)
172+
const Separator = styled.span(({ theme }) => ({
173+
display: "inline-block",
174+
width: "1px",
175+
height: "12px",
176+
margin: "0 8px",
177+
backgroundColor: theme.custom.colors.silverGrayLight,
178+
}))
179+
180+
const DateText = styled(Typography)(({ theme }) => ({
181+
...theme.typography.subtitle3,
182+
color: theme.custom.colors.silverGrayDark,
183+
}))
184+
185+
const CourseDateText: React.FC<{
186+
startDate?: string | null | undefined
187+
endDate?: string | null | undefined
188+
className?: string
189+
}> = ({ startDate, endDate, className }) => {
190+
const text = getCourseDateText(startDate, endDate)
191+
if (!text) return null
192+
return <DateText className={className}>{text}</DateText>
197193
}
198194

199-
const getDashboardEnrollmentStatus = (
200-
resource: DashboardResource,
201-
): EnrollmentStatus => {
202-
const hasValidCertificate =
203-
resource.type !== DashboardType.Course && !!resource.data.certificate?.uuid
195+
const DatePopoverContent = styled.div({
196+
maxWidth: "240px",
197+
display: "flex",
198+
padding: "8px",
199+
flexDirection: "column",
200+
alignItems: "flex-start",
201+
gap: "28px",
202+
alignSelf: "stretch",
203+
})
204204

205-
if (resource.type === DashboardType.Course) {
206-
return EnrollmentStatus.NotEnrolled
207-
}
205+
const DatePopoverTrigger = styled("button")(({ theme }) => ({
206+
...theme.typography.body2,
207+
color: theme.custom.colors.silverGrayDark,
208+
background: "none",
209+
border: "none",
210+
padding: 0,
211+
cursor: "pointer",
212+
"&:hover": {
213+
color: theme.custom.colors.silverGrayDark,
214+
textDecoration: "underline",
215+
},
216+
}))
208217

209-
if (resource.type === DashboardType.CourseRunEnrollment) {
210-
return hasValidCertificate
211-
? EnrollmentStatus.Completed
212-
: getEnrollmentStatus(resource.data)
213-
}
218+
const DatePopoverHeading = styled(Typography)(({ theme }) => ({
219+
color: theme.custom.colors.black,
220+
}))
214221

215-
return hasValidCertificate
216-
? EnrollmentStatus.Completed
217-
: EnrollmentStatus.Enrolled
218-
}
222+
const DatePopoverBody = styled(Typography)(({ theme }) => ({
223+
color: theme.custom.colors.black,
224+
}))
219225

220-
const CourseStartCountdown: React.FC<{
221-
startDate: string
222-
className?: string
223-
layout?: "default" | "compact"
224-
}> = ({ startDate, className, layout = "default" }) => {
225-
const calendarDays = calendarDaysUntil(startDate)
226+
const DatePopoverDismissButton = styled(Button)({
227+
alignSelf: "flex-end",
228+
})
229+
230+
const CourseDateSummary: React.FC<{
231+
startDate?: string | null | undefined
232+
endDate?: string | null | undefined
233+
}> = ({ startDate, endDate }) => {
234+
const popoverId = React.useId()
235+
const [popoverAnchorEl, setPopoverAnchorEl] =
236+
React.useState<HTMLButtonElement | null>(null)
237+
238+
const triggerText = getCourseDateText(startDate, endDate)
239+
const startDateFormatted = startDate
240+
? `${formatDate(startDate, "MMMM D, YYYY h:mm A")} ${getTimezone(startDate)}`
241+
: null
242+
const endDateFormatted = endDate
243+
? `${formatDate(endDate, "MMMM D, YYYY h:mm A")} ${getTimezone(endDate)}`
244+
: null
245+
const datePopoverContent = getRelativeDateContent(
246+
startDate,
247+
endDate,
248+
startDateFormatted,
249+
endDateFormatted,
250+
)
251+
252+
if (!triggerText || !datePopoverContent) return null
226253

227-
let value
228-
if (calendarDays === null || calendarDays < 0) return null
229-
if (calendarDays === 0) {
230-
value = "Starts Today"
231-
} else if (calendarDays === 1) {
232-
value = "Starts Tomorrow"
233-
} else {
234-
value = `Starts in ${calendarDays} days`
235-
}
236254
return (
237-
<CountdownRoot layout={layout}>
238-
<Link color="black" size="small" className={className}>
239-
{value}
240-
</Link>
241-
</CountdownRoot>
255+
<>
256+
<Popover
257+
anchorEl={popoverAnchorEl}
258+
open={!!popoverAnchorEl}
259+
onClose={() => setPopoverAnchorEl(null)}
260+
>
261+
<DatePopoverContent
262+
id={popoverId}
263+
role="dialog"
264+
aria-label="Important Dates"
265+
>
266+
<Stack direction="column" gap="4px">
267+
<DatePopoverHeading variant="subtitle3">
268+
Important Dates:
269+
</DatePopoverHeading>
270+
<DatePopoverBody variant="body3">
271+
This course{" "}
272+
<Typography variant="subtitle3" component="span">
273+
{datePopoverContent.startVerb}
274+
</Typography>{" "}
275+
{datePopoverContent.startSuffix}
276+
</DatePopoverBody>
277+
</Stack>
278+
{datePopoverContent.endVerb && datePopoverContent.endSuffix && (
279+
<DatePopoverBody variant="body3">
280+
This course{" "}
281+
<Typography variant="subtitle3" component="span">
282+
{datePopoverContent.endVerb}
283+
</Typography>{" "}
284+
{datePopoverContent.endSuffix}
285+
</DatePopoverBody>
286+
)}
287+
<DatePopoverDismissButton
288+
variant="primary"
289+
size="small"
290+
onClick={() => setPopoverAnchorEl(null)}
291+
>
292+
Got it!
293+
</DatePopoverDismissButton>
294+
</DatePopoverContent>
295+
</Popover>
296+
<DatePopoverTrigger
297+
aria-expanded={!!popoverAnchorEl}
298+
aria-haspopup="dialog"
299+
aria-controls={popoverAnchorEl ? popoverId : undefined}
300+
onClick={(event) => setPopoverAnchorEl(event.currentTarget)}
301+
>
302+
{triggerText}
303+
</DatePopoverTrigger>
304+
</>
242305
)
243306
}
244307

245308
export {
246309
CardRoot,
310+
CardTypeText,
247311
TitleHeading,
248312
TitleLink,
249313
TitleText,
250314
SubtitleLinkRoot,
251315
SubtitleLink,
252316
MenuButton,
253-
CountdownRoot,
254-
CourseStartCountdown,
255317
HorizontalSeparator,
256318
CoursewareActionColumn,
257319
CoursewareButton,
258320
CoursewareButtonLink,
259-
getCertificateLink,
260-
getDashboardEnrollmentStatus,
321+
Separator,
322+
DateText,
323+
CourseDateText,
324+
CourseDateSummary,
261325
}

frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ describe.each([
100100
run: { ...futureRunDates, courseware_url: coursewareUrl },
101101
})
102102
renderWithProviders(<EnrolledCourseCard enrollment={enrollment} />)
103-
const btn = await within(getCard()).findByRole("link", { name: "Continue" })
103+
const btn = await within(getCard()).findByRole("link", {
104+
name: /^Continue course:/,
105+
})
104106
expect(btn).toHaveAttribute("href", coursewareUrl)
105107
})
106108

@@ -194,6 +196,54 @@ describe.each([
194196
expect(getCard()).toHaveTextContent(/starts in 5 days/i)
195197
})
196198

199+
// ---------------------------------------------------------------------------
200+
// End date display
201+
// ---------------------------------------------------------------------------
202+
203+
test.each([
204+
{
205+
endDate: moment().add(2, "days").toISOString(),
206+
expectedText: /ends in 2 days/i,
207+
case: "future (plural)",
208+
},
209+
{
210+
endDate: moment().add(1, "day").toISOString(),
211+
expectedText: /ends tomorrow/i,
212+
case: "future (singular)",
213+
},
214+
{
215+
endDate: moment().subtract(2, "days").toISOString(),
216+
expectedText: /ended 2 days ago/i,
217+
case: "past (plural)",
218+
},
219+
{
220+
endDate: moment().subtract(1, "day").toISOString(),
221+
expectedText: /ended yesterday/i,
222+
case: "past (singular)",
223+
},
224+
])("Shows end date text ($case)", ({ endDate, expectedText }) => {
225+
setupUserApis()
226+
const enrollment = mitxonline.factories.enrollment.courseEnrollment({
227+
run: { ...currentRunDates, end_date: endDate },
228+
})
229+
renderWithProviders(<EnrolledCourseCard enrollment={enrollment} />)
230+
expect(within(getCard()).getByText(expectedText)).toBeInTheDocument()
231+
})
232+
233+
test("Does not show end date text when run has no end date", () => {
234+
setupUserApis()
235+
const enrollment = mitxonline.factories.enrollment.courseEnrollment({
236+
run: { ...currentRunDates, end_date: null },
237+
})
238+
renderWithProviders(<EnrolledCourseCard enrollment={enrollment} />)
239+
expect(
240+
within(getCard()).queryByText(/ends in \d+ day/i),
241+
).not.toBeInTheDocument()
242+
expect(
243+
within(getCard()).queryByText(/ended \d+ day/i),
244+
).not.toBeInTheDocument()
245+
})
246+
197247
// ---------------------------------------------------------------------------
198248
// Enrollment status indicator
199249
// ---------------------------------------------------------------------------
@@ -338,6 +388,26 @@ describe.each([
338388
},
339389
)
340390

391+
test("Does not show upgrade banner when upgrade deadline has passed", () => {
392+
setupUserApis()
393+
const enrollment = mitxonline.factories.enrollment.courseEnrollment({
394+
enrollment_mode: EnrollmentMode.Audit,
395+
b2b_contract_id: null,
396+
run: {
397+
...currentRunDates,
398+
is_upgradable: true,
399+
upgrade_deadline: moment().subtract(1, "day").toISOString(),
400+
upgrade_product_id: faker.number.int(),
401+
upgrade_product_price: faker.commerce.price(),
402+
upgrade_product_is_active: true,
403+
},
404+
})
405+
renderWithProviders(<EnrolledCourseCard enrollment={enrollment} />)
406+
expect(
407+
within(getCard()).queryByTestId("upgrade-root"),
408+
).not.toBeInTheDocument()
409+
})
410+
341411
test("Does not show upgrade banner when upgrade_product_is_active is missing", () => {
342412
setupUserApis()
343413
const enrollment = mitxonline.factories.enrollment.courseEnrollment({

0 commit comments

Comments
 (0)