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
69 changes: 68 additions & 1 deletion .storybook/decorators.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import {Button} from '@maykin-ui/admin-ui';
import type {Decorator} from '@storybook/react-vite';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import {Form, Formik} from 'formik';
import {RouterProvider, createMemoryRouter} from 'react-router';
import {fn} from 'storybook/test';

import {BASE_URL} from '@/api-mocks';
import {BASE_URL, mswWorker} from '@/api-mocks';
import FormLayout from '@/components/layout/FormLayout';
import AdminSettingsProvider from '@/context/AdminSettingsProvider';
import RouterErrorBoundary from '@/errors/RouterErrorBoundary';
import {sessionExpiresAt} from '@/guard/session/session-expiry';
import {formLoader} from '@/queryClient';

export const withAdminSettingsProvider: Decorator = (Story, {parameters}) => (
<AdminSettingsProvider
Expand Down Expand Up @@ -76,3 +81,65 @@ export const withSessionExpiry: Decorator = (Story, {parameters}) => {

return <Story />;
};

/**
* Decorator for setting up a form detail page with a router and query client. This makes
* it possible to test form detail pages and submission actions.
*
* This simulates a real form detail page, using the formLoader to fetch form data.
*
* For isolated Formik testing, like testing a singular form field, use the `withFormik`
* decorator.
*
* To capture form submission actions, use the `formDetailPages.onMutate` parameter.
*/
export const withFormLayout: Decorator = (Story, {parameters}) => {
const storyHandlers = parameters?.msw?.handlers ?? [];
const onMutate = parameters?.formDetailPages?.onMutate ?? fn();

// Register story handlers BEFORE router is created
if (storyHandlers.length > 0) {
mswWorker.use(...storyHandlers);
}

const storybookQueryClient = new QueryClient({
defaultOptions: {
mutations: {
// Capture mutate calls so we can see submission actions
onMutate: variables => onMutate(variables),
},
queries: {retry: false},
},
});

const router = createMemoryRouter(
[
{
path: '/admin-ui',
ErrorBoundary: RouterErrorBoundary,
children: [
{
path: 'forms/:formId',
loader: ({params}) => formLoader(storybookQueryClient, params.formId),
Component: FormLayout,
children: [
{
index: true,
Component: Story,
},
],
},
],
},
],
{
initialEntries: [`/admin-ui/forms/e450890a-4166-410e-8d64-0a54ad30ba01`],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this stay hardcoded like this? I can imagine you might want to supply values for this in tests?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mhh, yeah i think it should remain like this, for now.

Currently this decorator loads the storybook story as the index child of forms/:uuid. By setting the initialEntry to this route, we force this page to be the initial page (i.e. when you use it in storybook, you immediately see your Story)

I've tried something similar to your suggestion, where the initialEntries and routes can be set by the storybook tests themselves. It works, but makes this more complicated. Also, im not sure if we need this functionality at this moment.

This decorator is just to supply access to a QueryClient, so we can test somewhat real form detail page behaviour. I don't think that we, at this moment, require actual working routing and navigation between pages. When this does become a required feature, we can revisit this decorator and expand its capabilities.

}
);

return (
<QueryClientProvider client={storybookQueryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
};
12 changes: 12 additions & 0 deletions i18n/compiled/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
"value": "Categories"
}
],
"2wkqV6": [
{
"type": 0,
"value": "Preview"
}
],
"6UFbVC": [
{
"type": 0,
Expand Down Expand Up @@ -91,6 +97,12 @@
"value": "Forms"
}
],
"RT8KNi": [
{
"type": 0,
"value": "Save"
}
],
"ZiDANM": [
{
"type": 0,
Expand Down
12 changes: 12 additions & 0 deletions i18n/compiled/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
"value": "Categorieën"
}
],
"2wkqV6": [
{
"type": 0,
"value": "Preview"
}
],
"6UFbVC": [
{
"type": 0,
Expand Down Expand Up @@ -91,6 +97,12 @@
"value": "Formulieren"
}
],
"RT8KNi": [
{
"type": 0,
"value": "Opslaan"
}
],
"ZiDANM": [
{
"type": 0,
Expand Down
10 changes: 10 additions & 0 deletions i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
"description": "Route breadcrumb label for form categories",
"originalDefault": "categories"
},
"2wkqV6": {
"defaultMessage": "Preview",
"description": "Preview button text",
"originalDefault": "Preview"
},
"6UFbVC": {
"defaultMessage": "There was an authentication problem.",
"description": "'Not authenticated' error message",
Expand Down Expand Up @@ -49,6 +54,11 @@
"description": "Route breadcrumb label for forms overview",
"originalDefault": "forms"
},
"RT8KNi": {
"defaultMessage": "Save",
"description": "Save button text",
"originalDefault": "Save"
},
"ZiDANM": {
"defaultMessage": "Home",
"description": "Route breadcrumb label for home",
Expand Down
11 changes: 11 additions & 0 deletions i18n/messages/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
"description": "Route breadcrumb label for form categories",
"originalDefault": "categories"
},
"2wkqV6": {
"defaultMessage": "Preview",
"description": "Preview button text",
"isTranslated": true,
"originalDefault": "Preview"
},
"6UFbVC": {
"defaultMessage": "Je moet ingelogd zijn voor deze actie.",
"description": "'Not authenticated' error message",
Expand Down Expand Up @@ -49,6 +55,11 @@
"description": "Route breadcrumb label for forms overview",
"originalDefault": "forms"
},
"RT8KNi": {
"defaultMessage": "Opslaan",
"description": "Save button text",
"originalDefault": "Save"
},
"ZiDANM": {
"defaultMessage": "Home",
"description": "Route breadcrumb label for home",
Expand Down
24 changes: 24 additions & 0 deletions src/components/layout/BasicLayout.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {ArgTypes, Canvas, Meta} from '@storybook/addon-docs/blocks';

import * as BasicLayoutStories from './BasicLayout.stories';

<Meta of={BasicLayoutStories} />

# Basic Layout

The `BasicLayout` component is the generic layout component for most (if not all) pages in the
admin-ui. It provides a header, sidebar navigation, main content, and footer.

`BasicLayout` takes a optional `children` prop, which will be used as the main content of the page.
When the `children` property is not provided, a React-Router `<Outlet/>` will be used as the main
content, allowing regular Layout usage.

Because the `BasicLayout` component is used by other Layout components, we cannot assign it as a
global React-router Layout. Otherwise, the elements of the `BasicLayout` would be rendered multiple
times (as the global Layout, and in every Layout that uses it).

<Canvas of={BasicLayoutStories.Default} />

## Props

<ArgTypes />
28 changes: 28 additions & 0 deletions src/components/layout/BasicLayout.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {Toolbar} from '@maykin-ui/admin-ui';
import type {Meta, StoryObj} from '@storybook/react-vite';
import {reactRouterParameters, withRouter} from 'storybook-addon-remix-react-router';
import {expect, within} from 'storybook/test';

import BasicLayout from '@/components/layout/BasicLayout';
import type {RouteHandle} from '@/routes/types';
Expand Down Expand Up @@ -97,3 +99,29 @@ export const WithDynamicBreadcrumbs: Story = {
}),
},
};

