Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/how_tos/migrate-frontend-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@ Other configuration is now optional, and many values have been given sensible de
URL Config changes
------------------

Note that the .env files and env.config.js files also include a number of URLs for various micro-frontends and services. These URLs should now be expressed as part of the `apps` config as route roles, and used in code via `getUrlForRouteRole()`. Or as externalRoutes.
Note that the .env files and env.config.js files also include a number of URLs for various micro-frontends and services. These URLs should now be expressed as part of the `apps` config as route roles, or as `externalRoutes`, and resolved in code via `resolveRouteByRole()`. It substitutes route params, drops a trailing splat, and reports whether the result is a path in this site or an external URL, so a link can stay in the client when it is. `getLinkProps()` turns any URL into the props for a component that accepts `as`, a react-router `Link` for a path in this site and a plain anchor otherwise.

```js
// Creating a route role with for 'example' in an App
Expand All @@ -754,8 +754,14 @@ const app: App = {
}],
};

// Using the role in code to link to the page
const examplePageUrl = getUrlForRouteRole('example');
// Using the role in code to link to the page: { url: '/example', isInternal: true }, or null
// when no app or external route provides it
const examplePage = resolveRouteByRole('example');

// Rendering the link, so that a route in this site navigates without a page load
{examplePage && (
<Button {...getLinkProps(examplePage.url)}>Example</Button>
)}
```

App-specific config values
Expand Down
6 changes: 5 additions & 1 deletion runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,13 @@ export {

export {
authenticatedLoader,
getLinkProps,
getUrlByRouteRole,
isRoleRouteObject
isInternalUrl,
isRoleRouteObject,
resolveRouteByRole
} from './routing';
export type { LinkProps, ResolvedRoute } from './routing/utils';

export {
clearAllSubscriptions,
Expand Down
82 changes: 81 additions & 1 deletion runtime/routing/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { getUrlByRouteRole } from './utils';
import { Link } from 'react-router-dom';

import { getLinkProps, getUrlByRouteRole, isInternalUrl, resolveRouteByRole } from './utils';
import { getSiteConfig } from '../config';

jest.mock('../config');
Expand Down Expand Up @@ -60,3 +62,81 @@ describe('getUrlByRouteRole', () => {
expect(getUrlByRouteRole('nonexistent')).toBeNull();
});
});

describe('isInternalUrl', () => {
it('accepts a path in this site', () => {
expect(isInternalUrl('/account')).toBe(true);
expect(isInternalUrl('/')).toBe(true);
});

it('rejects absolute and protocol-relative URLs', () => {
expect(isInternalUrl('https://example.com/account')).toBe(false);
expect(isInternalUrl('//example.com/account')).toBe(false);
expect(isInternalUrl('mailto:help@example.com')).toBe(false);
});
});

describe('resolveRouteByRole', () => {
beforeEach(() => {
mockGetSiteConfig.mockReturnValue({
apps: [{
appId: 'test-app',
routes: [
{ path: '/account', handle: { roles: ['account'] } },
{ path: '/gradebook/:courseId', handle: { roles: ['gradebook'] } },
{ path: '/learning/:courseId/:unitId?', handle: { roles: ['learning'] } },
{ path: '/learner-dashboard/*', handle: { roles: ['dashboard'] } },
{ path: '/*', handle: { roles: ['catch-all'] } },
],
}],
externalRoutes: [{
role: 'profile',
url: 'https://apps.example.com/profile/',
}],
} as any);
});

it('resolves an app route to a path in this site', () => {
expect(resolveRouteByRole('account')).toEqual({ url: '/account', isInternal: true });
});

it('substitutes route params, optional ones included', () => {
expect(resolveRouteByRole('gradebook', { courseId: 'course-v1:edX+DemoX+Demo_Course' }))
.toEqual({ url: '/gradebook/course-v1:edX+DemoX+Demo_Course', isInternal: true });
expect(resolveRouteByRole('learning', { courseId: 'c1', unitId: 'u1' }))
.toEqual({ url: '/learning/c1/u1', isInternal: true });
});

it('drops an optional param that is not given', () => {
expect(resolveRouteByRole('learning', { courseId: 'c1' })).toEqual({ url: '/learning/c1', isInternal: true });
});

it('throws when a required param is not given', () => {
expect(() => resolveRouteByRole('gradebook')).toThrow('Missing ":courseId" param');
});

it('drops a trailing splat', () => {
expect(resolveRouteByRole('dashboard')).toEqual({ url: '/learner-dashboard', isInternal: true });
expect(resolveRouteByRole('catch-all')).toEqual({ url: '/', isInternal: true });
});

it('resolves an external route to its URL as configured', () => {
expect(resolveRouteByRole('profile', { courseId: 'c1' }))
.toEqual({ url: 'https://apps.example.com/profile/', isInternal: false });
});

it('returns null when nothing provides the role', () => {
expect(resolveRouteByRole('nonexistent')).toBeNull();
});
});

