Skip to content

Commit

Permalink
Toast-style notifications (#130)
Browse files Browse the repository at this point in the history
* Wire notification to "add pattern" and "delete pattern"

* Add test for notifications state

* Add interaction stories for toast notifications

* Linting

* Swap nanoid out for crypto.randomUUID() in an attempt to avoid an out of memory error on the Cloud.gov Pages build container.

* Remove superfluous ID prop
  • Loading branch information
danielnaab authored May 15, 2024
1 parent beb29c8 commit 357ae72
Show file tree
Hide file tree
Showing 15 changed files with 413 additions and 78 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const DraggableList: React.FC<DraggableListProps> = ({
{arrayChildren.map((child, index) => {
const patternId = order[index];
return (
<SortableItem key={patternId} id={patternId}>
<SortableItem key={index} id={patternId}>
{child}
</SortableItem>
);
Expand Down
19 changes: 15 additions & 4 deletions packages/design/src/FormManager/FormEdit/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
} from '@atj/forms';
import { type FormManagerContext } from '..';
import { type PatternFocus } from './types';
import {
NotificationSlice,
createNotificationsSlice,
} from '../common/Notifications';

export type FormEditSlice = {
context: FormManagerContext;
Expand All @@ -25,7 +29,7 @@ export type FormEditSlice = {
setFocus: (patternId: PatternId) => boolean;
updatePattern: (data: Pattern) => void;
updateActivePattern: (formData: PatternMap) => void;
};
} & NotificationSlice;

type FormEditStoreContext = {
context: FormManagerContext;
Expand All @@ -36,7 +40,8 @@ type FormEditStoreCreator = StateCreator<FormEditSlice, [], [], FormEditSlice>;

export const createFormEditSlice =
({ context, form }: FormEditStoreContext): FormEditStoreCreator =>
(set, get) => ({
(set, get, store) => ({
...createNotificationsSlice()(set, get, store),
context,
form,
availablePatterns: Object.entries(context.config.patterns).map(
Expand All @@ -50,6 +55,7 @@ export const createFormEditSlice =
const builder = new BlueprintBuilder(state.form);
const newPattern = builder.addPattern(state.context.config, patternType);
set({ form: builder.form, focus: { pattern: newPattern } });
state.addNotification('info', 'Pattern added.');
},
deleteSelectedPattern: () => {
const state = get();
Expand All @@ -59,8 +65,9 @@ export const createFormEditSlice =
const builder = new BlueprintBuilder(state.form);
builder.removePattern(state.context.config, state.focus.pattern.id);
set({ focus: undefined, form: builder.form });
state.addNotification('warning', 'Pattern deleted.');
},
setFocus: patternId => {
setFocus: function (patternId) {
const state = get();
if (state.focus?.pattern.id === patternId) {
return true;
Expand All @@ -69,7 +76,11 @@ export const createFormEditSlice =
return false;
}
const elementToSet = getPattern(state.form, patternId);
set({ focus: { errors: undefined, pattern: elementToSet } });
if (elementToSet) {
set({ focus: { errors: undefined, pattern: elementToSet } });
} else {
set({ focus: undefined });
}
return true;
},
updatePattern: pattern => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import type { Meta, StoryObj } from '@storybook/react';

import { FormManagerLayout, NavPage } from '.';
import { FormManagerLayout } from '.';
import { createTestForm, createTestFormManagerContext } from '../../test-form';
import { FormManagerProvider } from '../store';
import { NavPage } from './TopNavigation';

export default {
title: 'FormManagerLayout',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const TopNavigation = ({
{preview && <PreviewIconLink url={preview} uswdsRoot={uswdsRoot} />}
</div>
</div>
<MobileStepIndicator />
<MobileStepIndicator curPage={curPage} />
</div>
<div className="display-none tablet:display-block margin-top-1 margin-bottom-1">
<div className="grid-container grid-row">
Expand Down Expand Up @@ -169,7 +169,7 @@ const PreviewIconLink = ({
);
};

const MobileStepIndicator = () => (
const MobileStepIndicator = ({ curPage }: { curPage: NavPage }) => (
<div className="grid-row grid-gap flex-align-center">
<div className="grid-col grid-col-4">
<span className="usa-step-indicator__heading-counter">
Expand All @@ -179,13 +179,16 @@ const MobileStepIndicator = () => (
</span>
</div>
<div className="grid-col grid-col-8">
<select className="usa-select" name="options" id="options">
<option value="value1">Upload</option>
<option value="value1">Create</option>
<option selected value="value2">
Configure
</option>
<option value="value3">Public</option>
<select
className="usa-select"
name="options"
id="options"
defaultValue={curPage}
>
<option value={NavPage.upload}>Upload</option>
<option value={NavPage.create}>Create</option>
<option value={NavPage.configure}>Configure</option>
<option value={NavPage.publish}>Publish</option>
</select>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions packages/design/src/FormManager/FormManagerLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React from 'react';

import { Notifications } from '../common/Notifications';
import { type NavPage, TopNavigation } from './TopNavigation';
import { BottomNavigation } from './BottomNavigation';

export { NavPage } from './TopNavigation';

type FormManagerLayoutProps = {
children: React.ReactNode;
step?: NavPage;
Expand All @@ -24,6 +23,7 @@ export const FormManagerLayout = ({
}: FormManagerLayoutProps) => {
return (
<>
<Notifications />
{step && <TopNavigation curPage={step} preview={preview} />}
<section className="grid-container usa-section">
<div className="grid-row flex-justify-center">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Meta, StoryObj } from '@storybook/react';

import { NotificationAlert } from './NotificationAlert';

export default {
title: 'FormManager/Notifications/NotificationAlert',
component: NotificationAlert,
} satisfies Meta<typeof NotificationAlert>;

export const Info: StoryObj<typeof NotificationAlert> = {
args: {
type: 'info',
message: 'Informational message',
},
};
export const Warning: StoryObj<typeof NotificationAlert> = {
args: {
type: 'warning',
message: 'Informational message',
},
};
export const Success: StoryObj<typeof NotificationAlert> = {
args: {
type: 'success',
message: 'Success message',
},
};
export const Error: StoryObj<typeof NotificationAlert> = {
args: {
type: 'error',
message: 'Error message',
},
};
export const Emergency: StoryObj<typeof NotificationAlert> = {
args: {
type: 'emergency',
message: 'Emergency message',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import classNames from 'classnames';
import React from 'react';

import { type Notification } from './store';

type NotificationAlertProps = {
type: Notification['type'];
message: Notification['message'];
};

export const NotificationAlert = ({
type,
message,
}: NotificationAlertProps) => (
<div
className={classNames(
'usa-alert usa-alert--slim bg-light-blue padding-2',
`usa-alert--${type}`
)}
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<div className="usa-alert__body">
<p className="usa-alert__text">{message}</p>
</div>
</div>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Meta, StoryObj } from '@storybook/react';
import React from 'react';

import { Notifications } from './Notifications';
import { FormManagerProvider, useFormManagerStore } from '../../store';
import {
createTestForm,
createTestFormManagerContext,
} from '../../../test-form';

const StoryImpl = () => {
const { addNotification } = useFormManagerStore();
return (
<>
<button
onClick={() => addNotification('info', 'Notification triggered!')}
>
Trigger Notification
</button>
<Notifications />
</>
);
};

export default {
title: 'FormManager/Notifications/Notifications',
component: Notifications,
decorators: [
() => (
<FormManagerProvider
context={createTestFormManagerContext()}
form={createTestForm()}
>
<StoryImpl />
</FormManagerProvider>
),
],
} satisfies Meta<typeof Notifications>;

export const Default: StoryObj<typeof Notifications> = {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react';
import { useFormManagerStore } from '../../store';
import { NotificationAlert } from './NotificationAlert';

export const Notifications = () => {
const { notifications } = useFormManagerStore();
return (
<div
className={`position-fixed z-200`}
style={{
top: '60px',
left: '50%',
transform: 'translateX(-50%)',
maxWidth: '90%',
}}
>
{notifications.map(notification => (
<NotificationAlert
key={notification.id}
type={notification.type}
message={notification.message}
/>
))}
</div>
);
};
2 changes: 2 additions & 0 deletions packages/design/src/FormManager/common/Notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Notifications } from './Notifications';
export { type NotificationSlice, createNotificationsSlice } from './store';
25 changes: 25 additions & 0 deletions packages/design/src/FormManager/common/Notifications/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { create } from 'zustand';

import { createNotificationsSlice } from './store';

const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

describe('Notifications store', () => {
test('should register notification', async () => {
const timeoutMs = 1;
const store = create(createNotificationsSlice(timeoutMs));
const state = store.getState();
state.addNotification('info', 'Pattern added.');
const notifications = store.getState().notifications;

expect(notifications.length).toEqual(1);
expect(typeof notifications[0].id).toEqual('string');
expect(notifications[0].message).toEqual('Pattern added.');
expect(notifications[0].type).toEqual('info');

// Wait for the timeout and confirm the notification has been removed.
await wait(timeoutMs);
expect(store.getState().notifications.length).toEqual(0);
});
});
45 changes: 45 additions & 0 deletions packages/design/src/FormManager/common/Notifications/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { StateCreator } from 'zustand';

type NotificationType = 'info' | 'warning' | 'success' | 'error' | 'emergency';

export type Notification = {
type: NotificationType;
id: string;
message: string;
};

export type NotificationSlice = {
notifications: Notification[];
addNotification: (type: NotificationType, message: string) => void;
};

type NotificationStoreCreator = StateCreator<
NotificationSlice,
[],
[],
NotificationSlice
>;

export const createNotificationsSlice =
(timeout: number = 5000): NotificationStoreCreator =>
set => ({
notifications: [],
addNotification: (type, message) => {
const id = crypto.randomUUID();
set(state => ({
notifications: [...state.notifications, { id, type, message }],
}));

// Add an extra second timeout for each 120 words in the message.
const naiveWordCount = message.split(' ').length;
const extraTimeout = Math.floor(naiveWordCount / 120) * 1000;

setTimeout(() => {
set(state => ({
notifications: state.notifications.filter(
notification => notification.id !== id
),
}));
}, timeout + extraTimeout);
},
});
3 changes: 2 additions & 1 deletion packages/design/src/FormManager/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { FormDocumentImport } from './FormDocumentImport';
import FormEdit from './FormEdit';
import { type EditComponentForPattern } from './FormEdit/types';
import FormList from './FormList';
import { FormManagerLayout, NavPage } from './FormManagerLayout';
import { FormManagerLayout } from './FormManagerLayout';
import { NavPage } from './FormManagerLayout/TopNavigation';
import { FormPreview } from './FormPreview';
import * as AppRoutes from './routes';
import { FormManagerProvider } from './store';
Expand Down
6 changes: 3 additions & 3 deletions packages/design/src/FormManager/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import {
} from 'zustand';
import { createContext } from 'zustand-utils';

import { Result } from '@atj/common';
import { BlueprintBuilder, type Blueprint } from '@atj/forms';

import { type FormEditSlice, createFormEditSlice } from './FormEdit/store';
import { type FormListSlice, createFormListSlice } from './FormList/store';
import { type FormManagerContext } from '.';
import { Result } from '@atj/common';

type StoreContext = {
context: FormManagerContext;
Expand Down Expand Up @@ -66,7 +66,7 @@ const createFormManagerSlice =
context,
form,
formId,
createNewForm: async () => {
createNewForm: async function () {
const builder = new BlueprintBuilder();
builder.setFormSummary({
title: `My form - ${new Date().toISOString()}`,
Expand Down Expand Up @@ -101,7 +101,7 @@ const savePeriodically = (store: UseBoundStore<StoreApi<FormManagerStore>>) => {
let lastForm: Blueprint;
setInterval(async () => {
const { form, saveForm } = store.getState();
if (lastForm && lastForm !== form) {
if (form && form !== lastForm) {
await saveForm(form);
lastForm = form;
}
Expand Down
Loading

0 comments on commit 357ae72

Please sign in to comment.