diff --git a/lms/static/scripts/frontend_apps/api-types.ts b/lms/static/scripts/frontend_apps/api-types.ts index 1d3665f458..4d1574f915 100644 --- a/lms/static/scripts/frontend_apps/api-types.ts +++ b/lms/static/scripts/frontend_apps/api-types.ts @@ -153,12 +153,21 @@ export type BaseDashboardStats = { replies: number; }; +/** + * Response for `/api/dashboard/courses/{course_id}` call. + */ +export type Course = { + id: number; + title: string; +}; + /** * Response for `/api/dashboard/assignments/{assignment_id}` call. */ export type Assignment = { id: number; title: string; + course: Course; }; /** @@ -170,21 +179,10 @@ export type StudentStats = BaseDashboardStats & { export type StudentsStats = StudentStats[]; -/** - * Response for `/api/dashboard/courses/{course_id}` call. - */ -export type Course = { - id: number; - title: string; -}; - /** * Response for `/api/dashboard/courses/{course_id}/assignments/stats` call. */ -export type AssignmentStats = { - id: number; - title: string; - course: Course; +export type AssignmentStats = Assignment & { stats: BaseDashboardStats; }; diff --git a/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx b/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx index b7eac381fe..40ee2fc537 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx +++ b/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx @@ -1,9 +1,4 @@ -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from '@hypothesis/frontend-shared'; +import { Card, CardContent, CardHeader } from '@hypothesis/frontend-shared'; import { useParams } from 'wouter-preact'; import type { Assignment, StudentsStats } from '../../api-types'; @@ -11,6 +6,7 @@ import { useConfig } from '../../config'; import { useAPIFetch } from '../../utils/api'; import { formatDateTime } from '../../utils/date'; import { replaceURLParams } from '../../utils/url'; +import DashboardBreadcrumbs from './DashboardBreadcrumbs'; import OrderableActivityTable from './OrderableActivityTable'; /** @@ -27,21 +23,31 @@ export default function AssignmentActivity() { replaceURLParams(routes.assignment_stats, { assignment_id: assignmentId }), ); - const title = `Assignment: ${assignment.data?.title}`; - return ( <Card> - <CardHeader fullWidth> - <CardTitle tagName="h2" data-testid="title"> + <CardHeader fullWidth classes="flex-col !gap-x-0 !items-start"> + {assignment.data && ( + <div className="mb-3 mt-1"> + <DashboardBreadcrumbs + links={[ + { + title: assignment.data.course.title, + href: `/courses/${assignment.data.course.id}`, + }, + ]} + /> + </div> + )} + <h2 data-testid="title" className="text-lg text-brand font-semibold"> {assignment.isLoading && 'Loading...'} {assignment.error && 'Could not load assignment title'} - {assignment.data && title} - </CardTitle> + {assignment.data && assignment.data.title} + </h2> </CardHeader> <CardContent> <OrderableActivityTable loading={students.isLoading} - title={assignment.isLoading ? 'Loading...' : title} + title={assignment.data?.title ?? 'Loading...'} emptyMessage={ students.error ? 'Could not load students' : 'No students found' } diff --git a/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx b/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx new file mode 100644 index 0000000000..bd0f78f6b4 --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx @@ -0,0 +1,65 @@ +import { + ArrowLeftIcon, + CaretRightIcon, + Link, +} from '@hypothesis/frontend-shared'; +import classnames from 'classnames'; +import { Fragment } from 'preact'; +import { Link as RouterLink } from 'wouter-preact'; + +type BreadcrumbLink = { + title: string; + href: string; +}; + +export type DashboardBreadcrumbsProps = { + links: BreadcrumbLink[]; +}; + +function BreadcrumbLink({ + title, + href, + classes, +}: BreadcrumbLink & { classes?: string }) { + return ( + <RouterLink href={href} asChild> + <Link + underline="hover" + variant="text-light" + classes={classnames('truncate md:max-w-[350px]', classes)} + > + <ArrowLeftIcon className="md:hidden mr-2 inline" /> + {title} + </Link> + </RouterLink> + ); +} + +export default function DashboardBreadcrumbs({ + links, +}: DashboardBreadcrumbsProps) { + return ( + <div + className="font-bold flex flex-col md:flex-row gap-0.5" + data-testid="breadcrumbs-container" + > + {links.map(({ title, href }, index) => { + const isLastLink = index === links.length - 1; + + return ( + <Fragment key={`${index}${href}`}> + <BreadcrumbLink + href={href} + title={title} + classes={classnames('md:inline', { + // In mobile devices, show only the last link + hidden: !isLastLink, + })} + /> + {index !== links.length - 1 && <CaretRightIcon />} + </Fragment> + ); + })} + </div> + ); +} diff --git a/lms/static/scripts/frontend_apps/components/dashboard/test/AssignmentActivity-test.js b/lms/static/scripts/frontend_apps/components/dashboard/test/AssignmentActivity-test.js index eccd200d79..2f633a569c 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/test/AssignmentActivity-test.js +++ b/lms/static/scripts/frontend_apps/components/dashboard/test/AssignmentActivity-test.js @@ -37,7 +37,14 @@ describe('AssignmentActivity', () => { beforeEach(() => { fakeUseAPIFetch = sinon.stub().callsFake(url => ({ isLoading: false, - data: url.endsWith('stats') ? students : { title: 'The title' }, + data: url.endsWith('stats') + ? students + : { + title: 'The title', + course: { + title: 'The course', + }, + }, })); fakeConfig = { dashboard: { @@ -72,7 +79,7 @@ describe('AssignmentActivity', () => { fakeUseAPIFetch.returns({ isLoading: true }); const wrapper = createComponent(); - const titleElement = wrapper.find('CardTitle[data-testid="title"]'); + const titleElement = wrapper.find('[data-testid="title"]'); const tableElement = wrapper.find('OrderableActivityTable'); assert.equal(titleElement.text(), 'Loading...'); @@ -83,7 +90,7 @@ describe('AssignmentActivity', () => { fakeUseAPIFetch.returns({ error: new Error('Something failed') }); const wrapper = createComponent(); - const titleElement = wrapper.find('CardTitle[data-testid="title"]'); + const titleElement = wrapper.find('[data-testid="title"]'); const tableElement = wrapper.find('OrderableActivityTable'); assert.equal(titleElement.text(), 'Could not load assignment title'); @@ -92,9 +99,9 @@ describe('AssignmentActivity', () => { it('shows expected title', () => { const wrapper = createComponent(); - const titleElement = wrapper.find('CardTitle[data-testid="title"]'); + const titleElement = wrapper.find('[data-testid="title"]'); const tableElement = wrapper.find('OrderableActivityTable'); - const expectedTitle = `Assignment: The title`; + const expectedTitle = 'The title'; assert.equal(titleElement.text(), expectedTitle); assert.equal(tableElement.prop('title'), expectedTitle); diff --git a/lms/static/scripts/frontend_apps/components/dashboard/test/DashboardBreadcrumbs-test.js b/lms/static/scripts/frontend_apps/components/dashboard/test/DashboardBreadcrumbs-test.js new file mode 100644 index 0000000000..95235b50dc --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/dashboard/test/DashboardBreadcrumbs-test.js @@ -0,0 +1,34 @@ +import { checkAccessibility } from '@hypothesis/frontend-testing'; +import { mount } from 'enzyme'; + +import DashboardBreadcrumbs from '../DashboardBreadcrumbs'; + +describe('DashboardBreadcrumbs', () => { + function createComponent(props = {}) { + return mount(<DashboardBreadcrumbs {...props} />); + } + + [['foo', 'bar'], [], ['one', 'two', 'three']].forEach(links => { + it('shows expected amount of links', () => { + const wrapper = createComponent({ + title: 'Hello world', + links: links.map(title => ({ title, href: `/${title}` })), + }); + + assert.equal(wrapper.find('BreadcrumbLink').length, links.length); + }); + }); + + it( + 'should pass a11y checks', + checkAccessibility({ + content: () => + createComponent({ + links: [ + { title: 'Foo', href: '/foo' }, + { title: 'Bar', href: '/bar' }, + ], + }), + }), + ); +});