Skip to content
This repository was archived by the owner on Dec 7, 2021. It is now read-only.

Commit 5574cd9

Browse files
authored
feat: Save partial project progress during project creation (#769)
This adds functionality to persist partial project information when creating a new project. Right now when creating a new connection inline within the create project flow and returning to the create project screen your partial project information is lost. Partial form progress is now saved into local storage and bound when returning to the form. Resolves #758
1 parent b13eaf6 commit 5574cd9

File tree

4 files changed

+212
-60
lines changed

4 files changed

+212
-60
lines changed

src/react/components/pages/projectSettings/projectForm.test.tsx

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ describe("Project Form Component", () => {
1515
const appSettings = MockFactory.appSettings();
1616
const connections = MockFactory.createTestConnections();
1717
let wrapper: ReactWrapper<IProjectFormProps, IProjectFormState> = null;
18-
let onSubmitHandler: jest.Mock = null;
19-
let onCancelHandler: jest.Mock = null;
18+
const onSubmitHandler = jest.fn();
19+
const onChangeHandler = jest.fn();
20+
const onCancelHandler = jest.fn();
2021

2122
function createComponent(props: IProjectFormProps) {
2223
return mount(
@@ -33,13 +34,16 @@ describe("Project Form Component", () => {
3334

3435
describe("Completed project", () => {
3536
beforeEach(() => {
36-
onSubmitHandler = jest.fn();
37-
onCancelHandler = jest.fn();
37+
onChangeHandler.mockClear();
38+
onSubmitHandler.mockClear();
39+
onCancelHandler.mockClear();
40+
3841
wrapper = createComponent({
3942
project,
4043
connections,
4144
appSettings,
4245
onSubmit: onSubmitHandler,
46+
onChange: onChangeHandler,
4347
onCancel: onCancelHandler,
4448
});
4549
});
@@ -76,10 +80,14 @@ describe("Project Form Component", () => {
7680

7781
const form = wrapper.find("form");
7882
form.simulate("submit");
79-
expect(onSubmitHandler).toBeCalledWith({
83+
84+
const expectedProject = {
8085
...project,
8186
name: newName,
82-
});
87+
};
88+
89+
expect(onChangeHandler).toBeCalled();
90+
expect(onSubmitHandler).toBeCalledWith(expectedProject);
8391
});
8492

8593
it("starting project should update description upon submission", () => {
@@ -92,10 +100,14 @@ describe("Project Form Component", () => {
92100

93101
const form = wrapper.find("form");
94102
form.simulate("submit");
95-
expect(onSubmitHandler).toBeCalledWith({
103+
104+
const expectedProject = {
96105
...project,
97106
description: newDescription,
98-
});
107+
};
108+
109+
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
110+
expect(onSubmitHandler).toBeCalledWith(expectedProject);
99111
});
100112

101113
it("starting project should update source connection ID upon submission", () => {
@@ -109,11 +121,14 @@ describe("Project Form Component", () => {
109121
expect(wrapper.state().formData.sourceConnection).toEqual(newConnection);
110122
const form = wrapper.find("form");
111123
form.simulate("submit");
112-
expect(onSubmitHandler).toBeCalledWith({
124+
125+
const expectedProject = {
113126
...project,
114127
sourceConnection: connections[1],
115-
});
128+
};
116129

130+
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
131+
expect(onSubmitHandler).toBeCalledWith(expectedProject);
117132
});
118133

119134
it("starting project should update target connection ID upon submission", () => {
@@ -125,13 +140,17 @@ describe("Project Form Component", () => {
125140
wrapper.find("select#root_targetConnection").simulate("change", { target: { value: newConnection.id } });
126141
expect(wrapper.state().formData.targetConnection).toEqual(newConnection);
127142
wrapper.find("form").simulate("submit");
128-
expect(onSubmitHandler).toBeCalledWith({
143+
144+
const expectedProject = {
129145
...project,
130146
targetConnection: connections[1],
131-
});
147+
};
148+
149+
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
150+
expect(onSubmitHandler).toBeCalledWith(expectedProject);
132151
});
133152

134-
it("starting project should call onChangeHandler on submission", () => {
153+
it("starting project should call onSubmitHandler on submission", () => {
135154
const form = wrapper.find("form");
136155
form.simulate("submit");
137156
expect(onSubmitHandler).toBeCalledWith({
@@ -155,6 +174,7 @@ describe("Project Form Component", () => {
155174

156175
const form = wrapper.find("form");
157176
form.simulate("submit");
177+
expect(onChangeHandler).toBeCalledWith(expect.objectContaining(project));
158178
expect(onSubmitHandler).toBeCalledWith(
159179
expect.objectContaining({
160180
name: newName,
@@ -187,6 +207,7 @@ describe("Project Form Component", () => {
187207
appSettings,
188208
connections: newConnections,
189209
onSubmit: onSubmitHandler,
210+
onChange: onChangeHandler,
190211
onCancel: onCancelHandler,
191212
});
192213
// Source Connection should have all connections
@@ -202,13 +223,12 @@ describe("Project Form Component", () => {
202223

203224
describe("Empty Project", () => {
204225
beforeEach(() => {
205-
onSubmitHandler = jest.fn();
206-
onCancelHandler = jest.fn();
207226
wrapper = createComponent({
208227
project: null,
209228
appSettings,
210229
connections,
211230
onSubmit: onSubmitHandler,
231+
onChange: onChangeHandler,
212232
onCancel: onCancelHandler,
213233
});
214234
});
@@ -239,6 +259,7 @@ describe("Project Form Component", () => {
239259
appSettings,
240260
connections,
241261
onSubmit: onSubmitHandler,
262+
onChange: onChangeHandler,
242263
onCancel: onCancelHandler,
243264
});
244265
const newTagName = "My new tag";

src/react/components/pages/projectSettings/projectForm.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import Form, { FormValidation, ISubmitEvent } from "react-jsonschema-form";
2+
import Form, { FormValidation, ISubmitEvent, IChangeEvent } from "react-jsonschema-form";
33
import { ITagsInputProps, TagEditorModal, TagsInput } from "vott-react";
44
import { addLocValues, strings } from "../../../../common/strings";
55
import { IConnection, IProject, ITag, IAppSettings } from "../../../../models/applicationState";
@@ -28,6 +28,7 @@ export interface IProjectFormProps extends React.Props<ProjectForm> {
2828
connections: IConnection[];
2929
appSettings: IAppSettings;
3030
onSubmit: (project: IProject) => void;
31+
onChange?: (project: IProject) => void;
3132
onCancel?: () => void;
3233
}
3334

@@ -97,6 +98,7 @@ export default class ProjectForm extends React.Component<IProjectFormProps, IPro
9798
schema={this.state.formSchema}
9899
uiSchema={this.state.uiSchema}
99100
formData={this.state.formData}
101+
onChange={this.onFormChange}
100102
onSubmit={this.onFormSubmit}>
101103
<div>
102104
<button className="btn btn-success mr-1" type="submit">{strings.projectSettings.save}</button>
@@ -184,6 +186,12 @@ export default class ProjectForm extends React.Component<IProjectFormProps, IPro
184186
return errors;
185187
}
186188

189+
private onFormChange = (changeEvent: IChangeEvent<IProject>) => {
190+
if (this.props.onChange) {
191+
this.props.onChange(changeEvent.formData);
192+
}
193+
}
194+
187195
private onFormSubmit(args: ISubmitEvent<IProject>) {
188196
const project: IProject = {
189197
...args.formData,

src/react/components/pages/projectSettings/projectSettingsPage.test.tsx

Lines changed: 107 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,21 @@ import { Provider } from "react-redux";
44
import { BrowserRouter as Router } from "react-router-dom";
55
import MockFactory from "../../../../common/mockFactory";
66
import createReduxStore from "../../../../redux/store/store";
7-
import ProjectSettingsPage, { IProjectSettingsPageProps } from "./projectSettingsPage";
7+
import ProjectSettingsPage, { IProjectSettingsPageProps, IProjectSettingsPageState } from "./projectSettingsPage";
88

99
jest.mock("../../../../services/projectService");
1010
import ProjectService from "../../../../services/projectService";
11-
import { IAppSettings } from "../../../../models/applicationState";
11+
import { IAppSettings, IProject } from "../../../../models/applicationState";
1212
import ProjectMetrics from "./projectMetrics";
13+
import ProjectForm, { IProjectFormProps } from "./projectForm";
1314

1415
jest.mock("./projectMetrics", () => () => {
15-
return (
16-
<div className="project-settings-page-metrics">
17-
Dummy Project Metrics
18-
</div>
19-
);
20-
},
16+
return (
17+
<div className="project-settings-page-metrics">
18+
Dummy Project Metrics
19+
</div>
20+
);
21+
},
2122
);
2223

2324
describe("Project settings page", () => {
@@ -33,12 +34,29 @@ describe("Project settings page", () => {
3334
);
3435
}
3536

37+
const localStorageMock = {
38+
getItem: jest.fn(),
39+
setItem: jest.fn(),
40+
removeItem: jest.fn(),
41+
};
42+
43+
beforeAll(() => {
44+
Object.defineProperty(global, "_localStorage", {
45+
value: localStorageMock,
46+
writable: false,
47+
});
48+
});
49+
3650
beforeEach(() => {
51+
localStorageMock.getItem.mockClear();
52+
localStorageMock.setItem.mockClear();
53+
localStorageMock.removeItem.mockClear();
54+
3755
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
38-
projectServiceMock.prototype.load = jest.fn((project) => ({...project}));
56+
projectServiceMock.prototype.load = jest.fn((project) => ({ ...project }));
3957
});
4058

41-
it("Form submission calls save project action", (done) => {
59+
it("Form submission calls save project action", async () => {
4260
const store = createReduxStore(MockFactory.initialState());
4361
const props = MockFactory.projectSettingsProps();
4462
const saveProjectSpy = jest.spyOn(props.projectActions, "saveProject");
@@ -47,14 +65,12 @@ describe("Project settings page", () => {
4765

4866
const wrapper = createComponent(store, props);
4967
wrapper.find("form").simulate("submit");
68+
await MockFactory.flushUi();
5069

51-
setImmediate(() => {
52-
expect(saveProjectSpy).toBeCalled();
53-
done();
54-
});
70+
expect(saveProjectSpy).toBeCalled();
5571
});
5672

57-
it("Throws an error when a user tries to create a duplicate project", async (done) => {
73+
it("Throws an error when a user tries to create a duplicate project", async () => {
5874
const project = MockFactory.createTestProject("1");
5975
project.id = "25";
6076
const initialStateOverride = {
@@ -78,18 +94,16 @@ describe("Project settings page", () => {
7894
},
7995
});
8096
wrapper.find("form").simulate("submit");
81-
setImmediate(async () => {
82-
// expect(saveProjectSpy).toBeCalled();
83-
expect(saveProjectSpy.mockRejectedValue).not.toBeNull();
84-
done();
85-
});
97+
await MockFactory.flushUi();
98+
99+
expect(saveProjectSpy.mockRejectedValue).not.toBeNull();
86100
});
87101

88-
it("calls save project when user creates a unique project", (done) => {
102+
it("calls save project when user creates a unique project", async () => {
89103
const initialState = MockFactory.initialState();
90104

91105
// New Project should not have id or security token set by default
92-
const project = {...initialState.recentProjects[0]};
106+
const project = { ...initialState.recentProjects[0] };
93107
project.id = null;
94108
project.name = "Brand New Project";
95109
project.securityToken = "";
@@ -106,20 +120,20 @@ describe("Project settings page", () => {
106120
const wrapper = createComponent(store, props);
107121
wrapper.find("form").simulate("submit");
108122

109-
setImmediate(() => {
110-
// New security token was created for new project
111-
expect(saveAppSettingsSpy).toBeCalled();
112-
const appSettings = saveAppSettingsSpy.mock.calls[0][0] as IAppSettings;
113-
expect(appSettings.securityTokens.length).toEqual(initialState.appSettings.securityTokens.length + 1);
123+
await MockFactory.flushUi();
114124

115-
// New project was saved with new security token
116-
expect(saveProjectSpy).toBeCalledWith({
117-
...project,
118-
securityToken: `${project.name} Token`,
119-
});
125+
// New security token was created for new project
126+
expect(saveAppSettingsSpy).toBeCalled();
127+
const appSettings = saveAppSettingsSpy.mock.calls[0][0] as IAppSettings;
128+
expect(appSettings.securityTokens.length).toEqual(initialState.appSettings.securityTokens.length + 1);
120129

121-
done();
130+
// New project was saved with new security token
131+
expect(saveProjectSpy).toBeCalledWith({
132+
...project,
133+
securityToken: `${project.name} Token`,
122134
});
135+
136+
expect(localStorage.removeItem).toBeCalledWith("projectForm");
123137
});
124138

125139
it("render ProjectMetrics", () => {
@@ -146,4 +160,64 @@ describe("Project settings page", () => {
146160
expect(projectMetrics).toHaveLength(0);
147161
});
148162
});
163+
164+
describe("Persisting project form", () => {
165+
let wrapper: ReactWrapper = null;
166+
167+
function initPersistProjectFormTest() {
168+
const store = createReduxStore(MockFactory.initialState());
169+
const props = MockFactory.projectSettingsProps();
170+
props.match.url = "/projects/create";
171+
wrapper = createComponent(store, props);
172+
}
173+
174+
it("Loads partial project from local storage", () => {
175+
const partialProject: IProject = {
176+
...{} as any,
177+
name: "partial project",
178+
description: "partial project description",
179+
tags: [
180+
{ name: "tag-1", color: "#ff0000" },
181+
{ name: "tag-3", color: "#ffff00" },
182+
],
183+
};
184+
185+
localStorageMock.getItem.mockImplementationOnce(() => JSON.stringify(partialProject));
186+
187+
initPersistProjectFormTest();
188+
const projectSettingsPage = wrapper
189+
.find(ProjectSettingsPage)
190+
.childAt(0) as ReactWrapper<IProjectSettingsPageProps, IProjectSettingsPageState>;
191+
192+
expect(localStorage.getItem).toBeCalledWith("projectForm");
193+
expect(projectSettingsPage.state().project).toEqual(partialProject);
194+
});
195+
196+
it("Stores partial project in local storage", () => {
197+
initPersistProjectFormTest();
198+
const partialProject: IProject = {
199+
...{} as any,
200+
name: "partial project",
201+
};
202+
203+
const projectForm = wrapper.find(ProjectForm) as ReactWrapper<IProjectFormProps>;
204+
projectForm.props().onChange(partialProject);
205+
206+
expect(localStorage.setItem).toBeCalledWith("projectForm", JSON.stringify(partialProject));
207+
});
208+
209+
it("Does NOT store empty project in local storage", () => {
210+
initPersistProjectFormTest();
211+
const emptyProject: IProject = {
212+
...{} as any,
213+
sourceConnection: {},
214+
targetConnection: {},
215+
exportFormat: {},
216+
};
217+
const projectForm = wrapper.find(ProjectForm) as ReactWrapper<IProjectFormProps>;
218+
projectForm.props().onChange(emptyProject);
219+
220+
expect(localStorage.setItem).not.toBeCalled();
221+
});
222+
});
149223
});

0 commit comments

Comments
 (0)