Tutorial – Web interface

Goal for this step 🏁: Add web interface to the frontend to manage the content, with four screens for the admin web interface (content list, content editor, schema editor and changelog list), and two for the published web interface (content list and content display).

In order to add the web interface to manage the content, we need to add some dependencies (Leaflet is the library used for maps):

  • npm install @dossierhq/design @dossierhq/leaflet @dossierhq/react-components leaflet

Add the CSS needed from to src/main.tsx:

import '@dossierhq/design/main.css';
import '@dossierhq/leaflet/icons.css';
import '@dossierhq/leaflet/main.css';
import '@dossierhq/react-components/main.css';
import 'leaflet/dist/leaflet.css';

Next, define which auth keys to use, in src/AuthConfig.ts:

import type { DisplayAuthKey } from '@dossierhq/react-components';

export const DISPLAY_AUTH_KEYS: DisplayAuthKey[] = [
{ authKey: 'none', displayName: 'None' },
{ authKey: 'subject', displayName: 'User private' },
];

Next, configure the admin web interface, in src/AppAdminProvider.tsx. Later on we will do some more configuration here, but for now we go with mostly defaults.

import {
DossierProvider,
type DossierContextAdapter,
type FieldEditorProps,
type RichTextComponentEditorProps,
} from '@dossierhq/react-components';
import { useMemo } from 'react';
import { DISPLAY_AUTH_KEYS } from './AuthConfig.js';
import { useDossierClient } from './ClientUtils.js';

interface Props {
children: React.ReactNode;
}

export function AppAdminProvider({ children }: Props) {
const client = useDossierClient();
const args = useMemo(
() => ({
adapter: new AdminAdapter(),
authKeys: DISPLAY_AUTH_KEYS,
}),
[]
);

if (!client) return null;

return (
<DossierProvider {...args} client={client}>
{children}
</DossierProvider>
);
}

class AdminAdapter implements DossierContextAdapter {
renderFieldEditor(props: FieldEditorProps): JSX.Element | null {
return null;
}

renderRichTextComponentEditor(props: RichTextComponentEditorProps): JSX.Element | null {
return null;
}
}

Similarly for published web interface, in src/AppPublishedProvider.tsx:

import {
PublishedDossierProvider,
type FieldDisplayProps,
type PublishedDossierContextAdapter,
type RichTextComponentDisplayProps,
} from '@dossierhq/react-components';
import { useMemo } from 'react';
import { DISPLAY_AUTH_KEYS } from './AuthConfig.js';
import { usePublishedClient } from './ClientUtils.js';

interface Props {
children: React.ReactNode;
}

export function AppPublishedProvider({ children }: Props) {
const publishedClient = usePublishedClient();
const args = useMemo(
() => ({
adapter: new PublishedAdapter(),
authKeys: DISPLAY_AUTH_KEYS,
}),
[]
);

if (!publishedClient) return null;

return (
<PublishedDossierProvider {...args} publishedClient={publishedClient}>
{children}
</PublishedDossierProvider>
);
}

class PublishedAdapter implements PublishedDossierContextAdapter {
renderPublishedFieldDisplay(props: FieldDisplayProps): JSX.Element | null {
return null;
}

renderPublishedRichTextComponentDisplay(
props: RichTextComponentDisplayProps
): JSX.Element | null {
return null;
}
}

We also want to tweak the creation of the Dossier client and use a caching middleware in src/ClientUtils.ts:

import { useCachingDossierMiddleware } from '@dossierhq/react-components';

export function useDossierClient(): AppDossierClient | null {
const cachingAdminMiddleware = useCachingDossierMiddleware();
return useMemo(
() =>
createBaseDossierClient<ClientContext, AppDossierClient>({
context: { logger },
pipeline: [cachingAdminMiddleware, createAdminBackendMiddleware()],
}),
[cachingAdminMiddleware]
);
}

As for the routes we want to support four screens for the admin web interface (content list, content editor, schema editor and changelog list), and two for the published web interface (content list and content display). The library provide most of the layout and functionality, but we get to adapt it to the router, react to screen navigation and add an app specific navbar as a header.

In src/ContentListRoute.tsx:

import type { Entity } from '@dossierhq/core';
import { AdminEntityListScreen } from '@dossierhq/react-components';
import { useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Navbar } from './Navbar.js';

export function ContentListRoute() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();

