-
Notifications
You must be signed in to change notification settings - Fork 364
fix(LEMS-3701): update editorPage to sync json state #3196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8508147
370cad9
5670acd
e04145f
96f8b37
1eed041
a4ef692
81b7ea4
6d54585
38b069f
6a22054
88ba123
4a35cbb
2bea3d6
f294d59
ac23e42
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@khanacademy/perseus-editor": patch | ||
| --- | ||
|
|
||
| JsonEditor: Replaced deprecated lifecycle method with componentDidUpdate to sync internal state when props change | ||
| EditorPage: Added getSnapshotBeforeUpdate to capture editor state before switching to JSON mode |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| import {render, screen} from "@testing-library/react"; | ||
| import {userEvent as userEventLib} from "@testing-library/user-event"; | ||
| import * as React from "react"; | ||
|
|
||
| import JsonEditor from "./json-editor"; | ||
|
|
||
| import type {UserEvent} from "@testing-library/user-event"; | ||
|
|
||
| describe("JsonEditor", () => { | ||
| let userEvent: UserEvent; | ||
| beforeEach(() => { | ||
| userEvent = userEventLib.setup({ | ||
| advanceTimers: jest.advanceTimersByTime, | ||
| }); | ||
| }); | ||
|
|
||
| it("should render with initial value", () => { | ||
| // Arrange | ||
| const initialValue = { | ||
| content: "Test content", | ||
| widgets: {}, | ||
| }; | ||
|
|
||
| // Act | ||
| render( | ||
| <JsonEditor | ||
| multiLine={true} | ||
| value={initialValue} | ||
| onChange={() => {}} | ||
| editingDisabled={false} | ||
| />, | ||
| ); | ||
|
|
||
| // Assert | ||
| expect(screen.getByDisplayValue(/Test content/)).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("should update when value prop changes", () => { | ||
| // Arrange | ||
| const initialValue = { | ||
| content: "Initial content", | ||
| widgets: {}, | ||
| }; | ||
|
|
||
| const updatedValue = { | ||
| content: "Updated content", | ||
| widgets: {}, | ||
| }; | ||
|
|
||
| const {rerender} = render( | ||
| <JsonEditor | ||
| multiLine={true} | ||
| value={initialValue} | ||
| onChange={() => {}} | ||
| editingDisabled={false} | ||
| />, | ||
| ); | ||
|
|
||
| // Act | ||
| rerender( | ||
| <JsonEditor | ||
| multiLine={true} | ||
| value={updatedValue} | ||
| onChange={() => {}} | ||
| editingDisabled={false} | ||
| />, | ||
| ); | ||
|
|
||
| // Assert | ||
| expect( | ||
| screen.queryByDisplayValue(/Initial content/), | ||
| ).not.toBeInTheDocument(); | ||
| expect(screen.getByDisplayValue(/Updated content/)).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("should call onChange when valid JSON is entered", async () => { | ||
| // Arrange | ||
| const onChangeMock = jest.fn(); | ||
| const initialValue = {content: "test"}; | ||
|
|
||
| render( | ||
| <JsonEditor | ||
| multiLine={true} | ||
| value={initialValue} | ||
| onChange={onChangeMock} | ||
| editingDisabled={false} | ||
| />, | ||
| ); | ||
|
|
||
| const textarea = screen.getByRole("textbox"); | ||
|
|
||
| // Act | ||
| await userEvent.clear(textarea); | ||
| textarea.focus(); | ||
| await userEvent.paste('{"content": "new content"}'); | ||
|
|
||
| // Assert | ||
| expect(onChangeMock).toHaveBeenCalledWith({content: "new content"}); | ||
| }); | ||
|
|
||
| it("should not call onChange for invalid JSON", async () => { | ||
| // Arrange | ||
| const onChangeMock = jest.fn(); | ||
| const initialValue = {content: "test"}; | ||
|
|
||
| render( | ||
| <JsonEditor | ||
| multiLine={true} | ||
| value={initialValue} | ||
| onChange={onChangeMock} | ||
| editingDisabled={false} | ||
| />, | ||
| ); | ||
|
|
||
| const textarea = screen.getByRole("textbox"); | ||
|
|
||
| // Act | ||
| await userEvent.clear(textarea); | ||
| textarea.focus(); | ||
| await userEvent.paste("{invalid json"); | ||
|
|
||
| // Assert | ||
| expect(onChangeMock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("should be disabled when editingDisabled is true", () => { | ||
| // Arrange | ||
| const initialValue = {content: "test"}; | ||
|
|
||
| // Act | ||
| render( | ||
| <JsonEditor | ||
| multiLine={true} | ||
| value={initialValue} | ||
| onChange={() => {}} | ||
| editingDisabled={true} | ||
| />, | ||
| ); | ||
|
|
||
| // Assert | ||
| expect(screen.getByRole("textbox")).toBeDisabled(); | ||
| }); | ||
|
|
||
| it("should replace valid user input when parent updates value", async () => { | ||
| // Arrange | ||
| const initialValue = {content: "Initial"}; | ||
| const updatedValue = {content: "Updated from parent"}; | ||
|
|
||
| const {rerender} = render( | ||
| <JsonEditor | ||
| multiLine={true} | ||
| value={initialValue} | ||
| onChange={() => {}} | ||
| editingDisabled={false} | ||
| />, | ||
| ); | ||
|
|
||
| const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; | ||
|
|
||
| // Act | ||
| await userEvent.clear(textarea); | ||
| textarea.focus(); | ||
| await userEvent.paste('{"content": "Valid user input"}'); | ||
|
|
||
| rerender( | ||
| <JsonEditor | ||
| multiLine={true} | ||
| value={updatedValue} | ||
| onChange={() => {}} | ||
| editingDisabled={false} | ||
| />, | ||
| ); | ||
|
|
||
| // Assert | ||
| expect(textarea.value).toContain("Updated from parent"); | ||
| }); | ||
|
|
||
| it("should replace invalid user input when parent updates value", async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should there be a test for valid user input when the parent updates the value? |
||
| // Arrange | ||
| const initialValue = {content: "Initial"}; | ||
| const updatedValue = {content: "Updated from parent"}; | ||
|
|
||
| const {rerender} = render( | ||
| <JsonEditor | ||
| multiLine={true} | ||
| value={initialValue} | ||
| onChange={() => {}} | ||
| editingDisabled={false} | ||
| />, | ||
| ); | ||
|
|
||
| const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; | ||
|
|
||
| // Act | ||
| await userEvent.clear(textarea); | ||
| textarea.focus(); | ||
| await userEvent.paste('{"content": "User is typing'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am missing something - how is this invalid user input?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's incomplete, its missing the |
||
|
|
||
| rerender( | ||
| <JsonEditor | ||
| multiLine={true} | ||
| value={updatedValue} | ||
| onChange={() => {}} | ||
| editingDisabled={false} | ||
| />, | ||
| ); | ||
|
|
||
| // Assert | ||
| expect(textarea.value).toContain("Updated from parent"); | ||
| }); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can remove code in line 2, if UNSAFE tag is removed
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wish ESLint would tell us if there's "disable" statements that are unused. 🤷♂️ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,4 @@ | ||
| /* eslint-disable @typescript-eslint/no-invalid-this */ | ||
| /* eslint-disable react/no-unsafe */ | ||
| import * as React from "react"; | ||
| import _ from "underscore"; | ||
|
|
||
|
|
@@ -42,18 +41,28 @@ class JsonEditor extends React.Component<Props, State> { | |
| }; | ||
| } | ||
|
|
||
| UNSAFE_componentWillReceiveProps(nextProps) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you know why this was marked UNSAFE?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update, I asked Claude about this:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. " 😓 |
||
| const shouldReplaceContent = | ||
| !this.state.valid || | ||
| !_.isEqual( | ||
| nextProps.value, | ||
| JSON.parse( | ||
| this.state.currentValue ? this.state.currentValue : "", | ||
| ), | ||
| ); | ||
| componentDidUpdate(prevProps: Props) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given that this function is used to update state, could we just use the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for the suggestion! With |
||
| if (!_.isEqual(prevProps.value, this.props.value)) { | ||
| const shouldReplaceContent = | ||
| !this.state.valid || | ||
| !_.isEqual(this.props.value, this.getCurrentValueAsJson()); | ||
|
|
||
| if (shouldReplaceContent) { | ||
| this.setState({ | ||
| currentValue: JSON.stringify(this.props.value, null, 4), | ||
| valid: true, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This reads much better! Thank you!! |
||
|
|
||
| if (shouldReplaceContent) { | ||
| this.setState(this.getInitialState()); | ||
| getCurrentValueAsJson() { | ||
| try { | ||
| return this.state.currentValue | ||
| ? JSON.parse(this.state.currentValue) | ||
| : {}; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -122,7 +122,21 @@ class EditorPage extends React.Component<Props, State> { | |
| this.updateRenderer(); | ||
| } | ||
|
|
||
| componentDidUpdate() { | ||
| getSnapshotBeforeUpdate(prevProps: Props, prevState: State) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TIL: Cool, I hadn't seen this React function before. |
||
| if (!prevProps.jsonMode && this.props.jsonMode) { | ||
| return { | ||
| ...(this.itemEditor.current?.serialize({ | ||
| keepDeletedWidgets: true, | ||
| }) ?? {}), | ||
| hints: this.hintsEditor.current?.serialize({ | ||
| keepDeletedWidgets: true, | ||
| }), | ||
| }; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| componentDidUpdate(previousProps: Props, prevState: State, snapshot: any) { | ||
| // NOTE: It is required to delay the preview update until after the | ||
| // current frame, to allow for ItemEditor to render its widgets. | ||
| // This then enables to serialize the widgets properties correctly, | ||
|
|
@@ -133,12 +147,47 @@ class EditorPage extends React.Component<Props, State> { | |
| setTimeout(() => { | ||
| this.updateRenderer(); | ||
| }); | ||
|
|
||
| // Use serialized snapshot from before unmount | ||
| if (snapshot) { | ||
| this.setState({json: snapshot}); | ||
| return; | ||
| } | ||
|
|
||
| if ( | ||
| !_.isEqual(previousProps.question, this.props.question) || | ||
| !_.isEqual(previousProps.answerArea, this.props.answerArea) || | ||
| !_.isEqual(previousProps.hints, this.props.hints) | ||
| ) { | ||
| this.syncJsonStateFromProps(); | ||
| } | ||
| } | ||
|
|
||
| componentWillUnmount() { | ||
| this._isMounted = false; | ||
| } | ||
|
|
||
| /** | ||
| * Updates JSON state when props change from the parent. | ||
| * | ||
| * `state.json` is initialized once in the constructor. If the | ||
| * Frontend sends fresh data while the editor is already mounted, | ||
| * we need to update state.json to reflect those changes. | ||
| */ | ||
| syncJsonStateFromProps() { | ||
| if (!this.props.question) { | ||
| return; | ||
| } | ||
|
|
||
| this.setState({ | ||
| json: { | ||
| question: this.props.question, | ||
| answerArea: this.props.answerArea, | ||
| hints: this.props.hints as Hint[], | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| toggleJsonMode: () => void = () => { | ||
| this.setState( | ||
| { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we also assert that "Initial content" is NOT in the document?