Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ The host page is able to communicate with the web component via custom methods p
- `runCode`: triggers a code run in the editor
- `rerunCode`: stops the current code run and starts another code run in the editor
- `stopCode`: stops the current code run
- `codeChangedSinceInitialLoad`: getter that returns whether the current project files differ from the initial load by file count, name, extension, or content.

This allows the host page to query the current code in the editor and to control code runs from outside the web component, for example.

Expand Down
18 changes: 18 additions & 0 deletions src/redux/EditorSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const editorInitialState = {
modals: {},
errorDetails: {},
runnerBeingLoaded: null | "pyodide" | "skulpt",
initialComponents: [],
};
Comment thread
cocomarine marked this conversation as resolved.

export const EditorSlice = createSlice({
Expand Down Expand Up @@ -201,6 +202,11 @@ export const EditorSlice = createSlice({
},
setProject: (state, action) => {
state.project = action.payload;
state.initialComponents = (action.payload.components || []).map((c) => ({
name: c.name,
extension: c.extension,
content: c.content,
}));
if (!state.project.image_list) {
state.project.image_list = [];
}
Expand Down Expand Up @@ -379,6 +385,11 @@ export const EditorSlice = createSlice({
state.project = action.payload.project;
state.loading = "idle";
}
state.initialComponents = (state.project.components || []).map((c) => ({
name: c.name,
extension: c.extension,
content: c.content,
}));
});
builder.addCase("editor/saveProject/rejected", (state) => {
state.saving = "failed";
Expand All @@ -393,6 +404,13 @@ export const EditorSlice = createSlice({
state.saving = "success";
state.project = action.payload.project;
state.loading = "idle";
state.initialComponents = (action.payload.project.components || []).map(
(c) => ({
name: c.name,
extension: c.extension,
content: c.content,
}),
);
});
builder.addCase("editor/loadRemixProject/pending", loadProjectPending);
builder.addCase("editor/loadRemixProject/fulfilled", (state, action) => {
Expand Down
171 changes: 171 additions & 0 deletions src/redux/EditorSlice.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import reducer, {
addProjectComponent,
updateProjectComponent,
setCascadeUpdate,
setProject,
} from "./EditorSlice";

const mockCreateRemix = jest.fn();
Expand Down Expand Up @@ -296,6 +297,13 @@ describe("When project has no identifier", () => {
saving: "success",
lastSavedTime: 1669808953,
loading: "idle",
initialComponents: [
{
name: "main",
extension: "py",
content: "# hello",
},
],
};

expect(
Expand Down Expand Up @@ -386,6 +394,13 @@ describe("When project has an identifier", () => {
const expectedState = {
project: project,
saving: "success",
initialComponents: [
{
name: "main",
extension: "py",
content: "# hello",
},
],
};

expect(
Expand Down Expand Up @@ -422,6 +437,13 @@ describe("When project has an identifier", () => {
saving: "success",
loading: "idle",
lastSaveAutosave: false,
initialComponents: [
{
name: "main",
extension: "py",
content: "# hello",
},
],
};

expect(
Expand Down Expand Up @@ -692,3 +714,152 @@ describe("Loading a project", () => {
});
});
});

describe("initialComponents snapshot", () => {
const project = {
project_type: "python",
components: [{ name: "main", extension: "py", content: "print('hello')" }],
};

test("setProject captures the snapshot", () => {
const state = reducer(undefined, setProject(project));
expect(state.initialComponents).toEqual([
{
name: "main",
extension: "py",
content: "print('hello')",
},
]);
});

test("setProject captures multiple components", () => {
const multiComponentProject = {
project_type: "python",
components: [
{ name: "main", extension: "py", content: "# first" },
{ name: "utils", extension: "py", content: "# second" },
],
};
const state = reducer(undefined, setProject(multiComponentProject));
expect(state.initialComponents).toEqual([
{
name: "main",
extension: "py",
content: "# first",
},
{
name: "utils",
extension: "py",
content: "# second",
},
]);
});

test("updateProjectComponent does not change the initial snapshot", () => {
const state = reducer(undefined, setProject(project));
const edited = reducer(
state,
updateProjectComponent({
name: "main",
extension: "py",
content: "print('edited')",
cascadeUpdate: false,
}),
);
expect(edited.initialComponents).toEqual([
{
name: "main",
extension: "py",
content: "print('hello')",
},
]);
expect(edited.project.components[0].content).toBe("print('edited')");
});

test("setProject resets snapshot when loading a new project", () => {
let state = reducer(undefined, setProject(project));
state = reducer(
state,
updateProjectComponent({
name: "main",
extension: "py",
content: "print('edited')",
cascadeUpdate: false,
}),
);

const newProject = {
project_type: "python",
components: [
{ name: "main", extension: "py", content: "print('new project')" },
],
};
state = reducer(state, setProject(newProject));
expect(state.initialComponents).toEqual([
{
name: "main",
extension: "py",
content: "print('new project')",
},
]);
});

test("loadProject/fulfilled captures initial components", () => {
const loadThunk = syncProject("load");
const pendingState = reducer(
undefined,
loadThunk.pending("req1", {}, undefined, { requestId: "req1" }),
);
const fulfilledAction = loadThunk.fulfilled({ project }, "req1");
const fulfilledState = reducer(
{ ...pendingState, loading: "pending", currentLoadingRequestId: "req1" },
fulfilledAction,
);
expect(fulfilledState.initialComponents).toEqual([
{
name: "main",
extension: "py",
content: "print('hello')",
},
]);
});

test("saveProject/fulfilled updates initialComponents", () => {
const saveThunk = syncProject("save");
const stateWithProject = reducer(undefined, setProject(project));
const stateAfterSave = reducer(
stateWithProject,
saveThunk.fulfilled({ project }),
);
expect(stateAfterSave.initialComponents).toEqual([
{
name: "main",
extension: "py",
content: "print('hello')",
},
]);
});

test("remixProject/fulfilled updates initialComponents", () => {
const remixThunk = syncProject("remix");
const remixedProject = {
...project,
identifier: "remixed-id",
components: [
{ name: "main", extension: "py", content: "print('remixed')" },
],
};
const stateWithProject = reducer(undefined, setProject(project));
const stateAfterRemix = reducer(
stateWithProject,
remixThunk.fulfilled({ project: remixedProject }),
);
expect(stateAfterRemix.initialComponents).toEqual([
{
name: "main",
extension: "py",
content: "print('remixed')",
},
]);
});
});
7 changes: 7 additions & 0 deletions src/redux/reducers/loadProjectReducers.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ const loadProjectFulfilled = (state, action) => {
state.currentLoadingRequestId === action.meta.requestId
) {
state.project = action.payload.project;
state.initialComponents = (action.payload.project.components || []).map(
(c) => ({
name: c.name,
extension: c.extension,
content: c.content,
}),
);
state.loading = "success";
Comment thread
cocomarine marked this conversation as resolved.
state.justLoaded = true;
state.saving = "idle";
Expand Down
26 changes: 25 additions & 1 deletion src/redux/reducers/loadProjectReducers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const requestingAProject = function (project, projectFile) {
openFiles: [[]],
loading: "pending",
currentLoadingRequestId: "my_request_id",
initialComponents: [],
};
const expectedState = {
openFiles: [[projectFile]],
Expand All @@ -63,6 +64,7 @@ const requestingAProject = function (project, projectFile) {
saving: "idle",
project: project,
currentLoadingRequestId: undefined,
initialComponents: project.components,
};
expect(reducer(initialState, loadFulfilledAction)).toEqual(expectedState);
});
Expand Down Expand Up @@ -122,7 +124,7 @@ describe("When requesting a python project", () => {
{
name: "main",
extension: "py",
content: "# hello",
content: "# hello world",
},
],
image_list: [],
Expand All @@ -147,6 +149,28 @@ describe("When requesting a HTML project", () => {
requestingAProject(project, "index.html");
});

describe("When requesting a project with multiple components", () => {
const project = {
name: "hello world with multiple components",
project_type: "python",
identifier: "my-project-identifier",
components: [
{
name: "main",
extension: "py",
content: "# hello world",
},
{
name: "utils",
extension: "py",
content: "# some utils",
},
],
image_list: [],
};
requestingAProject(project, "main.py");
});

describe("EditorSliceReducers::loadProjectRejectedReducer", () => {
let action;
let initialState;
Expand Down
18 changes: 18 additions & 0 deletions src/web-component.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ class WebComponent extends HTMLElement {
return state.editor.project.components[0]?.content;
}

get codeChangedSinceInitialLoad() {
const { project, initialComponents } = store.getState().editor;
const current = project?.components;

if (!current || initialComponents.length === 0) return false;

// If the number of components is different, consider it changed
if (current.length !== initialComponents.length) return true;

// If component file contents, names or extensions are different, consider it changed
return current.some(
(component, i) =>
component.content !== initialComponents[i].content ||
component.name !== initialComponents[i].name ||
component.extension !== initialComponents[i].extension,
);
}

get menuItems() {
return this.componentProperties.menuItems;
}
Expand Down