const handleCreateEntity = useCallback(
(type: string) => navigate(`/edit-content?new=${type}:${crypto.randomUUID()}`),
[navigate]
);
const handleEntityOpen = useCallback(
(entity: Entity) => navigate(`/edit-content?id=${entity.id}`),
[navigate]
);

return (
<AdminEntityListScreen
header={<Navbar current="content" />}
urlSearchParams={searchParams}
onUrlSearchParamsChange={setSearchParams}
onCreateEntity={handleCreateEntity}
onOpenEntity={handleEntityOpen}
/>
);
}

For the editor routes we want to be able to prevent navigation if there are unsaved changes. So let's first create a context in src/ScreenChangesContext.ts:

import { createContext } from 'react';

export const ScreenChangesContext = createContext<string | null>(null);

In src/ContentEditorRoute.tsx:

import { EntityEditorScreen } from '@dossierhq/react-components';
import { useCallback, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Navbar } from './Navbar.js';
import { ScreenChangesContext } from './ScreenChangesContext.js';

export function ContentEditorRoute() {
const [searchParams, setSearchParams] = useSearchParams();
const [hasChanges, setHasChanges] = useState(false);

const handleSearchParamsChange = useCallback(
(searchParams: URLSearchParams) => setSearchParams(searchParams, { replace: true }),
[setSearchParams]
);

return (
<ScreenChangesContext.Provider
value={hasChanges ? 'Changes will be lost, are you sure you want to leave the page?' : null}
>
<EntityEditorScreen
header={<Navbar current="content" />}
urlSearchParams={searchParams}
onUrlSearchParamsChange={handleSearchParamsChange}
onEditorHasChangesChange={setHasChanges}
/>
</ScreenChangesContext.Provider>
);
}

In src/SchemaEditorRoute.tsx:

import { SchemaEditorScreen } from '@dossierhq/react-components';
import { useState } from 'react';
import { Navbar } from './Navbar.js';
import { ScreenChangesContext } from './ScreenChangesContext.js';

export function SchemaEditorRoute() {
const [hasChanges, setHasChanges] = useState(false);

return (
<ScreenChangesContext.Provider
value={
hasChanges
? 'Changes to schema will be lost, are you sure you want to leave the page?'
: null
}
>
<SchemaEditorScreen
header={<Navbar current="schema" />}
onEditorHasChangesChange={setHasChanges}
/>
</ScreenChangesContext.Provider>
);
}

In src/ChangelogListRoute.tsx:

import { ChangelogScreen } from '@dossierhq/react-components';
import { useSearchParams } from 'react-router-dom';
import { Navbar } from './Navbar.js';

export function ChangelogListRoute() {
const [searchParams, setSearchParams] = useSearchParams();

return (
<ChangelogScreen
header={<Navbar current="changelog" />}
urlSearchParams={searchParams}
onUrlSearchParamsChange={setSearchParams}
/>
);
}

In src/PublishedContentListRoute.tsx:

import type { PublishedEntity } from '@dossierhq/core';
import { PublishedEntityListScreen } from '@dossierhq/react-components';
import { useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Navbar } from './Navbar.js';

export function PublishedContentListRoute() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();

const handleEntityOpen = useCallback(
(entity: PublishedEntity) => navigate(`/published-content/display?id=${entity.id}`),
[navigate]
);

return (
<PublishedEntityListScreen
header={<Navbar current="published-content" />}
urlSearchParams={searchParams}
onUrlSearchParamsChange={setSearchParams}
onOpenEntity={handleEntityOpen}
/>
);
}

In src/PublishedContentDisplayRoute.tsx:

import { PublishedEntityDisplayScreen } from '@dossierhq/react-components';
import { useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Navbar } from './Navbar.js';

export function PublishedContentDisplayRoute() {
const [searchParams, setSearchParams] = useSearchParams();

const handleSearchParamsChange = useCallback(
(searchParams: URLSearchParams) => setSearchParams(searchParams, { replace: true }),
[setSearchParams]
);

return (
<PublishedEntityDisplayScreen
header={<Navbar current="published-content" />}
urlSearchParams={searchParams}
onUrlSearchParamsChange={handleSearchParamsChange}
/>
);
}

For the nav bar we need a utility hook to be able to navigate away from the page when there are editor change, src/useBeforeUnload.ts:

import { useWindowEventListener } from '@dossierhq/design';
import { useCallback } from 'react';

export function useBeforeUnload(message: string | null) {
const handleBeforeUnload = useCallback(
(event: BeforeUnloadEvent) => {
if (message) {
event.returnValue = message;
}
},
[message]
);

useWindowEventListener('beforeunload', handleBeforeUnload);
}