describe('getLinkProps', () => {
it('links a path in this site through react-router', () => {
expect(getLinkProps('/account')).toEqual({ as: Link, to: '/account' });
});

it('links anything else through a plain anchor', () => {
expect(getLinkProps('https://example.com/account')).toEqual({ href: 'https://example.com/account' });
expect(getLinkProps('//example.com/account')).toEqual({ href: '//example.com/account' });
});
});
75 changes: 74 additions & 1 deletion runtime/routing/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { RouteObject } from 'react-router';
import { ElementType } from 'react';
import { generatePath, RouteObject } from 'react-router';
import { Link } from 'react-router-dom';

import { RoleRouteObject } from '../../types';
import { getSiteConfig } from '../config';
Expand Down Expand Up @@ -50,3 +52,74 @@ export function getUrlByRouteRole(role: string) {
export function isRoleRouteObject(match: RouteObject): match is RoleRouteObject {
return match.handle !== undefined && 'roles' in match.handle;
}

/**
* Whether `url` is a path within this site, one react-router can navigate to, rather than an
* absolute or protocol-relative URL.
*
* @param {string} url
* @returns {boolean}
*/
export function isInternalUrl(url: string): boolean {
return url.startsWith('/') && !url.startsWith('//');
}

export interface ResolvedRoute {
/** The path or URL to link to. */
url: string;
/** Whether `url` is a path in this site, so a react-router `Link` can navigate to it. */
isInternal: boolean;
}

/**
* Resolves the route the site provides for `role`, if any, ready to link to: the path of an
* installed app's route, or the URL of an external route, as `getUrlByRouteRole` finds it. An app
* route's path has its params filled from `params` and a trailing splat dropped, as react-router's
* `generatePath` does, so a missing required param throws; an external URL is returned as
* configured. `isInternal` tells the two apart.
*
* ```
* const gradebook = resolveRouteByRole('org.openedx.frontend.role.gradebook', { courseId });
* if (gradebook?.isInternal) {
* return <Button as={Link} to={gradebook.url}>View gradebook</Button>;
* }
* return <Button as="a" href={gradebook?.url ?? legacyGradebookUrl}>View gradebook</Button>;
* ```
*
* @param {string} role
* @param {Object.<string, string>} [params] Values for the route's params, by name.
* @returns {ResolvedRoute|null} `null` when no app or external route provides the role.
*/
export function resolveRouteByRole(role: string, params: Record<string, string> = {}): ResolvedRoute | null {
const path = getUrlByRouteRole(role);
if (path === null) {
return null;
}
const isInternal = isInternalUrl(path);
return { url: isInternal ? generatePath(path, params) : path, isInternal };
}

export interface LinkProps {
as?: ElementType;
to?: string;
href?: string;
}

/**
* Props for linking to `url` from a component that accepts `as`, such as Paragon's `Button`,
* `Hyperlink`, `NavLink` or `Dropdown.Item`: a react-router `Link` for a path in this site, so the
* navigation stays in the client, and a plain anchor `href` for anything else.
*
* ```
* <Button {...getLinkProps(url)}>Go</Button>
* ```
*
* @param {string} url
* @returns {LinkProps}
*/
export function getLinkProps(url: string): LinkProps {
if (isInternalUrl(url)) {
return { as: Link, to: url };
}
return { href: url };
}
61 changes: 47 additions & 14 deletions shell/Logo.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import { ReactElement } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { getSiteConfig, mergeSiteConfig, setSiteConfig } from '../runtime/config';
import { SiteConfig } from '../types';
import { homeRole } from './constants';
import Logo from './Logo';

function LocationDisplay() {
const { pathname } = useLocation();
return <div data-testid="location">{pathname}</div>;
}

function renderWithRouter(ui: ReactElement, initialEntry = '/elsewhere') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
{ui}
<LocationDisplay />
</MemoryRouter>
);
}

