Skip to content

Commit 24c7b5e

Browse files
authored
Project language changes edge-cases and localStore (#1482)
Closes: RaspberryPiFoundation/digital-editor-issues#889 This fix makes the editor reload the project when the site language changes, but only if the project has not been edited. For example, if a project has both Spanish and English versions, switching the site from Spanish to English now loads the English project code. If the user has already changed the code, the editor keeps their local work instead of replacing it with the other language version. I've found for french is not working but i think it's because of the fallback of the server. https://github.com/user-attachments/assets/0fb0ab99-2e8c-4330-906a-d885fbfacc21
1 parent 5d5d673 commit 24c7b5e

7 files changed

Lines changed: 269 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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,25 @@ 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 (!Array.isArray(currentComponents) || !Array.isArray(initialComponents)) {
21+
return false;
22+
}
23+
24+
if (currentComponents.length !== initialComponents.length) return true;
25+
26+
return currentComponents.some((component, index) =>
27+
componentHasChanged(component, initialComponents[index]),
28+
);
29+
};

src/utils/projectHelpers.test.js

Lines changed: 81 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,83 @@ 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 initial snapshot is not an array", () => {
104+
expect(
105+
projectHasChangedSinceInitialLoad(
106+
{ components: initialComponents },
107+
"not components",
108+
),
109+
).toBe(false);
110+
});
111+
112+
test("returns false when the project started with no components and still has none", () => {
113+
expect(projectHasChangedSinceInitialLoad({ components: [] }, [])).toBe(
114+
false,
115+
);
116+
});
117+
118+
test("returns true when a component has been added to an empty project", () => {
119+
expect(
120+
projectHasChangedSinceInitialLoad({ components: initialComponents }, []),
121+
).toBe(true);
122+
});
123+
124+
test("returns true when component content has changed", () => {
125+
expect(
126+
projectHasChangedSinceInitialLoad(
127+
{
128+
components: [
129+
{
130+
...initialComponents[0],
131+
content: "print('hello!')",
132+
},
133+
],
134+
},
135+
initialComponents,
136+
),
137+
).toBe(true);
138+
});
139+
140+
test("returns true when a component has been added", () => {
141+
expect(
142+
projectHasChangedSinceInitialLoad(
143+
{
144+
components: [
145+
...initialComponents,
146+
{
147+
name: "extra",
148+
extension: "py",
149+
content: "",
150+
},
151+
],
152+
},
153+
initialComponents,
154+
),
155+
).toBe(true);
156+
});
157+
});

0 commit comments

Comments
 (0)