We base our navbar on the design library, src/Navbar.tsx:

import { Navbar as DesignNavbar } from '@dossierhq/design';
import { useCallback, useContext, useState, type MouseEvent, type MouseEventHandler } from 'react';
import { Link } from 'react-router-dom';
import { ScreenChangesContext } from './ScreenChangesContext.js';
import { useBeforeUnload } from './useBeforeUnload.js';

interface Props {
current: 'home' | 'content' | 'published-content' | 'schema' | 'changelog';
}

export function Navbar({ current }: Props) {
const [active, setActive] = useState(false);
const screenChangesMessage = useContext(ScreenChangesContext);

const handleLinkClick = useCallback(
(event: MouseEvent) => {
if (screenChangesMessage && !window.confirm(screenChangesMessage)) {
event.preventDefault();
}
},
[screenChangesMessage]
);

useBeforeUnload(screenChangesMessage);

return (
<DesignNavbar>
<DesignNavbar.Brand>
<DesignNavbar.Item active={current === 'home'}>
{NavItemRender('Home', '/', handleLinkClick)}
</DesignNavbar.Item>
<DesignNavbar.Burger active={active} onClick={() => setActive(!active)} />
</DesignNavbar.Brand>
<DesignNavbar.Menu active={active}>
<DesignNavbar.Start>
<DesignNavbar.Item active={current === 'content'}>
{NavItemRender('Content', '/content', handleLinkClick)}
</DesignNavbar.Item>
<DesignNavbar.Item active={current === 'published-content'}>
{NavItemRender('Published content', '/published-content', handleLinkClick)}
</DesignNavbar.Item>
<DesignNavbar.Item active={current === 'schema'}>
{NavItemRender('Schema', '/schema', handleLinkClick)}
</DesignNavbar.Item>
<DesignNavbar.Item active={current === 'changelog'}>
{NavItemRender('Changelog', '/changelog', handleLinkClick)}
</DesignNavbar.Item>
</DesignNavbar.Start>
</DesignNavbar.Menu>
</DesignNavbar>
);
}

function NavItemRender(text: string, to: string, onClick: MouseEventHandler<HTMLAnchorElement>) {
const renderer = ({ className }: { className: string }) => {
return (
<Link to={to} className={className} onClick={onClick}>
{text}
</Link>
);
};
return renderer;
}

To wrap it all together we update src/App.tsx to include a NotificationContainer (so we can display notifications), AppAdminProvider and AppPublishedProvider that we created earlier and all the routes.

import { NotificationContainer } from '@dossierhq/design';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AppAdminProvider } from './AppAdminProvider.js';
import { AppPublishedProvider } from './AppPublishedProvider.js';
import { ChangelogListRoute } from './ChangelogListRoute.js';
import { ContentEditorRoute } from './ContentEditorRoute.js';
import { ContentListRoute } from './ContentListRoute.js';
import { IndexRoute } from './IndexRoute.js';
import { PublishedContentDisplayRoute } from './PublishedContentDisplayRoute.js';
import { PublishedContentListRoute } from './PublishedContentListRoute.js';
import { SchemaEditorRoute } from './SchemaEditorRoute.js';

const router = createBrowserRouter([
{ path: '/', element: <IndexRoute /> },
{ path: '/content', element: <ContentListRoute /> },
{ path: '/edit-content', element: <ContentEditorRoute /> },
{ path: '/published-content', element: <PublishedContentListRoute /> },
{ path: '/published-content/display', element: <PublishedContentDisplayRoute /> },
{ path: '/schema', element: <SchemaEditorRoute /> },
{ path: '/changelog', element: <ChangelogListRoute /> },
]);

export default function App() {
return (
<NotificationContainer>
<AppAdminProvider>
<AppPublishedProvider>
<RouterProvider router={router} />
</AppPublishedProvider>
</AppAdminProvider>
</NotificationContainer>
);
}

Now we can access all the screens. The content list screen looks like this:

Screenshot of the content list screen

Opening the content editor screen for an entity looks like this:

Screenshot of the content editor screen

The published content list screen looks like this:

Screenshot of published content list screen

Opening the content display screen for an entity looks like this:

Screenshot of content display screen

The schema editor screen looks like this:

Screenshot of schema editor screen

And finally, the change log screen looks like this:

 

Screenshot of changelog screen