describe('Logo component', () => {
let originalConfig: SiteConfig;

beforeEach(() => {
// Shallow clone is sufficient here since we only modify headerLogoImageUrl, a top-level field.
// Shallow clone is sufficient here since we only modify top-level fields.
originalConfig = { ...getSiteConfig() };
});

Expand All @@ -17,39 +34,55 @@ describe('Logo component', () => {
});

it('renders the image with default URL and links to / when no props are provided', async () => {
const { getByRole } = render(<Logo />);
const image = getByRole('img');
renderWithRouter(<Logo />);
const image = screen.getByRole('img');
expect(image).toHaveAttribute('src', 'https://edx-cdn.org/v3/default/logo.svg');
const link = getByRole('link');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/');
});

it('renders the image with provided imageUrl and links to / by default', async () => {
const testUrl = 'https://example.com/test-logo.svg';
const { getByRole } = render(<Logo imageUrl={testUrl} />);
const image = getByRole('img');
renderWithRouter(<Logo imageUrl={testUrl} />);
const image = screen.getByRole('img');
expect(image).toHaveAttribute('src', testUrl);
const link = getByRole('link');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/');
});

it('renders the image with headerLogoImageUrl when set in site config', async () => {
const configLogoUrl = 'https://example.com/config-logo.svg';
mergeSiteConfig({ headerLogoImageUrl: configLogoUrl });
const { getByRole } = render(<Logo />);
const image = getByRole('img');
renderWithRouter(<Logo />);
const image = screen.getByRole('img');
expect(image).toHaveAttribute('src', configLogoUrl);
const link = getByRole('link');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/');
});

it('renders the image wrapped in a Hyperlink when destinationUrl is provided', async () => {
const testDestinationUrl = 'https://example.com';
const { getByRole } = render(<Logo destinationUrl={testDestinationUrl} />);
const link = getByRole('link');
renderWithRouter(<Logo destinationUrl={testDestinationUrl} />);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', testDestinationUrl);
const image = getByRole('img');
const image = screen.getByRole('img');
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute('src', 'https://edx-cdn.org/v3/default/logo.svg');
});

it('navigates to the home route in the client when an app provides one', async () => {
mergeSiteConfig({
apps: [{
appId: 'org.openedx.frontend.app.logoTest',
routes: [{ path: '/learner-dashboard/*', handle: { roles: [homeRole] } }],
}],
});
renderWithRouter(<Logo />);

const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/learner-dashboard');

fireEvent.click(link);
expect(screen.getByTestId('location')).toHaveTextContent('/learner-dashboard');
});
});
7 changes: 4 additions & 3 deletions shell/Logo.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { IntlProvider } from 'react-intl';
import { Hyperlink, Image } from '@openedx/paragon';
import { getSiteConfig } from '../runtime/config';
import { getUrlByRouteRole } from '../runtime/routing';
import { getLinkProps, resolveRouteByRole } from '../runtime/routing';
import { homeRole } from './constants';

interface LogoProps {
Expand All @@ -11,7 +11,7 @@ interface LogoProps {

export default function Logo({
imageUrl = getSiteConfig().headerLogoImageUrl ?? 'https://edx-cdn.org/v3/default/logo.svg',
destinationUrl = getUrlByRouteRole(homeRole) || '/'
destinationUrl = resolveRouteByRole(homeRole)?.url ?? '/'
}: LogoProps) {
const image = (
<Image src={imageUrl} style={{ maxHeight: '2rem' }} />
Expand All @@ -21,9 +21,10 @@ export default function Logo({
return image;
}

// A path in this site is a react-router Link, so the navigation stays in the client.
return (
<IntlProvider locale="en">
<Hyperlink destination={destinationUrl} className="p-0">
<Hyperlink {...getLinkProps(destinationUrl)} className="p-0">
{image}
</Hyperlink>
</IntlProvider>
Expand Down
3 changes: 1 addition & 2 deletions shell/header/anonymous-menu/LoginButton.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Button } from '@openedx/paragon';
import { getUrlByRouteRole, useSiteConfig, useIntl } from '../../../runtime';
import { getLinkProps, getUrlByRouteRole, useSiteConfig, useIntl } from '../../../runtime';
import { loginRole } from '../../constants';
import messages from '../../Shell.messages';
import { getLinkProps } from './utils';

export default function LoginButton({ ...props }) {
const config = useSiteConfig();
Expand Down
3 changes: 1 addition & 2 deletions shell/header/anonymous-menu/RegisterButton.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Button } from '@openedx/paragon';

import { getUrlByRouteRole, useSiteConfig, useIntl } from '../../../runtime';
import { getLinkProps, getUrlByRouteRole, useSiteConfig, useIntl } from '../../../runtime';
import { registerRole } from '../../constants';
import messages from '../../Shell.messages';
import { getLinkProps } from './utils';

export default function RegisterButton({ ...props }) {
const config = useSiteConfig();
Expand Down
19 changes: 0 additions & 19 deletions shell/header/anonymous-menu/utils.ts

This file was deleted.

Loading
Loading