Skip to content

Commit de2788b

Browse files
committed
Additional auto-save updates
1 parent b6859b5 commit de2788b

11 files changed

Lines changed: 354 additions & 163 deletions

File tree

app/helpers/application_helper.rb

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,6 @@ def flatten_translations(hash, prefix = nil)
139139
if value.is_a?(Hash)
140140
result.merge!(flatten_translations(value, full_key))
141141
elsif !value.is_a?(Proc)
142-
# Strip the Rails-style `%{key}` sigil so the frontend I18n helper
143-
# can match interpolation tokens with its `{key}` regex.
144142
result[full_key] = value.to_s.gsub(/%\{/, '{')
145143
end
146144
end
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
@import "~styles/_global";
2+
3+
.banner {
4+
display: flex;
5+
flex-wrap: wrap;
6+
align-items: center;
7+
gap: $size-10;
8+
@include setPadding($size-16, $size-16, $size-16, $size-16);
9+
@include setMargin($size-0, $size-0, $size-20, $size-0);
10+
background-color: $cornflower;
11+
border: 1px solid $blumine;
12+
border-radius: $size-4;
13+
14+
@media screen and (max-width: $small) {
15+
flex-direction: column;
16+
align-items: flex-start;
17+
}
18+
}
19+
20+
.message {
21+
flex: 1;
22+
color: $blumine;
23+
@include setFontSize($size-16);
24+
25+
@media screen and (max-width: $small) {
26+
width: 100%;
27+
}
28+
}
29+
30+
.actions {
31+
display: flex;
32+
gap: $size-10;
33+
flex-shrink: 0;
34+
35+
@media screen and (max-width: $small) {
36+
width: 100%;
37+
justify-content: flex-end;
38+
}
39+
}
40+
41+
.primaryButton {
42+
@include button;
43+
@include buttonS;
44+
@include buttonHover;
45+
background: $blumine !important;
46+
color: $white !important;
47+
}
48+
49+
.secondaryButton {
50+
@include button;
51+
@include buttonS;
52+
@include buttonHover;
53+
color: $blumine !important;
54+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// @flow
2+
import React from 'react';
3+
import type { Node } from 'react';
4+
import css from './FeedbackBanner.scss';
5+
6+
export type Action = {
7+
label: string,
8+
onClick: Function,
9+
primary?: boolean,
10+
};
11+
12+
export type Props = {
13+
message: string,
14+
actions: Action[],
15+
};
16+
17+
export const FeedbackBanner = ({ message, actions }: Props): Node => (
18+
<div className={css.banner} role="status" aria-live="polite">
19+
<span className={css.message}>{message}</span>
20+
<div className={css.actions}>
21+
{actions.map((action) => (
22+
<button
23+
key={action.label}
24+
type="button"
25+
className={action.primary ? css.primaryButton : css.secondaryButton}
26+
onClick={action.onClick}
27+
>
28+
{action.label}
29+
</button>
30+
))}
31+
</div>
32+
</div>
33+
);
34+
35+
export default FeedbackBanner;

client/app/components/Form/FormAutosaveBanner.jsx

Lines changed: 0 additions & 49 deletions
This file was deleted.

client/app/components/Form/FormAutosaveBanner.scss

Lines changed: 0 additions & 24 deletions
This file was deleted.

client/app/components/Form/__tests__/FormAutosaveBanner.spec.jsx

Lines changed: 34 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,62 +2,52 @@
22
import React from 'react';
33
import { render, screen } from '@testing-library/react';
44
import userEvent from '@testing-library/user-event';
5-
import { FormAutosaveBanner } from 'components/Form/FormAutosaveBanner';
6-
7-
const onRestore = jest.fn();
8-
const onDismiss = jest.fn();
9-
10-
const renderBanner = (savedAt = Date.now()) => render(
11-
<FormAutosaveBanner
12-
savedAt={savedAt}
13-
onRestore={onRestore}
14-
onDismiss={onDismiss}
5+
import { FeedbackBanner } from 'components/FeedbackBanner';
6+
7+
const onPrimary = jest.fn();
8+
const onSecondary = jest.fn();
9+
10+
const renderBanner = (message = 'Test message') => render(
11+
<FeedbackBanner
12+
message={message}
13+
actions={[
14+
{ label: 'Primary', onClick: onPrimary, primary: true },
15+
{ label: 'Secondary', onClick: onSecondary },
16+
]}
1517
/>,
1618
);
1719

18-
describe('FormAutosaveBanner', () => {
20+
describe('FeedbackBanner', () => {
1921
beforeEach(() => {
2022
jest.clearAllMocks();
2123
});
2224

23-
it('renders the restore and dismiss buttons', () => {
24-
renderBanner();
25-
expect(screen.getByRole('button', { name: /restore draft/i })).toBeInTheDocument();
26-
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument();
27-
});
28-
29-
it('shows "just now" for a timestamp within the last minute', () => {
30-
renderBanner(Date.now());
31-
expect(screen.getByRole('status')).toHaveTextContent('just now');
32-
});
33-
34-
it('shows minutes when the draft is a few minutes old', () => {
35-
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
36-
renderBanner(fiveMinutesAgo);
37-
expect(screen.getByRole('status')).toHaveTextContent('5 minutes ago');
38-
});
39-
40-
it('shows "1 minute ago" when the draft is exactly 1 minute old', () => {
41-
const oneMinuteAgo = Date.now() - 60 * 1000;
42-
renderBanner(oneMinuteAgo);
43-
expect(screen.getByRole('status')).toHaveTextContent('1 minute ago');
44-
});
45-
46-
it('shows "1 hour ago" when the draft is exactly 1 hour old', () => {
47-
const oneHourAgo = Date.now() - 60 * 60 * 1000;
48-
renderBanner(oneHourAgo);
49-
expect(screen.getByRole('status')).toHaveTextContent('1 hour ago');
25+
it('renders the message and both action buttons', () => {
26+
renderBanner('You have an unsaved draft.');
27+
expect(screen.getByRole('status')).toHaveTextContent('You have an unsaved draft.');
28+
expect(screen.getByRole('button', { name: 'Primary' })).toBeInTheDocument();
29+
expect(screen.getByRole('button', { name: 'Secondary' })).toBeInTheDocument();
5030
});
5131

52-
it('calls onRestore when the restore button is clicked', async () => {
32+
it('calls the primary action callback when clicked', async () => {
5333
renderBanner();
54-
await userEvent.click(screen.getByRole('button', { name: /restore draft/i }));
55-
expect(onRestore).toHaveBeenCalledTimes(1);
34+
await userEvent.click(screen.getByRole('button', { name: 'Primary' }));
35+
expect(onPrimary).toHaveBeenCalledTimes(1);
5636
});
5737

58-
it('calls onDismiss when the dismiss button is clicked', async () => {
38+
it('calls the secondary action callback when clicked', async () => {
5939
renderBanner();
60-
await userEvent.click(screen.getByRole('button', { name: /dismiss/i }));
61-
expect(onDismiss).toHaveBeenCalledTimes(1);
40+
await userEvent.click(screen.getByRole('button', { name: 'Secondary' }));
41+
expect(onSecondary).toHaveBeenCalledTimes(1);
42+
});
43+
44+
it('renders with a single action', () => {
45+
render(
46+
<FeedbackBanner
47+
message="Done."
48+
actions={[{ label: 'OK', onClick: onPrimary, primary: true }]}
49+
/>,
50+
);
51+
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument();
6252
});
6353
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// @flow
2+
import { getNewInputs } from 'components/Form/utils';
3+
4+
describe('getNewInputs', () => {
5+
describe('for a required text input', () => {
6+
const input = {
7+
id: 'text-id',
8+
type: 'text',
9+
name: 'some[field]',
10+
required: true,
11+
};
12+
13+
it('reports an error when the field is empty', () => {
14+
const { errors } = getNewInputs({
15+
inputs: [input],
16+
errors: {},
17+
refs: { 'text-id': { value: '' } },
18+
});
19+
expect(errors['text-id']).toBe(true);
20+
});
21+
22+
it('reports no error when the field has a value', () => {
23+
const { errors } = getNewInputs({
24+
inputs: [input],
25+
errors: {},
26+
refs: { 'text-id': { value: 'hello' } },
27+
});
28+
expect(errors['text-id']).toBe(false);
29+
});
30+
31+
it('clears a stale error when the field now has a value', () => {
32+
const { errors } = getNewInputs({
33+
inputs: [input],
34+
errors: { 'text-id': true },
35+
refs: { 'text-id': { value: 'hello' } },
36+
});
37+
expect(errors['text-id']).toBe(false);
38+
});
39+
});
40+
41+
describe('for a required textareaTemplate input', () => {
42+
const input = {
43+
id: 'textarea-template-id',
44+
type: 'textareaTemplate',
45+
name: 'moment[why]',
46+
required: true,
47+
};
48+
49+
it('reports an error when the field is empty', () => {
50+
const { errors } = getNewInputs({
51+
inputs: [input],
52+
errors: {},
53+
refs: { 'textarea-template-id': { value: '' } },
54+
});
55+
expect(errors['textarea-template-id']).toBe(true);
56+
});
57+
58+
it('clears a stale error when the field now has a value', () => {
59+
const { errors } = getNewInputs({
60+
inputs: [input],
61+
errors: { 'textarea-template-id': true },
62+
refs: { 'textarea-template-id': { value: '<p>Hello</p>' } },
63+
});
64+
expect(errors['textarea-template-id']).toBe(false);
65+
});
66+
});
67+
68+
describe('for a non-validated type without a DOM ref', () => {
69+
const input = {
70+
id: 'quickcreate-id',
71+
type: 'quickCreate',
72+
name: 'some[field]',
73+
required: true,
74+
};
75+
76+
it('preserves an existing error set by component-level validation', () => {
77+
const { errors } = getNewInputs({
78+
inputs: [input],
79+
errors: { 'quickcreate-id': true },
80+
refs: {},
81+
});
82+
expect(errors['quickcreate-id']).toBe(true);
83+
});
84+
});
85+
});

client/app/components/Form/index.jsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,6 @@ export const Form = ({ action, inputs: inputsProps }: Props): Node => {
126126

127127
const myRefs: Object = {};
128128
const formRef = useRef<HTMLFormElement | null>(null);
129-
// Holds the latest collect-and-save function so the interval never captures
130-
// stale references across renders.
131129
const saveLatestRef = useRef<Function | null>(null);
132130

133131
const {

0 commit comments

Comments
 (0)