export const WithComponentProperties: Story = {
args: {
children: <SimplePageContent />,
footer: (
<Toolbar
pad
variant="alt"
align="start"
items={[
{
variant: 'primary',
children: 'Footer action',
},
]}
/>
),
},
play: async ({canvasElement}) => {
const canvas = within(canvasElement);

// Both the children and footer content should be rendered
expect(canvas.getByText(/Lorem ipsum dolor sit amet/)).toBeVisible();
expect(canvas.getByRole('button', {name: 'Footer action'})).toBeVisible();
},
};
50 changes: 19 additions & 31 deletions src/components/layout/BasicLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,19 @@ import {Outlet} from 'react-router';
import {EnvironmentBadge, FormStatusBadge} from '@/components/badge';
import {useBreadcrumbItems} from '@/hooks/useBreadcrumbItems';

const BasicLayout = () => {
export interface BasicLayoutProps {
/**
* The content to be displayed inside the layout. If not provided, the Outlet component
* will be used to render the current route's content.
*/
children?: React.ReactNode;
/**
* The footer content to be displayed at the bottom of the layout.
*/
footer?: React.ReactNode;
}

const BasicLayout: React.FC<BasicLayoutProps> = ({footer, children}) => {
const breadcrumbItems = useBreadcrumbItems();
return (
<CardBaseTemplate
Expand Down Expand Up @@ -81,36 +93,12 @@ const BasicLayout = () => {
/>,
]}
/>
<Body fullHeight>
<Outlet />
</Body>
<Column direction="row" span={12}>
<Toolbar
pad
variant="alt"
align="start"
items={[
{
variant: 'primary',
children: (
<>
<Outline.BookmarkSquareIcon />
Action 1
</>
),
},
{
variant: 'secondary',
children: (
<>
<Outline.EyeIcon />
Action 2
</>
),
},
]}
/>
</Column>
<Body fullHeight>{children ?? <Outlet />}</Body>
{footer && (
<Column direction="row" span={12}>
{footer}
</Column>
)}
</CardBaseTemplate>
);
};
Expand Down
25 changes: 10 additions & 15 deletions src/components/layout/FormLayout.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ import * as FormLayoutStories from './FormLayout.stories';

# Form Layout

The `FormLayout` component is used to provide Formik state/context to the form-detail pages, and
handle the form submission.
The `FormLayout` component is a layout component for form-detail pages. The `FormLayout` component
adds Formik state/context for form editing, and handles the form submission. It also implements the
`BasicLayout` component, and adds footer controls for saving and previewing the form.

`FormLayout` use the custom react-router loader `formLoader` to fetch the form details. The
`FormLayout` uses the custom react-router loader `formLoader` to fetch the form details. The
`formLoader` loader uses a tanstack query client to keep a cached version of the form details, to
prevent duplicate API requests.

The form submissions are persisted through a tanstack useMutation: `useFormMutation`.
`useFormMutation` persists the data to API, and updates the local form details cache.
The form submissions are persisted through `useFormMutation`, which implements a tanstack
useMutation. `useFormMutation` persists the data to API, and updates the local form details cache.

The Formik state/context is added through a secondary `FormLayoutInner` component.

Expand All @@ -29,20 +30,14 @@ implementation and testing simple.
A simple form page example:

```tsx
import {Button} from '@maykin-ui/admin-ui';
import {Field, Form} from 'formik';
import {Form} from 'formik';

import {TextField} from '@/components/form/TextField';

const FormPageContent: React.FC = () => {
return (
<Form>
<div>
<label htmlFor="form-name">Form name</label>
<Field id="form-name" name="name" />
</div>

<Button variant="primary" type="submit">
Submit
</Button>
<TextField name="name" label="Form name" />
</Form>
);
};
Expand Down
Loading
Loading