Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/hooks/useProject.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from "react-redux";
import { syncProject, setProject } from "../redux/EditorSlice";
import { createDefaultPythonProject } from "../utils/defaultProjects";
import { useTranslation } from "react-i18next";
import { projectHasChangedSinceInitialLoad } from "../utils/projectHelpers";

export const useProject = ({
reactAppApiEndpoint = null,
Expand All @@ -29,6 +30,9 @@ export const useProject = ({
isBrowserPreview || (embedded && browserPreviewFromQuery);
const shouldSkipCache = isEmbeddedMode && !canUseBrowserPreviewCache;
const project = useSelector((state) => state.editor.project);
const initialComponents = useSelector(
(state) => state.editor.initialComponents,
);
const loadDispatched = useRef(false);

const getCachedProject = (id) =>
Expand Down Expand Up @@ -65,6 +69,19 @@ export const useProject = ({
!projectIdentifier && cachedProject && !initialProject;
const cachedProjectMatchesRequest =
isCachedSavedProject || isCachedUnsavedProject;
const currentProjectMatchesRequest = projectIdentifier
? project?.identifier === projectIdentifier
: !project?.identifier;
const currentProjectChanged = projectHasChangedSinceInitialLoad(
project,
initialComponents,
);

// If this same project has local edits, keep them across rerenders such
// as locale, access-token, or cache changes until the user saves or remixes.
if (currentProjectMatchesRequest && currentProjectChanged) {
return;
}

// Browser previews need the current local edits. Starter projects can be
// served from a fallback locale, so the cached locale may not match the URL.
Expand Down
69 changes: 69 additions & 0 deletions src/hooks/useProject.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@ describe("When not embedded", () => {
wrapper = ({ children }) => <Provider store={store}>{children}</Provider>;
});

const setCurrentProjectWithEdits = (locale = "es-LA") => {
const initialComponents = [
{
name: "main",
extension: "py",
content: "print('hola')",
},
];
initialState.editor.project = {
project_type: "python",
identifier: cachedProject.identifier,
locale,
components: [
{
...initialComponents[0],
content: "print('edited')",
},
],
};
initialState.editor.initialComponents = initialComponents;
const updatedMockStore = configureStore([]);
store = updatedMockStore(initialState);
wrapper = ({ children }) => <Provider store={store}>{children}</Provider>;
};

test("If no identifier uses default python project", () => {
renderHook(() => useProject({}), { wrapper });
return waitFor(() =>
Expand Down Expand Up @@ -163,6 +188,50 @@ describe("When not embedded", () => {
);
});

test("If current project has changed and locale changes, keeps current project", async () => {
setCurrentProjectWithEdits();
syncProject.mockImplementationOnce(jest.fn((_) => loadProject));

renderHook(
() =>
useProject({
projectIdentifier: cachedProject.identifier,
locale: "en",
accessToken,
reactAppApiEndpoint,
}),
{ wrapper },
);

expect(syncProject).not.toHaveBeenCalled();
await waitFor(() => expect(setProject).not.toHaveBeenCalled());
});

test("If current project has changed and locale changes back, keeps current project", async () => {
setCurrentProjectWithEdits();
localStorage.setItem(
cachedProject.identifier,
JSON.stringify({
...cachedProject,
locale: "es-LA",
}),
);

renderHook(
() =>
useProject({
projectIdentifier: cachedProject.identifier,
locale: "es-LA",
accessToken,
reactAppApiEndpoint,
}),
{ wrapper },
);

expect(syncProject).not.toHaveBeenCalled();
await waitFor(() => expect(setProject).not.toHaveBeenCalled());
});

test("If cached project does not match locale and browserPreview query is used outside embedded viewer, does not use cached project", () => {
syncProject.mockImplementation(jest.fn((_) => jest.fn()));
window.history.pushState(
Expand Down
23 changes: 17 additions & 6 deletions src/hooks/useProjectPersistence.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { isOwner } from "../utils/projectHelpers";
import { useDispatch, useSelector } from "react-redux";
import {
isOwner,
projectHasChangedSinceInitialLoad,
} from "../utils/projectHelpers";
import {
expireJustLoaded,
setHasShownSavePrompt,
Expand All @@ -20,6 +23,9 @@ export const useProjectPersistence = ({
loadRemix = true,
}) => {
const dispatch = useDispatch();
const initialComponents = useSelector(
(state) => state.editor.initialComponents,
);

const combinedFileSize = project.components?.reduce(
(sum, component) => sum + component.content.length,
Expand Down Expand Up @@ -87,14 +93,19 @@ export const useProjectPersistence = ({
}),
);
} else {
const projectChangedSinceInitialLoad =
projectHasChangedSinceInitialLoad(project, initialComponents);

if (justLoaded) {
dispatch(expireJustLoaded());
} else {
if (!hasShownSavePrompt) {
user ? showSavePrompt() : showLoginPrompt();
dispatch(setHasShownSavePrompt());
if (!projectChangedSinceInitialLoad) {
return;
}
}
if (!hasShownSavePrompt) {
user ? showSavePrompt() : showLoginPrompt();
dispatch(setHasShownSavePrompt());
}
saveToLocalStorage(project);
}
}
Expand Down
62 changes: 61 additions & 1 deletion src/hooks/useProjectPersistence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import {
} from "../redux/EditorSlice";
import { showLoginPrompt, showSavePrompt } from "../utils/Notifications";

let mockInitialComponents = [];

jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useDispatch: () => jest.fn(),
useSelector: (selector) =>
selector({ editor: { initialComponents: mockInitialComponents } }),
}));

jest.mock("../redux/EditorSlice", () => ({
Expand Down Expand Up @@ -56,21 +60,50 @@ const project = {
],
user_id: user1.profile.user,
};
const initialComponents = project.components.map((component) => ({
name: component.name,
extension: component.extension,
content: component.content,
}));
const editedProject = {
...project,
components: [
{
...project.components[0],
content: "# hello edited",
},
],
};

beforeEach(() => {
mockInitialComponents = initialComponents;
});

afterEach(() => {
mockInitialComponents = [];
localStorage.clear();
});

describe("When not logged in", () => {
describe("When just loaded", () => {
beforeEach(() => {
renderHook(() => useProjectPersistence({ user: null, justLoaded: true }));
renderHook(() =>
useProjectPersistence({
user: null,
project,
justLoaded: true,
}),
);
jest.runAllTimers();
});

test("Expires justLoaded", () => {
expect(expireJustLoaded).toHaveBeenCalled();
});

test("Project not saved in localStorage", () => {
expect(localStorage.getItem("hello-world-project")).toBeNull();
});
});

describe("When not just loaded", () => {
Expand Down Expand Up @@ -142,6 +175,33 @@ describe("When logged in", () => {
test("Expires justLoaded", () => {
expect(expireJustLoaded).toHaveBeenCalled();
});

test("Project not saved in localStorage", () => {
expect(localStorage.getItem("hello-world-project")).toBeNull();
});
});

describe("When just loaded and project has changed", () => {
beforeEach(() => {
renderHook(() =>
useProjectPersistence({
user: user2,
project: editedProject,
justLoaded: true,
}),
);
jest.runAllTimers();
});

test("Expires justLoaded", () => {
expect(expireJustLoaded).toHaveBeenCalled();
});

test("Project saved in localStorage", () => {
expect(localStorage.getItem("hello-world-project")).toEqual(
JSON.stringify(editedProject),
);
});
});

describe("When not just loaded", () => {
Expand Down
22 changes: 22 additions & 0 deletions src/utils/projectHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,25 @@ export const isOwner = (user, project) => {
(user.profile.user === project.user_id || !project.identifier)
);
};

const componentHasChanged = (component, initialComponent) =>
component.content !== initialComponent.content ||
component.name !== initialComponent.name ||
component.extension !== initialComponent.extension;

export const projectHasChangedSinceInitialLoad = (
project,
initialComponents = null,
) => {
const currentComponents = project?.components;

if (!Array.isArray(currentComponents) || !Array.isArray(initialComponents)) {
return false;
}

if (currentComponents.length !== initialComponents.length) return true;

return currentComponents.some((component, index) =>
componentHasChanged(component, initialComponents[index]),
);
};
Comment thread
abcampo-iry marked this conversation as resolved.
82 changes: 81 additions & 1 deletion src/utils/projectHelpers.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isOwner } from "./projectHelpers";
import { isOwner, projectHasChangedSinceInitialLoad } from "./projectHelpers";

describe("With logged in user", () => {
const user = {
Expand Down Expand Up @@ -75,3 +75,83 @@ describe("With no active user", () => {
});
});
});

describe("projectHasChangedSinceInitialLoad", () => {
const initialComponents = [
{
name: "main",
extension: "py",
content: "print('hello')",
},
];

test("returns false when components match the initial snapshot", () => {
expect(
projectHasChangedSinceInitialLoad(
{ components: initialComponents },
initialComponents,
),
).toBe(false);
});

test("returns false when the initial snapshot is missing", () => {
expect(
projectHasChangedSinceInitialLoad({ components: initialComponents }),
).toBe(false);
});

test("returns false when the initial snapshot is not an array", () => {
expect(
projectHasChangedSinceInitialLoad(
{ components: initialComponents },
"not components",
),
).toBe(false);
});

test("returns false when the project started with no components and still has none", () => {
expect(projectHasChangedSinceInitialLoad({ components: [] }, [])).toBe(
false,
);
});

test("returns true when a component has been added to an empty project", () => {
expect(
projectHasChangedSinceInitialLoad({ components: initialComponents }, []),
).toBe(true);
});

test("returns true when component content has changed", () => {
expect(
projectHasChangedSinceInitialLoad(
{
components: [
{
...initialComponents[0],
content: "print('hello!')",
},
],
},
initialComponents,
),
).toBe(true);
});

test("returns true when a component has been added", () => {
expect(
projectHasChangedSinceInitialLoad(
{
components: [
...initialComponents,
{
name: "extra",
extension: "py",
content: "",
},
],
},
initialComponents,
),
).toBe(true);
});
});
Loading
Loading