Skip to content

Commit f2a92c6

Browse files
committed
project justLoaded update
1 parent fbc4d4f commit f2a92c6

7 files changed

Lines changed: 258 additions & 22 deletions

File tree

src/hooks/useProject.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from "react-redux";
44
import { syncProject, setProject } from "../redux/EditorSlice";
55
import { createDefaultPythonProject } from "../utils/defaultProjects";
66
import { useTranslation } from "react-i18next";
7+
import { projectHasChangedSinceInitialLoad } from "../utils/projectHelpers";
78

89
export const useProject = ({
910
reactAppApiEndpoint = null,
@@ -29,6 +30,9 @@ export const useProject = ({
2930
isBrowserPreview || (embedded && browserPreviewFromQuery);
3031
const shouldSkipCache = isEmbeddedMode && !canUseBrowserPreviewCache;
3132
const project = useSelector((state) => state.editor.project);
33+
const initialComponents = useSelector(
34+
(state) => state.editor.initialComponents,
35+
);
3236
const loadDispatched = useRef(false);
3337

3438
const getCachedProject = (id) =>
@@ -65,6 +69,19 @@ export const useProject = ({
6569
!projectIdentifier && cachedProject && !initialProject;
6670
const cachedProjectMatchesRequest =
6771
isCachedSavedProject || isCachedUnsavedProject;
72+
const currentProjectMatchesRequest = projectIdentifier
73+
? project?.identifier === projectIdentifier
74+
: !project?.identifier;
75+
const currentProjectChanged = projectHasChangedSinceInitialLoad(
76+
project,
77+
initialComponents,
78+
);
79+
80+
// If this same project has local edits, keep them across rerenders such
81+
// as locale, access-token, or cache changes until the user saves or remixes.
82+
if (currentProjectMatchesRequest && currentProjectChanged) {
83+
return;
84+
}
6885

6986
// Browser previews need the current local edits. Starter projects can be
7087
// served from a fallback locale, so the cached locale may not match the URL.

src/hooks/useProject.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,31 @@ describe("When not embedded", () => {
5555
wrapper = ({ children }) => <Provider store={store}>{children}</Provider>;
5656
});
5757

58+
const setCurrentProjectWithEdits = (locale = "es-LA") => {
59+
const initialComponents = [
60+
{
61+
name: "main",
62+
extension: "py",
63+
content: "print('hola')",
64+
},
65+
];
66+
initialState.editor.project = {
67+
project_type: "python",
68+
identifier: cachedProject.identifier,
69+
locale,
70+
components: [
71+
{
72+
...initialComponents[0],
73+
content: "print('edited')",
74+
},
75+
],
76+
};
77+
initialState.editor.initialComponents = initialComponents;
78+
const updatedMockStore = configureStore([]);
79+
store = updatedMockStore(initialState);
80+
wrapper = ({ children }) => <Provider store={store}>{children}</Provider>;
81+
};
82+
5883
test("If no identifier uses default python project", () => {
5984
renderHook(() => useProject({}), { wrapper });
6085
return waitFor(() =>
@@ -163,6 +188,50 @@ describe("When not embedded", () => {
163188
);
164189
});
165190

191+
test("If current project has changed and locale changes, keeps current project", async () => {
192+
setCurrentProjectWithEdits();
193+
syncProject.mockImplementationOnce(jest.fn((_) => loadProject));
194+
195+
renderHook(
196+
() =>
197+
useProject({
198+
projectIdentifier: cachedProject.identifier,
199+
locale: "en",
200+
accessToken,
201+
reactAppApiEndpoint,
202+
}),
203+
{ wrapper },
204+
);
205+
206+
expect(syncProject).not.toHaveBeenCalled();
207+
await waitFor(() => expect(setProject).not.toHaveBeenCalled());
208+
});
209+
210+
test("If current project has changed and locale changes back, keeps current project", async () => {
211+
setCurrentProjectWithEdits();
212+
localStorage.setItem(
213+
cachedProject.identifier,
214+
JSON.stringify({
215+
...cachedProject,
216+
locale: "es-LA",
217+
}),
218+
);
219+
220+
renderHook(
221+
() =>
222+
useProject({
223+
projectIdentifier: cachedProject.identifier,
224+
locale: "es-LA",
225+
accessToken,
226+
reactAppApiEndpoint,
227+
}),
228+
{ wrapper },
229+
);
230+
231+
expect(syncProject).not.toHaveBeenCalled();
232+
await waitFor(() => expect(setProject).not.toHaveBeenCalled());
233+
});
234+
166235
test("If cached project does not match locale and browserPreview query is used outside embedded viewer, does not use cached project", () => {
167236
syncProject.mockImplementation(jest.fn((_) => jest.fn()));
168237
window.history.pushState(

src/hooks/useProjectPersistence.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { useEffect } from "react";
2-
import { useDispatch } from "react-redux";
3-
import { isOwner } from "../utils/projectHelpers";
2+
import { useDispatch, useSelector } from "react-redux";
3+
import {
4+
isOwner,
5+
projectHasChangedSinceInitialLoad,
6+
} from "../utils/projectHelpers";
47
import {
58
expireJustLoaded,
69
setHasShownSavePrompt,
@@ -20,6 +23,9 @@ export const useProjectPersistence = ({
2023
loadRemix = true,
2124
}) => {
2225
const dispatch = useDispatch();
26+
const initialComponents = useSelector(
27+
(state) => state.editor.initialComponents,
28+
);
2329

2430
const combinedFileSize = project.components?.reduce(
2531
(sum, component) => sum + component.content.length,
@@ -87,14 +93,19 @@ export const useProjectPersistence = ({
8793
}),
8894
);
8995
} else {
96+
const projectChangedSinceInitialLoad =
97+
projectHasChangedSinceInitialLoad(project, initialComponents);
98+
9099
if (justLoaded) {
91100
dispatch(expireJustLoaded());
92-
} else {
93-
if (!hasShownSavePrompt) {
94-
user ? showSavePrompt() : showLoginPrompt();
95-
dispatch(setHasShownSavePrompt());
101+
if (!projectChangedSinceInitialLoad) {
102+
return;
96103
}
97104
}
105+
if (!hasShownSavePrompt) {
106+
user ? showSavePrompt() : showLoginPrompt();
107+
dispatch(setHasShownSavePrompt());
108+
}
98109
saveToLocalStorage(project);
99110
}
100111
}

src/hooks/useProjectPersistence.test.js

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import {
77
} from "../redux/EditorSlice";
88
import { showLoginPrompt, showSavePrompt } from "../utils/Notifications";
99

10+
let mockInitialComponents = [];
11+
1012
jest.mock("react-redux", () => ({
1113
...jest.requireActual("react-redux"),
1214
useDispatch: () => jest.fn(),
15+
useSelector: (selector) =>
16+
selector({ editor: { initialComponents: mockInitialComponents } }),
1317
}));
1418

1519
jest.mock("../redux/EditorSlice", () => ({
@@ -56,21 +60,50 @@ const project = {
5660
],
5761
user_id: user1.profile.user,
5862
};
63+
const initialComponents = project.components.map((component) => ({
64+
name: component.name,
65+
extension: component.extension,
66+
content: component.content,
67+
}));
68+
const editedProject = {
69+
...project,
70+
components: [
71+
{
72+
...project.components[0],
73+
content: "# hello edited",
74+
},
75+
],
76+
};
77+
78+
beforeEach(() => {
79+
mockInitialComponents = initialComponents;
80+
});
5981

6082
afterEach(() => {
83+
mockInitialComponents = [];
6184
localStorage.clear();
6285
});
6386

6487
describe("When not logged in", () => {
6588
describe("When just loaded", () => {
6689
beforeEach(() => {
67-
renderHook(() => useProjectPersistence({ user: null, justLoaded: true }));
90+
renderHook(() =>
91+
useProjectPersistence({
92+
user: null,
93+
project,
94+
justLoaded: true,
95+
}),
96+
);
6897
jest.runAllTimers();
6998
});
7099

71100
test("Expires justLoaded", () => {
72101
expect(expireJustLoaded).toHaveBeenCalled();
73102
});
103+
104+
test("Project not saved in localStorage", () => {
105+
expect(localStorage.getItem("hello-world-project")).toBeNull();
106+
});
74107
});
75108

76109
describe("When not just loaded", () => {
@@ -142,6 +175,33 @@ describe("When logged in", () => {
142175
test("Expires justLoaded", () => {
143176
expect(expireJustLoaded).toHaveBeenCalled();
144177
});
178+
179+
test("Project not saved in localStorage", () => {
180+
expect(localStorage.getItem("hello-world-project")).toBeNull();
181+
});
182+
});
183+
184+
describe("When just loaded and project has changed", () => {
185+
beforeEach(() => {
186+
renderHook(() =>
187+
useProjectPersistence({
188+
user: user2,
189+
project: editedProject,
190+
justLoaded: true,
191+
}),
192+
);
193+
jest.runAllTimers();
194+
});
195+
196+
test("Expires justLoaded", () => {
197+
expect(expireJustLoaded).toHaveBeenCalled();
198+
});
199+
200+
test("Project saved in localStorage", () => {
201+
expect(localStorage.getItem("hello-world-project")).toEqual(
202+
JSON.stringify(editedProject),
203+
);
204+
});
145205
});
146206

147207
describe("When not just loaded", () => {

src/utils/projectHelpers.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,23 @@ export const isOwner = (user, project) => {
55
(user.profile.user === project.user_id || !project.identifier)
66
);
77
};
8+
9+
const componentHasChanged = (component, initialComponent) =>
10+
component.content !== initialComponent.content ||
11+
component.name !== initialComponent.name ||
12+
component.extension !== initialComponent.extension;
13+
14+
export const projectHasChangedSinceInitialLoad = (
15+
project,
16+
initialComponents = null,
17+
) => {
18+
const currentComponents = project?.components;
19+
20+
if (!currentComponents || initialComponents === null) return false;
21+
22+
if (currentComponents.length !== initialComponents.length) return true;
23+
24+
return currentComponents.some((component, index) =>
25+
componentHasChanged(component, initialComponents[index]),
26+
);
27+
};

src/utils/projectHelpers.test.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isOwner } from "./projectHelpers";
1+
import { isOwner, projectHasChangedSinceInitialLoad } from "./projectHelpers";
22

33
describe("With logged in user", () => {
44
const user = {
@@ -75,3 +75,74 @@ describe("With no active user", () => {
7575
});
7676
});
7777
});
78+
79+
describe("projectHasChangedSinceInitialLoad", () => {
80+
const initialComponents = [
81+
{
82+
name: "main",
83+
extension: "py",
84+
content: "print('hello')",
85+
},
86+
];
87+
88+
test("returns false when components match the initial snapshot", () => {
89+
expect(
90+
projectHasChangedSinceInitialLoad(
91+
{ components: initialComponents },
92+
initialComponents,
93+
),
94+
).toBe(false);
95+
});
96+
97+
test("returns false when the initial snapshot is missing", () => {
98+
expect(
99+
projectHasChangedSinceInitialLoad({ components: initialComponents }),
100+
).toBe(false);
101+
});
102+
103+
test("returns false when the project started with no components and still has none", () => {
104+
expect(projectHasChangedSinceInitialLoad({ components: [] }, [])).toBe(
105+
false,
106+
);
107+
});
108+
109+
test("returns true when a component has been added to an empty project", () => {
110+
expect(
111+
projectHasChangedSinceInitialLoad({ components: initialComponents }, []),
112+
).toBe(true);
113+
});
114+
115+
test("returns true when component content has changed", () => {
116+
expect(
117+
projectHasChangedSinceInitialLoad(
118+
{
119+
components: [
120+
{
121+
...initialComponents[0],
122+
content: "print('hello!')",
123+
},
124+
],
125+
},
126+
initialComponents,
127+
),
128+
).toBe(true);
129+
});
130+
131+
test("returns true when a component has been added", () => {
132+
expect(
133+
projectHasChangedSinceInitialLoad(
134+
{
135+
components: [
136+
...initialComponents,
137+
{
138+
name: "extra",
139+
extension: "py",
140+
content: "",
141+
},
142+
],
143+
},
144+
initialComponents,
145+
),
146+
).toBe(true);
147+
});
148+
});

0 commit comments

Comments
 (0)