diff --git a/frontend-base/jest.config.js b/frontend-base/jest.config.js index c8c8e83611..9d7a74f39a 100644 --- a/frontend-base/jest.config.js +++ b/frontend-base/jest.config.js @@ -1,6 +1,7 @@ const { createConfig } = require('@openedx/frontend-base/tools'); module.exports = createConfig('test', { + testEnvironment: '/jsdom-environment-with-node-fetch.mjs', setupFilesAfterEnv: [ '/src/setupTest.js', ], @@ -13,6 +14,7 @@ module.exports = createConfig('test', { 'src/setupTest.js', 'src/i18n', 'src/__mocks__', + 'src/tests', ], moduleNameMapper: { '^@src/(.*)$': '/src/$1', diff --git a/frontend-base/jsdom-environment-with-node-fetch.mjs b/frontend-base/jsdom-environment-with-node-fetch.mjs new file mode 100644 index 0000000000..fdc6e3ef37 --- /dev/null +++ b/frontend-base/jsdom-environment-with-node-fetch.mjs @@ -0,0 +1,16 @@ +// This file can be removed if https://github.com/jsdom/jsdom/issues/1724 is resolved. +import jsdom from 'jest-environment-jsdom'; + +const JSDOMEnvironment = jsdom.default; + +export default class JSDOMEnvironmentWithNodeFetch extends JSDOMEnvironment { + constructor(...args) { + super(...args); + this.global.AbortController = AbortController; + this.global.AbortSignal = AbortSignal; + this.global.fetch = fetch; + this.global.Headers = Headers; + this.global.Request = Request; + this.global.Response = Response; + } +} diff --git a/frontend-base/src/StubPage.tsx b/frontend-base/src/StubPage.tsx new file mode 100644 index 0000000000..356ffbc21d --- /dev/null +++ b/frontend-base/src/StubPage.tsx @@ -0,0 +1,21 @@ +import { Container } from '@openedx/paragon'; +import { useParams } from 'react-router-dom'; + +interface StubPageProps { + page: string; +} + +// Stands in for a page until its port replaces it; deleted with the last port. +const StubPage = ({ page }: StubPageProps) => { + const params = useParams(); + return ( + +

{page}: not yet ported

+
    + {Object.entries(params).map(([name, value]) =>
  • {`${name}=${value}`}
  • )} +
+
+ ); +}; + +export default StubPage; diff --git a/frontend-base/src/app.test.ts b/frontend-base/src/app.test.ts index 076ef911ea..96434ecc62 100644 --- a/frontend-base/src/app.test.ts +++ b/frontend-base/src/app.test.ts @@ -1,9 +1,107 @@ +import { + getUrlByRouteRole, + mergeSiteConfig, + providesCourseBarMasqueradeRolesId, + providesCourseBarRolesId, +} from '@openedx/frontend-base'; + import { learningApp } from './index'; +import { + courseDatesRole, + courseOutlineRole, + courseProgressRole, + coursewareRole, + learningCourseRole, + learningRole, +} from './constants'; + +const [main] = learningApp.routes ?? []; +const [course, preview, ...nonCourse] = main.children ?? []; describe('learningApp', () => { - it('declares the Learning app id with no routes or slot operations yet', () => { + it('declares the Learning app id with no slot operations yet', () => { expect(learningApp.appId).toBe('org.openedx.frontend.app.learning'); - expect(learningApp.routes).toEqual([]); expect(learningApp.slots).toEqual([]); }); + + it('provides the course role for the course bar and the masquerade bar', () => { + expect(learningApp.provides).toEqual({ + [providesCourseBarRolesId]: [learningCourseRole], + [providesCourseBarMasqueradeRolesId]: [learningCourseRole], + }); + }); + + it('declares the learning role on the root route', () => { + expect(main.path).toBe('learning'); + expect(main.handle?.roles).toEqual([learningRole]); + }); + + it('declares the course role on the course and preview layout routes', () => { + expect(course.path).toBe('course/:courseId'); + expect(course.handle?.roles).toEqual([learningCourseRole]); + expect(preview.path).toBe('preview/course/:courseId'); + expect(preview.handle?.roles).toEqual([learningCourseRole]); + }); + + it('declares a destination role on four course pages and none on the rest', () => { + const declaredRolesByCoursePath = Object.fromEntries( + (course.children ?? []).map((route) => [route.index ? '(index)' : route.path, route.handle?.roles]), + ); + expect(declaredRolesByCoursePath).toEqual({ + home: [courseOutlineRole], + dates: [courseDatesRole], + progress: [courseProgressRole], + 'progress/:targetUserId': [courseProgressRole], + 'discussion/:path/*': undefined, + live: undefined, + '(index)': [coursewareRole], + ':sequenceId': [coursewareRole], + ':sequenceId/:unitId': [coursewareRole], + 'course-end': undefined, + 'access-denied': undefined, + }); + expect((preview.children ?? []).map((route) => [route.path, route.handle?.roles])).toEqual([ + [':sequenceId', [coursewareRole]], + [':sequenceId/:unitId', [coursewareRole]], + ]); + expect(nonCourse.map((route) => [route.path, route.handle?.roles])).toEqual([ + ['goal-unsubscribe/:token', undefined], + ['redirect/*', undefined], + ['preferences-unsubscribe/:userToken/:updatePatch?', undefined], + ]); + }); +}); + +describe('role resolution', () => { + beforeAll(() => { + mergeSiteConfig({ apps: [learningApp] }); + }); + + it('learning resolves to the root, not to any page', () => { + expect(getUrlByRouteRole(learningRole)).toBe('/learning'); + }); + + it('learningCourse resolves to the course, which prefix-matches every tab', () => { + expect(getUrlByRouteRole(learningCourseRole)).toBe('/learning/course/:courseId'); + }); + + it('courseOutline resolves to the outline tab', () => { + expect(getUrlByRouteRole(courseOutlineRole)).toBe('/learning/course/:courseId/home'); + }); + + it('courseDates resolves to the dates tab', () => { + expect(getUrlByRouteRole(courseDatesRole)).toBe('/learning/course/:courseId/dates'); + }); + + it("courseProgress resolves to the learner's own progress, not another user's", () => { + const url = getUrlByRouteRole(courseProgressRole); + expect(url).toBe('/learning/course/:courseId/progress'); + expect(url).not.toMatch(/:targetUserId/); + }); + + it('courseware resolves to the bare course path (an index route, hence the slash), not a sequence, unit or preview path', () => { + const url = getUrlByRouteRole(coursewareRole); + expect(url).toBe('/learning/course/:courseId/'); + expect(url).not.toMatch(/:sequenceId|:unitId|preview/); + }); }); diff --git a/frontend-base/src/app.ts b/frontend-base/src/app.ts index fec03c6e71..f6d9e98c7b 100644 --- a/frontend-base/src/app.ts +++ b/frontend-base/src/app.ts @@ -1,11 +1,13 @@ import { App } from '@openedx/frontend-base'; import { appId } from '@src/constants'; +import provides from '@src/provides'; import routes from '@src/routes'; import slots from '@src/slots'; const app: App = { appId, routes, + provides, slots, }; diff --git a/frontend-base/src/constants.ts b/frontend-base/src/constants.ts index ba8030562e..f599ffaf43 100644 --- a/frontend-base/src/constants.ts +++ b/frontend-base/src/constants.ts @@ -1,2 +1,8 @@ export const appId = 'org.openedx.frontend.app.learning'; + export const learningRole = 'org.openedx.frontend.role.learning'; +export const learningCourseRole = 'org.openedx.frontend.role.learningCourse'; +export const courseOutlineRole = 'org.openedx.frontend.role.courseOutline'; +export const courseDatesRole = 'org.openedx.frontend.role.courseDates'; +export const courseProgressRole = 'org.openedx.frontend.role.courseProgress'; +export const coursewareRole = 'org.openedx.frontend.role.courseware'; diff --git a/frontend-base/src/provides.ts b/frontend-base/src/provides.ts new file mode 100644 index 0000000000..fbf93e2e03 --- /dev/null +++ b/frontend-base/src/provides.ts @@ -0,0 +1,10 @@ +import { providesCourseBarMasqueradeRolesId, providesCourseBarRolesId } from '@openedx/frontend-base'; + +import { learningCourseRole } from './constants'; + +const provides = { + [providesCourseBarRolesId]: [learningCourseRole], + [providesCourseBarMasqueradeRolesId]: [learningCourseRole], +}; + +export default provides; diff --git a/frontend-base/src/routes.test.tsx b/frontend-base/src/routes.test.tsx new file mode 100644 index 0000000000..ebe1a77e52 --- /dev/null +++ b/frontend-base/src/routes.test.tsx @@ -0,0 +1,150 @@ +import { runPageRules } from '@openedx/frontend-base'; +import { screen } from '@testing-library/react'; + +import routes from '@src/routes'; +import { renderRoutes } from '@src/tests/render'; + +const courseId = 'course-v1:OpenedX+DemoX+DemoCourse'; + +const renderAt = (path: string) => renderRoutes(routes, `/learning${path}`); +const expectStub = async (page: string, params: Record) => { + expect(await screen.findByText(`${page}: not yet ported`)).toBeInTheDocument(); + Object.entries(params).forEach(([name, value]) => { + expect(screen.getByText(`${name}=${value}`)).toBeInTheDocument(); + }); +}; + +describe('routes', () => { + describe('outline', () => { + it('renders at course/:courseId/home', async () => { + renderAt(`/course/${courseId}/home`); + await expectStub('outline', { courseId }); + }); + }); + + describe('dates', () => { + it('renders at course/:courseId/dates', async () => { + renderAt(`/course/${courseId}/dates`); + await expectStub('dates', { courseId }); + }); + }); + + describe('progress', () => { + it('renders at course/:courseId/progress', async () => { + renderAt(`/course/${courseId}/progress`); + await expectStub('progress', { courseId }); + }); + + it('renders at course/:courseId/progress/:targetUserId with the target user', async () => { + renderAt(`/course/${courseId}/progress/42`); + await expectStub('progress', { courseId, targetUserId: '42' }); + }); + }); + + describe('discussion', () => { + it('renders at course/:courseId/discussion/:path/* with the path and the rest', async () => { + renderAt(`/course/${courseId}/discussion/topics/abc`); + await expectStub('discussion', { courseId, path: 'topics', '*': 'abc' }); + }); + }); + + describe('live', () => { + it('renders at course/:courseId/live', async () => { + renderAt(`/course/${courseId}/live`); + await expectStub('live', { courseId }); + }); + }); + + describe('courseware', () => { + it('renders at course/:courseId', async () => { + renderAt(`/course/${courseId}`); + await expectStub('courseware', { courseId }); + }); + + it('renders at course/:courseId/:sequenceId with the sequence', async () => { + renderAt(`/course/${courseId}/seq`); + await expectStub('courseware', { courseId, sequenceId: 'seq' }); + }); + + it('renders at course/:courseId/:sequenceId/:unitId with both ids', async () => { + renderAt(`/course/${courseId}/seq/unit`); + await expectStub('courseware', { courseId, sequenceId: 'seq', unitId: 'unit' }); + }); + + it('renders at preview/course/:courseId/:sequenceId with the sequence', async () => { + renderAt(`/preview/course/${courseId}/seq`); + await expectStub('courseware', { courseId, sequenceId: 'seq' }); + }); + + it('renders at preview/course/:courseId/:sequenceId/:unitId with both ids', async () => { + renderAt(`/preview/course/${courseId}/seq/unit`); + await expectStub('courseware', { courseId, sequenceId: 'seq', unitId: 'unit' }); + }); + }); + + describe('courseExit', () => { + it('renders at course/:courseId/course-end', async () => { + renderAt(`/course/${courseId}/course-end`); + await expectStub('courseExit', { courseId }); + }); + }); + + describe('accessDenied', () => { + it('renders at course/:courseId/access-denied', async () => { + renderAt(`/course/${courseId}/access-denied`); + await expectStub('accessDenied', { courseId }); + }); + }); + + describe('goalUnsubscribe', () => { + it('renders at goal-unsubscribe/:token with the token', async () => { + renderAt('/goal-unsubscribe/tok'); + await expectStub('goalUnsubscribe', { token: 'tok' }); + }); + }); + + describe('redirect', () => { + it('renders at redirect/* with the rest', async () => { + renderAt('/redirect/dashboard'); + await expectStub('redirect', { '*': 'dashboard' }); + }); + }); + + describe('preferencesUnsubscribe', () => { + it('renders at preferences-unsubscribe/:userToken with the token', async () => { + renderAt('/preferences-unsubscribe/tok'); + await expectStub('preferencesUnsubscribe', { userToken: 'tok' }); + }); + + it('renders at preferences-unsubscribe/:userToken/:updatePatch with the patch', async () => { + renderAt('/preferences-unsubscribe/tok/patch'); + await expectStub('preferencesUnsubscribe', { userToken: 'tok', updatePatch: 'patch' }); + }); + }); + + describe('page rules', () => { + it.each([ + `/course/${courseId}/home`, + `/course/${courseId}/dates`, + `/course/${courseId}/progress`, + `/course/${courseId}/progress/42`, + `/course/${courseId}/discussion/topics/abc`, + `/course/${courseId}/live`, + `/course/${courseId}`, + `/course/${courseId}/seq`, + `/course/${courseId}/seq/unit`, + `/preview/course/${courseId}/seq`, + `/preview/course/${courseId}/seq/unit`, + `/course/${courseId}/course-end`, + `/course/${courseId}/access-denied`, + '/goal-unsubscribe/tok', + '/redirect/dashboard', + '/preferences-unsubscribe/tok', + '/preferences-unsubscribe/tok/patch', + ])('reports no page-rule diagnostics at %s', async (path) => { + renderAt(path); + await screen.findByText(/: not yet ported$/); + expect(runPageRules(document)).toEqual([]); + }); + }); +}); diff --git a/frontend-base/src/routes.tsx b/frontend-base/src/routes.tsx index 422793ecbf..02af191726 100644 --- a/frontend-base/src/routes.tsx +++ b/frontend-base/src/routes.tsx @@ -1,5 +1,178 @@ import { RoleRouteObject } from '@openedx/frontend-base'; +import { Outlet } from 'react-router-dom'; -const routes: RoleRouteObject[] = []; +import { + courseDatesRole, + courseOutlineRole, + courseProgressRole, + coursewareRole, + learningCourseRole, + learningRole, +} from './constants'; +import StubPage from './StubPage'; + +const outlineRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.outline', + path: 'home', + handle: { roles: [courseOutlineRole] }, + element: , + }, +]; + +const datesRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.dates', + path: 'dates', + handle: { roles: [courseDatesRole] }, + element: , + }, +]; + +const progressRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.progress', + path: 'progress', + handle: { roles: [courseProgressRole] }, + element: , + }, + { + path: 'progress/:targetUserId', + handle: { roles: [courseProgressRole] }, + element: , + }, +]; + +const discussionRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.discussion', + path: 'discussion/:path/*', + element: , + }, +]; + +const liveRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.live', + path: 'live', + element: , + }, +]; + +const coursewareRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.courseware', + index: true, + handle: { roles: [coursewareRole] }, + element: , + }, + { + path: ':sequenceId', + handle: { roles: [coursewareRole] }, + element: , + }, + { + path: ':sequenceId/:unitId', + handle: { roles: [coursewareRole] }, + element: , + }, +]; + +const previewCoursewareRoutes: RoleRouteObject[] = [ + { + path: ':sequenceId', + handle: { roles: [coursewareRole] }, + element: , + }, + { + path: ':sequenceId/:unitId', + handle: { roles: [coursewareRole] }, + element: , + }, +]; + +const courseExitRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.courseExit', + path: 'course-end', + element: , + }, +]; + +const accessDeniedRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.accessDenied', + path: 'access-denied', + element: , + }, +]; + +const courseRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.course', + path: 'course/:courseId', + handle: { roles: [learningCourseRole] }, + element: , + children: [ + ...outlineRoutes, + ...datesRoutes, + ...progressRoutes, + ...discussionRoutes, + ...liveRoutes, + ...coursewareRoutes, + ...courseExitRoutes, + ...accessDeniedRoutes, + ], + }, + { + id: 'org.openedx.frontend.route.learning.preview', + path: 'preview/course/:courseId', + handle: { roles: [learningCourseRole] }, + element: , + children: previewCoursewareRoutes, + }, +]; + +const goalUnsubscribeRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.goalUnsubscribe', + path: 'goal-unsubscribe/:token', + element: , + }, +]; + +const redirectRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.redirect', + path: 'redirect/*', + element: , + }, +]; + +const preferencesUnsubscribeRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.preferencesUnsubscribe', + path: 'preferences-unsubscribe/:userToken/:updatePatch?', + element: , + }, +]; + +const routes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.main', + path: 'learning', + handle: { roles: [learningRole] }, + async lazy() { + const module = await import(/* webpackChunkName: "learning-main" */ './Main'); + return { Component: module.default }; + }, + children: [ + ...courseRoutes, + ...goalUnsubscribeRoutes, + ...redirectRoutes, + ...preferencesUnsubscribeRoutes, + ], + }, +]; export default routes; diff --git a/frontend-base/src/tests/render.tsx b/frontend-base/src/tests/render.tsx new file mode 100644 index 0000000000..3b410e240a --- /dev/null +++ b/frontend-base/src/tests/render.tsx @@ -0,0 +1,9 @@ +import { IntlProvider, RoleRouteObject } from '@openedx/frontend-base'; +import { render } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; + +export const renderRoutes = (routes: RoleRouteObject[], path: string) => render( + + + , +); diff --git a/frontend-base/tsconfig.build.json b/frontend-base/tsconfig.build.json index a48f6e31e5..e87dac6b92 100644 --- a/frontend-base/tsconfig.build.json +++ b/frontend-base/tsconfig.build.json @@ -14,6 +14,7 @@ "src/**/*.spec.ts", "src/**/*.spec.tsx", "src/__mocks__/**/*", + "src/**/tests/**/*", "src/setupTest.js" ] } diff --git a/frontend-base/tsconfig.json b/frontend-base/tsconfig.json index 0ee4ab357e..255fbc7780 100644 --- a/frontend-base/tsconfig.json +++ b/frontend-base/tsconfig.json @@ -13,6 +13,7 @@ "babel.config.js", "eslint.config.js", "jest.config.js", + "jsdom-environment-with-node-fetch.mjs", "site.config.*.tsx", ], }