From 00edf540ea377eec5f532fd5cfa895ce7a5cef83 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 4 Jun 2024 16:24:50 +0200 Subject: [PATCH] Create dashboard organization home page (#6325) --- lms/static/scripts/frontend_apps/api-types.ts | 5 + .../frontend_apps/components/AppRoot.tsx | 2 +- .../dashboard/AssignmentActivity.tsx | 13 ++- .../components/dashboard/CourseActivity.tsx | 20 +++- .../components/dashboard/DashboardApp.tsx | 12 ++- .../dashboard/DashboardBreadcrumbs.tsx | 22 ++-- .../dashboard/OrderableActivityTable.tsx | 6 +- .../dashboard/OrganizationActivity.tsx | 55 ++++++++++ .../dashboard/test/AssignmentActivity-test.js | 6 +- .../test/DashboardBreadcrumbs-test.js | 3 +- .../test/OrganizationActivity-test.js | 102 ++++++++++++++++++ lms/static/scripts/frontend_apps/config.ts | 1 + package.json | 2 +- yarn.lock | 10 +- 14 files changed, 228 insertions(+), 31 deletions(-) create mode 100644 lms/static/scripts/frontend_apps/components/dashboard/OrganizationActivity.tsx create mode 100644 lms/static/scripts/frontend_apps/components/dashboard/test/OrganizationActivity-test.js diff --git a/lms/static/scripts/frontend_apps/api-types.ts b/lms/static/scripts/frontend_apps/api-types.ts index 4d1574f915..1b21340433 100644 --- a/lms/static/scripts/frontend_apps/api-types.ts +++ b/lms/static/scripts/frontend_apps/api-types.ts @@ -161,6 +161,11 @@ export type Course = { title: string; }; +/** + * Response for `/api/dashboard/organizations/{organization_public_id}` call. + */ +export type Courses = Course[]; + /** * Response for `/api/dashboard/assignments/{assignment_id}` call. */ diff --git a/lms/static/scripts/frontend_apps/components/AppRoot.tsx b/lms/static/scripts/frontend_apps/components/AppRoot.tsx index 065ef61cd2..e65cc54e2e 100644 --- a/lms/static/scripts/frontend_apps/components/AppRoot.tsx +++ b/lms/static/scripts/frontend_apps/components/AppRoot.tsx @@ -41,7 +41,7 @@ export default function AppRoot({ initialConfig, services }: AppRootProps) { - + diff --git a/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx b/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx index ee216146dc..e0da83c75c 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx +++ b/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx @@ -1,4 +1,9 @@ -import { Card, CardContent, CardHeader } from '@hypothesis/frontend-shared'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@hypothesis/frontend-shared'; import classnames from 'classnames'; import { useParams } from 'wouter-preact'; @@ -31,7 +36,7 @@ export default function AssignmentActivity() { @@ -47,11 +52,11 @@ export default function AssignmentActivity() { /> )} -

+ {assignment.isLoading && 'Loading...'} {assignment.error && 'Could not load assignment title'} {assignment.data && title} -

+
- + +
+ +
{course.isLoading && 'Loading...'} {course.error && 'Could not load course title'} @@ -80,7 +91,10 @@ export default function CourseActivity() { return
{stats[field]}
; } else if (field === 'title') { return ( - + {stats.title} ); diff --git a/lms/static/scripts/frontend_apps/components/dashboard/DashboardApp.tsx b/lms/static/scripts/frontend_apps/components/dashboard/DashboardApp.tsx index cb71922975..213835faa3 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/DashboardApp.tsx +++ b/lms/static/scripts/frontend_apps/components/dashboard/DashboardApp.tsx @@ -1,11 +1,16 @@ import classnames from 'classnames'; -import { Route, Switch } from 'wouter-preact'; +import { Route, Switch, useParams } from 'wouter-preact'; import AssignmentActivity from './AssignmentActivity'; import CourseActivity from './CourseActivity'; import DashboardFooter from './DashboardFooter'; +import OrganizationActivity from './OrganizationActivity'; export default function DashboardApp() { + const { organizationPublicId } = useParams<{ + organizationPublicId: string; + }>(); + return (
+ + +
diff --git a/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx b/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx index 68586b6caa..e56ba27a56 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx +++ b/lms/static/scripts/frontend_apps/components/dashboard/DashboardBreadcrumbs.tsx @@ -4,6 +4,7 @@ import { Link, } from '@hypothesis/frontend-shared'; import classnames from 'classnames'; +import { useMemo } from 'preact/hooks'; import { Link as RouterLink } from 'wouter-preact'; export type BreadcrumbLink = { @@ -12,7 +13,7 @@ export type BreadcrumbLink = { }; export type DashboardBreadcrumbsProps = { - links: BreadcrumbLink[]; + links?: BreadcrumbLink[]; }; function BreadcrumbLink({ title, href }: BreadcrumbLink) { @@ -30,15 +31,20 @@ function BreadcrumbLink({ title, href }: BreadcrumbLink) { * Navigation breadcrumbs showing a list of links */ export default function DashboardBreadcrumbs({ - links, + links = [], }: DashboardBreadcrumbsProps) { + const linksWithHome = useMemo( + (): BreadcrumbLink[] => [{ title: 'Home', href: '' }, ...links], + [links], + ); + return (
- {links.map(({ title, href }, index) => { - const isLastLink = index === links.length - 1; + {linksWithHome.map(({ title, href }, index) => { + const isLastLink = index === linksWithHome.length - 1; return ( 4, + 'md:max-w-[50%]': linksWithHome.length === 2, + 'md:max-w-[33%]': linksWithHome.length === 3, + 'md:max-w-[25%]': linksWithHome.length === 4, + 'md:max-w-[230px]': linksWithHome.length > 4, })} > diff --git a/lms/static/scripts/frontend_apps/components/dashboard/OrderableActivityTable.tsx b/lms/static/scripts/frontend_apps/components/dashboard/OrderableActivityTable.tsx index 28c4d406f9..955180064e 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/OrderableActivityTable.tsx +++ b/lms/static/scripts/frontend_apps/components/dashboard/OrderableActivityTable.tsx @@ -4,9 +4,7 @@ import { useOrderedRows } from '@hypothesis/frontend-shared'; import type { OrderDirection } from '@hypothesis/frontend-shared/lib/types'; import { useMemo, useState } from 'preact/hooks'; -import type { BaseDashboardStats } from '../../api-types'; - -export type OrderableActivityTableProps = Pick< +export type OrderableActivityTableProps = Pick< DataTableProps, 'emptyMessage' | 'rows' | 'renderItem' | 'loading' | 'title' > & { @@ -32,7 +30,7 @@ const descendingOrderColumns: readonly string[] = [ * Annotation activity table for dashboard views. Includes built-in support for * sorting columns. */ -export default function OrderableActivityTable({ +export default function OrderableActivityTable({ defaultOrderField, rows, columnNames, diff --git a/lms/static/scripts/frontend_apps/components/dashboard/OrganizationActivity.tsx b/lms/static/scripts/frontend_apps/components/dashboard/OrganizationActivity.tsx new file mode 100644 index 0000000000..d02187e15d --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/dashboard/OrganizationActivity.tsx @@ -0,0 +1,55 @@ +import { + Card, + CardContent, + CardHeader, + Link, +} from '@hypothesis/frontend-shared'; +import { Link as RouterLink } from 'wouter-preact'; + +import type { Courses } from '../../api-types'; +import { useConfig } from '../../config'; +import { urlPath, useAPIFetch } from '../../utils/api'; +import { replaceURLParams } from '../../utils/url'; +import OrderableActivityTable from './OrderableActivityTable'; + +export type OrganizationActivityProps = { + organizationPublicId: string; +}; + +/** + * List of courses that belong to a specific organization + */ +export default function OrganizationActivity({ + organizationPublicId, +}: OrganizationActivityProps) { + const { dashboard } = useConfig(['dashboard']); + const { routes } = dashboard; + const courses = useAPIFetch( + replaceURLParams(routes.organization_courses, { + organization_public_id: organizationPublicId, + }), + ); + + return ( + + + + ( + + {stats.title} + + )} + /> + + + ); +} 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 a93a019ccf..173430efd5 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 @@ -79,7 +79,7 @@ describe('AssignmentActivity', () => { fakeUseAPIFetch.returns({ isLoading: true }); const wrapper = createComponent(); - const titleElement = wrapper.find('[data-testid="title"]'); + const titleElement = wrapper.find('CardTitle[data-testid="title"]'); const tableElement = wrapper.find('OrderableActivityTable'); assert.equal(titleElement.text(), 'Loading...'); @@ -90,7 +90,7 @@ describe('AssignmentActivity', () => { fakeUseAPIFetch.returns({ error: new Error('Something failed') }); const wrapper = createComponent(); - const titleElement = wrapper.find('[data-testid="title"]'); + const titleElement = wrapper.find('CardTitle[data-testid="title"]'); const tableElement = wrapper.find('OrderableActivityTable'); assert.equal(titleElement.text(), 'Could not load assignment title'); @@ -99,7 +99,7 @@ describe('AssignmentActivity', () => { it('shows expected title', () => { const wrapper = createComponent(); - const titleElement = wrapper.find('[data-testid="title"]'); + const titleElement = wrapper.find('CardTitle[data-testid="title"]'); const tableElement = wrapper.find('OrderableActivityTable'); const expectedTitle = 'Assignment: The title'; 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 index 8206af2e7d..fd7c35013e 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/test/DashboardBreadcrumbs-test.js +++ b/lms/static/scripts/frontend_apps/components/dashboard/test/DashboardBreadcrumbs-test.js @@ -14,7 +14,8 @@ describe('DashboardBreadcrumbs', () => { links: links.map(title => ({ title, href: `/${title}` })), }); - assert.equal(wrapper.find('BreadcrumbLink').length, links.length); + // Breadcrumbs always renders a static extra link for the home page + assert.equal(wrapper.find('BreadcrumbLink').length, links.length + 1); }); }); diff --git a/lms/static/scripts/frontend_apps/components/dashboard/test/OrganizationActivity-test.js b/lms/static/scripts/frontend_apps/components/dashboard/test/OrganizationActivity-test.js new file mode 100644 index 0000000000..7980f2e86f --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/dashboard/test/OrganizationActivity-test.js @@ -0,0 +1,102 @@ +import { + checkAccessibility, + mockImportedComponents, +} from '@hypothesis/frontend-testing'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; + +import { Config } from '../../../config'; +import OrganizationActivity, { $imports } from '../OrganizationActivity'; + +describe('OrganizationActivity', () => { + const courses = [ + { + id: 1, + title: 'Course A', + }, + { + id: 2, + title: 'Course B', + }, + ]; + + let fakeUseAPIFetch; + let fakeConfig; + + beforeEach(() => { + fakeUseAPIFetch = sinon.stub().resolves(courses); + fakeConfig = { + dashboard: { + routes: { + organization_courses: + '/api/dashboard/organizations/:organization_public_id', + }, + }, + }; + + $imports.$mock(mockImportedComponents()); + $imports.$mock({ + '../../utils/api': { + useAPIFetch: fakeUseAPIFetch, + }, + }); + }); + + afterEach(() => { + $imports.$restore(); + }); + + function createComponent() { + return mount( + + + , + ); + } + + it('sets loading state in table while data is loading', () => { + fakeUseAPIFetch.returns({ isLoading: true }); + + const wrapper = createComponent(); + const tableElement = wrapper.find('OrderableActivityTable'); + + assert.isTrue(tableElement.prop('loading')); + }); + + it('shows error if loading data fails', () => { + fakeUseAPIFetch.returns({ error: new Error('Something failed') }); + + const wrapper = createComponent(); + const tableElement = wrapper.find('OrderableActivityTable'); + + assert.equal(tableElement.prop('emptyMessage'), 'Could not load courses'); + }); + + it('shows empty courses message', () => { + const wrapper = createComponent(); + const tableElement = wrapper.find('OrderableActivityTable'); + + assert.equal(tableElement.prop('emptyMessage'), 'No courses found'); + }); + + courses.forEach(course => { + it('renders course links', () => { + const wrapper = createComponent(); + const item = wrapper + .find('OrderableActivityTable') + .props() + .renderItem(course); + const itemWrapper = mount(item); + + assert.equal(itemWrapper.text(), course.title); + assert.equal(itemWrapper.prop('href'), `/courses/${course.id}`); + }); + }); + + it( + 'should pass a11y checks', + checkAccessibility({ + content: () => createComponent(), + }), + ); +}); diff --git a/lms/static/scripts/frontend_apps/config.ts b/lms/static/scripts/frontend_apps/config.ts index aef99b7284..0f90fb9b92 100644 --- a/lms/static/scripts/frontend_apps/config.ts +++ b/lms/static/scripts/frontend_apps/config.ts @@ -245,6 +245,7 @@ export type DashboardRoutes = { assignment_stats: string; course: string; course_assignment_stats: string; + organization_courses: string; }; export type DashboardConfig = { diff --git a/package.json b/package.json index cae8743927..08ae2d32a0 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@babel/preset-react": "^7.24.6", "@babel/preset-typescript": "^7.24.6", "@hypothesis/frontend-build": "^3.0.0", - "@hypothesis/frontend-shared": "^7.10.0", + "@hypothesis/frontend-shared": "^7.10.1", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^25.0.8", "@rollup/plugin-node-resolve": "^15.2.3", diff --git a/yarn.lock b/yarn.lock index 57da3216de..03469868e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1988,15 +1988,15 @@ __metadata: languageName: node linkType: hard -"@hypothesis/frontend-shared@npm:^7.10.0": - version: 7.10.0 - resolution: "@hypothesis/frontend-shared@npm:7.10.0" +"@hypothesis/frontend-shared@npm:^7.10.1": + version: 7.10.1 + resolution: "@hypothesis/frontend-shared@npm:7.10.1" dependencies: highlight.js: ^11.6.0 wouter-preact: ^3.0.0 peerDependencies: preact: ^10.4.0 - checksum: 22b88111452b83a9a4c6987e47071b167e0c653e22519321eea871d4ffe7f0f38555b88572227e960abade87c62b1b4e46a01ed8d2f827ed05447bc4a886400c + checksum: 758c415d8e68325a3c630122e76bd40895cafbfa00404829549380c873aed6e8fd937c27da5a0632240d547c262425b1374b75e951d498d546aa19c6894daff1 languageName: node linkType: hard @@ -7193,7 +7193,7 @@ __metadata: "@babel/preset-react": ^7.24.6 "@babel/preset-typescript": ^7.24.6 "@hypothesis/frontend-build": ^3.0.0 - "@hypothesis/frontend-shared": ^7.10.0 + "@hypothesis/frontend-shared": ^7.10.1 "@hypothesis/frontend-testing": ^1.2.2 "@rollup/plugin-babel": ^6.0.4 "@rollup/plugin-commonjs": ^25.0.8