Skip to content

Commit 7b8a5ec

Browse files
authored
Various functional improvements (#2481)
* Ensure a Moments template is populated when creating a new moment from the template screen * Auto-save for main feature forms * Fix Form save bug * Additional auto-save updates * Fix responsive layout change from mobile open to desktop * More auto-save updates
1 parent 3284c53 commit 7b8a5ec

34 files changed

Lines changed: 1217 additions & 17 deletions

app/helpers/application_helper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ 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-
result[full_key] = value.to_s
142+
result[full_key] = value.to_s.gsub(/%\{/, '{')
143143
end
144144
end
145145
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/__tests__/Form.spec.jsx

Lines changed: 252 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,45 @@
11
// @flow
22
import React from 'react';
3-
import { render, screen, fireEvent } from '@testing-library/react';
3+
import {
4+
render, screen, fireEvent, act,
5+
} from '@testing-library/react';
46
import userEvent from '@testing-library/user-event';
57
import { InputMocks } from 'mocks/InputMocks';
68
import Form from 'components/Form';
79

810
// TODO (julianguyen): Include InputTextarea after writing stubs for pell editor
911

12+
const STORAGE_KEY = 'ifme_autosave_fake-action';
13+
14+
const renderMinimalForm = () => render(
15+
<Form
16+
action="/fake-action"
17+
inputs={[
18+
InputMocks.inputTextProps,
19+
InputMocks.inputSwitchProps,
20+
InputMocks.inputSubmitProps,
21+
]}
22+
/>,
23+
);
24+
25+
const renderFullForm = () => render(
26+
<Form
27+
action="/fake-action"
28+
inputs={[
29+
InputMocks.inputTextProps,
30+
InputMocks.inputSelectProps,
31+
InputMocks.inputRadioProps,
32+
InputMocks.inputCheckboxProps,
33+
InputMocks.inputCheckboxGroupProps,
34+
InputMocks.inputMultiSelectProps,
35+
InputMocks.inputTagProps,
36+
InputMocks.inputSwitchProps,
37+
InputMocks.inputNumberProps,
38+
InputMocks.inputSubmitProps,
39+
]}
40+
/>,
41+
);
42+
1043
const getComponent = () => (
1144
<Form
1245
action="/fake-action"
@@ -64,6 +97,224 @@ describe('Form', () => {
6497
});
6598
});
6699

100+
describe('auto-save', () => {
101+
beforeEach(() => { localStorage.clear(); });
102+
afterEach(() => { localStorage.clear(); });
103+
104+
describe('interval', () => {
105+
beforeEach(() => { jest.useFakeTimers(); });
106+
afterEach(() => { jest.useRealTimers(); });
107+
108+
it('does not write to localStorage when nothing has changed from the initial form state', () => {
109+
renderMinimalForm();
110+
act(() => { jest.advanceTimersByTime(2000); });
111+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
112+
});
113+
114+
it('does not write to localStorage when only default/pre-filled values are present', () => {
115+
render(
116+
<Form
117+
action="/fake-action"
118+
inputs={[
119+
InputMocks.inputSelectProps,
120+
{ ...InputMocks.inputCheckboxProps, checked: true },
121+
InputMocks.inputSwitchProps,
122+
InputMocks.inputSubmitProps,
123+
]}
124+
/>,
125+
);
126+
act(() => { jest.advanceTimersByTime(2000); });
127+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
128+
});
129+
130+
it('saves to localStorage after the user changes a text field', () => {
131+
const { container } = renderMinimalForm();
132+
fireEvent.change(
133+
container.querySelector('input[name="some-text-name"]'),
134+
{ target: { value: 'Hello world' } },
135+
);
136+
act(() => { jest.advanceTimersByTime(2000); });
137+
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
138+
expect(saved?.values?.['some-text-name']).toBe('Hello world');
139+
});
140+
141+
it('saves to localStorage after the user changes a select', () => {
142+
const { container } = render(
143+
<Form
144+
action="/fake-action"
145+
inputs={[
146+
InputMocks.inputSelectProps,
147+
InputMocks.inputSubmitProps,
148+
]}
149+
/>,
150+
);
151+
fireEvent.change(
152+
container.querySelector('select[name="some-select-name"]'),
153+
{ target: { value: '1' } },
154+
);
155+
act(() => { jest.advanceTimersByTime(2000); });
156+
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
157+
expect(saved?.values?.['some-select-name']).toBe('1');
158+
});
159+
160+
it('does not re-save to localStorage after the form is submitted', () => {
161+
const { container } = renderMinimalForm();
162+
fireEvent.change(
163+
container.querySelector('input[name="some-text-name"]'),
164+
{ target: { value: 'Hello world' } },
165+
);
166+
fireEvent.submit(container.querySelector('form'));
167+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
168+
act(() => { jest.advanceTimersByTime(2000); });
169+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
170+
});
171+
});
172+
173+
describe('restore banner', () => {
174+
it('shows when localStorage contains a draft', () => {
175+
localStorage.setItem(
176+
STORAGE_KEY,
177+
JSON.stringify({ timestamp: Date.now(), values: { 'some-text-name': 'Saved text' } }),
178+
);
179+
renderMinimalForm();
180+
expect(screen.getByRole('status')).toBeInTheDocument();
181+
expect(screen.getByRole('button', { name: 'Restore draft' })).toBeInTheDocument();
182+
});
183+
184+
it('does not show when localStorage is empty', () => {
185+
renderMinimalForm();
186+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
187+
});
188+
189+
it('populates text fields with saved values when Restore draft is clicked', async () => {
190+
localStorage.setItem(
191+
STORAGE_KEY,
192+
JSON.stringify({ timestamp: Date.now(), values: { 'some-text-name': 'My restored text' } }),
193+
);
194+
renderMinimalForm();
195+
await userEvent.click(screen.getByRole('button', { name: 'Restore draft' }));
196+
expect(screen.getByPlaceholderText('Some Text Placeholder')).toHaveValue('My restored text');
197+
});
198+
199+
it('hides the banner and removes the draft when Dismiss is clicked', async () => {
200+
localStorage.setItem(
201+
STORAGE_KEY,
202+
JSON.stringify({ timestamp: Date.now(), values: { 'some-text-name': 'Saved text' } }),
203+
);
204+
renderMinimalForm();
205+
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
206+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
207+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
208+
});
209+
210+
it('removes the draft from localStorage when the form is submitted without errors', () => {
211+
localStorage.setItem(
212+
STORAGE_KEY,
213+
JSON.stringify({ timestamp: Date.now(), values: { 'some-text-name': 'Old draft' } }),
214+
);
215+
const { container } = renderMinimalForm();
216+
fireEvent.submit(container.querySelector('form'));
217+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
218+
});
219+
});
220+
221+
describe('restore: all input types', () => {
222+
it('restores a select to its saved option', async () => {
223+
localStorage.setItem(
224+
STORAGE_KEY,
225+
JSON.stringify({ timestamp: Date.now(), values: { 'some-select-name': '1' } }),
226+
);
227+
const { container } = renderFullForm();
228+
await userEvent.click(screen.getByRole('button', { name: 'Restore draft' }));
229+
expect(container.querySelector('select[name="some-select-name"]').value).toBe('1');
230+
});
231+
232+
it('restores a radio group to its saved selection', async () => {
233+
localStorage.setItem(
234+
STORAGE_KEY,
235+
JSON.stringify({ timestamp: Date.now(), values: { 'some-radio-name': '2' } }),
236+
);
237+
const { container } = renderFullForm();
238+
await userEvent.click(screen.getByRole('button', { name: 'Restore draft' }));
239+
const checkedRadio = container.querySelector('input[type="radio"][name="some-radio-name"]:checked');
240+
expect(checkedRadio).not.toBeNull();
241+
expect(checkedRadio.value).toBe('2');
242+
});
243+
244+
it('restores a single checkbox to its saved checked state', async () => {
245+
localStorage.setItem(
246+
STORAGE_KEY,
247+
JSON.stringify({ timestamp: Date.now(), values: { 'some-checkbox-name': '1' } }),
248+
);
249+
const { container } = renderFullForm();
250+
await userEvent.click(screen.getByRole('button', { name: 'Restore draft' }));
251+
expect(container.querySelector('input[type="checkbox"][name="some-checkbox-name"]').checked).toBe(true);
252+
});
253+
254+
it('restores a checkbox group to its saved checked states', async () => {
255+
localStorage.setItem(
256+
STORAGE_KEY,
257+
JSON.stringify({
258+
timestamp: Date.now(),
259+
values: {
260+
'some-checkbox-one-name': '1',
261+
'some-checkbox-two-name': '0',
262+
},
263+
}),
264+
);
265+
const { container } = renderFullForm();
266+
await userEvent.click(screen.getByRole('button', { name: 'Restore draft' }));
267+
expect(container.querySelector('input[type="checkbox"][name="some-checkbox-one-name"]').checked).toBe(true);
268+
expect(container.querySelector('input[type="checkbox"][name="some-checkbox-two-name"]').checked).toBe(false);
269+
});
270+
271+
it('restores a switch toggle to its saved on state', async () => {
272+
localStorage.setItem(
273+
STORAGE_KEY,
274+
JSON.stringify({ timestamp: Date.now(), values: { 'some-switch-name': 'true' } }),
275+
);
276+
const { container } = renderFullForm();
277+
await userEvent.click(screen.getByRole('button', { name: 'Restore draft' }));
278+
expect(container.querySelector('input[type="checkbox"][name="some-switch-name"]').checked).toBe(true);
279+
});
280+
281+
it('restores a quickCreate widget to its saved selections', async () => {
282+
const quickCreateInput = {
283+
id: 'some-qc-id',
284+
type: 'quickCreate',
285+
name: 'some-qc-name[]',
286+
label: 'Some QC Label',
287+
placeholder: 'Search',
288+
checkboxes: [
289+
{
290+
id: 'qc-one', label: 'Option One', value: 1, checked: false,
291+
},
292+
{
293+
id: 'qc-two', label: 'Option Two', value: 2, checked: false,
294+
},
295+
],
296+
formProps: { action: '/test', inputs: [] },
297+
};
298+
localStorage.setItem(
299+
STORAGE_KEY,
300+
JSON.stringify({ timestamp: Date.now(), values: { 'some-qc-name[]': ['1'] } }),
301+
);
302+
const { container } = render(
303+
<Form
304+
action="/fake-action"
305+
inputs={[quickCreateInput, InputMocks.inputSubmitProps]}
306+
/>,
307+
);
308+
await userEvent.click(screen.getByRole('button', { name: 'Restore draft' }));
309+
const checkbox = container.querySelector(
310+
'input[type="checkbox"][name="some-qc-name[]"][value="1"]',
311+
);
312+
expect(checkbox).not.toBeNull();
313+
expect(checkbox.checked).toBe(true);
314+
});
315+
});
316+
});
317+
67318
describe('for changes on the input with number type', () => {
68319
it('has no errors when submit is clicked', async () => {
69320
render(getComponent());

0 commit comments

Comments
 (0)