Skip to content
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

Implements the snippet editor in React #460

Merged
merged 53 commits into from Apr 23, 2018
Merged

Conversation

atimmer
Copy link
Contributor

@atimmer atimmer commented Apr 5, 2018

Summary

This PR can be summarized i.n the following changelog entry:

  • Add a SnippetEditor component. Renders the SnippetPreview component with editor fields to change the data.
  • Add a ReplacementVariable component. Renders a DraftJS editor with replacement variables as entities.
  • Add a FormattedScreenReaderMessage component Renders a message translated, but also as screen reader text. Ensures that ScreenReaderText can remain pure and only accept a string.

Relevant technical choices:

  • DraftJS is used for the input fields.

    • The DraftJS mention plugin is used to auto complete replacement variables.
    • % is used as a trigger.
    • Replacement variables need to be passed as key value pairs. The actual shape is abstracted in a constants.js file.
    • We serialize the DraftJS editor state to a string on every change. Because these are small input field this is feasible. If performance suffers we need to serialize less often.
    • DraftJS is abstracted to a ReplacementVariableEditor component. This expects a string and will manage the DraftJS editor state.
  • SnippetEditor is the main component.

    • It contains state that doesn't need to travel up the tree. Such as the hovered and active fields. And whether the editor is open.
    • The data travels upwards and is accepted as props.
  • The progress bars are not measured yet. @herregroen and I thought we should solve this in YoastSEO.js. Because the assessment should be able to run independently of the snippet preview. The SnippetEditor component accepts the shape of an assessment result which the progress bars are rendered based upon.

  • The default mode is mobile.

  • We've dropped the SnippetPreview example because the SnippetEditor example covers that completely.

  • Added 3 colors to colors.scss and colors.json that are used for the carets.

Also includes some quality of life changes

  • Change devtool to cheap-module-eval-source-map which make sourcemaps work.
  • Add NameModulesPlugin so you can see which modules have been hot reloaded.
  • Use .babelrc in the webpack config.
  • Add CaseSensitivePathsPlugin, this makes us aware of case sensitivity issues earlier.
  • Adds new pattern for exposing public components. Every separate part can expose its components using an index.js, these will be aggregated in the root index.js. I haven't refactored all other components.
  • Setup test coverage for all files. This means now files that have 0% coverage are also included in the coverage. This drastically reduced the coverage, but it does mean that it is more accurate.

Test instructions

This PR can be tested by following these steps:

  • Turn on the local example using yarn start.
  • Go to the Snippet preview menu item.
  • Test that the snippet editor works in the same way as the old one (currently in the plugin).

App.js Outdated
@@ -13,6 +12,7 @@ import SidebarCollapsibleWrapper from "./app/SidebarCollapsibleWrapper";
// Required to make Material UI work with touch screens.
import injectTapEventPlugin from "react-tap-event-plugin";
import Checkbox from "./composites/Plugin/Shared/components/Checkbox";
import SnippetEditor from "./app/SnippetEditorWrapper";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapper naming does not seem to be explanatory, have you considered using Example?

@@ -2,7 +2,6 @@ import React from "react";
import { IntlProvider } from "react-intl";

import SearchResultsEditor from "./composites/SearchResultEditor/SearchResultEditor";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imports should be sorted and separated by type. Currently composites and app components are mixed.

@@ -0,0 +1,35 @@
import React from "react";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internal and external dependencies should be separated. ScreenReaderText should likely be imported at the very bottom with a newline between it and the external dependencies.

* punctuation can for example be a colon.
*
* @param {Object} props The view properties.
* @param {string} props.before A piece of text to render before the translation.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearly specifying the keys this object accepts helps a lot in understanding what's going on.

*/
const FormattedScreenReaderMessage = ( props ) => {
return (
<FormattedMessage { ...omit( props, "children" ) }>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using object deconstruction as opposed to the omit function likely is a lot better for performance.

@@ -1,5 +1,8 @@
/* External dependencies */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't these be single line comments?

And do we have any rules regarding comments having to end with a ..

import { EditorState, convertToRaw, convertFromRaw } from "draft-js";
import Editor from "draft-js-plugins-editor";
import createMentionPlugin, { defaultSuggestionsFilter } from "draft-js-mention-plugin";
import { serializeEditor, unserializeEditor } from "../serialization";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splitting internal and external dependencies here would also be helpful.

serializeEditor,
] );

class ReplaceVarEditor extends React.Component {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class is missing documentation.

/**
* Constructs the replacement variable editor for use.
*
* @param {Object} props The props to instantiate this editor with.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to fully specify all the keys you are able to pass to props.

this.onChange = this.onChange.bind( this );
this.onSearchChange = this.onSearchChange.bind( this );

this.mentionsPlugin = createMentionPlugin( {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that the mentions plugin is used for replacement variables it would probably be helpful to add a comment here why we're using the mentions plugin.

import { lengthAssessmentShape, replacementVariablesShape } from "../constants";
import FormattedScreenReaderMessage from "../../../../a11y/FormattedScreenReaderMessage";

const SwitcherButton = Button.extend`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component should be documented and probably other styled components should be documented.

Copy link
Contributor

@moorscode moorscode left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review - Dev Presentation feedback

* punctuation can for example be a colon.
*
* @param {Object} props The view properties.
* @param {string} props.before A piece of text to render before the translation.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formatting of this comment can be clearer, to make sure that the sub-props are visually also indented and belong below the parent.

* @param {string} props.before A piece of text to render before the translation.
* @param {string} props.after A piece of text to render after the translation.
*
* @returns {ReactElement} ScreenReaderText The div containing the screen reader text.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScreenReaderText seems redundant here.

const FormattedScreenReaderMessage = ( props ) => {
return (
<FormattedMessage { ...omit( props, "children" ) }>
{ ( textNodes ) => <ScreenReaderText>{ [ props.before, ...textNodes, props.after ].join( "" ) }</ScreenReaderText> }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is hard to read, please consider placing the elements on separate lines.

import { serializeEditor, unserializeEditor } from "../serialization";
import flow from "lodash/flow";
import PropTypes from "prop-types";
import { replacementVariablesShape } from "../constants";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is a constant, having a visual representation would be nice. In other languages constants are normally defined as all-caps.

*
* @returns {EditorState} The editor state.
*/
const createEditorState = flow( [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flow is a magical thing, I cannot really guess what is happening here. Is there a way to make this more usable/readable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After you know what flow does, this code is more readable than its alternatives.


ReplaceVarEditor.propTypes = {
content: PropTypes.string.isRequired,
onChange: PropTypes.func,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider placing this close to the other event-props.


ReplaceVarEditor.propTypes = {
content: PropTypes.string.isRequired,
onChange: PropTypes.func,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not required, but also not supplied in the defaultProps.

replacementVariables: props.replacementVariables,
};

this._serializedContent = content;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sudden appearance of serialized is not evident from the code, could you elaborate on this in a comment or maybe choose different variable names.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestions: use raw as definition, as this is unprepared/touched.

App.js Outdated
@@ -13,6 +12,7 @@ import SidebarCollapsibleWrapper from "./app/SidebarCollapsibleWrapper";
// Required to make Material UI work with touch screens.
import injectTapEventPlugin from "react-tap-event-plugin";
import Checkbox from "./composites/Plugin/Shared/components/Checkbox";
import SnippetEditor from "./app/SnippetEditorWrapper";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move import to all the other imports that are examples.

@@ -2,7 +2,6 @@ import React from "react";
import { IntlProvider } from "react-intl";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment about examples

import ScreenReaderText from "./ScreenReaderText";
import { FormattedMessage } from "react-intl";
import omit from "lodash/omit";
import PropTypes from "prop-types";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this with a capital letter?

@@ -1,5 +1,8 @@
/* External dependencies */
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

Copy link
Member

@igorschoester igorschoester left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CR 🚧

import styled from "styled-components";
import debounce from "lodash/debounce";

// External dependencies.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be internal dependencies here.

import React from "react";
import PropTypes from "prop-types";

// External dependencies.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be internal dependencies here.

import omit from "lodash/omit";
import PropTypes from "prop-types";

// External dependencies.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be internal dependencies here.

*/
render() {
let props = Object.assign( {}, this.state );
const data = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is separate for readability? If yes, then why did you not extend this to titleLengthAssessment and descriptionLengthAssessment also?


props.onMouseOver = this.onMouseOver;
props.onMouseLeave = this.onMouseLeave;
let props = Object.assign( {}, this.state, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid confusion maybe not name this props. Reminds me of the eslint no-shadow setting we added before, that is not yet live.
Also this can be const instead of let.

* @returns {void}
*/
setFieldFocus( field ) {
field = this.mapFieldToEditor( field );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this on it's own line?

Copy link
Contributor Author

@atimmer atimmer Apr 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because I find it easier to read.

/**
* Constructs the snippet editor fields.
*
* @param {Object} props The props for the editor fields.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add propTypes descriptions?

@@ -1,5 +1,8 @@
/* External components */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why components and not dependencies like everywhere else? Also multiline comment again.

@@ -0,0 +1,288 @@
/* External components */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why components here and not dependencies?

/* Internal dependencies */
import ScreenReaderText from "../../../../a11y/ScreenReaderText";
import FixedWidthContainer from "./fixedWidthContainer";
// External dependencies.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be internal dependencies here.

@atimmer atimmer force-pushed the stories/snippet-editor branch 2 times, most recently from e9a5024 to c9bf2a8 Compare April 17, 2018 15:15
@atimmer atimmer removed their assignment Apr 18, 2018
Herre Groen and others added 11 commits April 18, 2018 16:44
This gives us the ability to persist the content in the DraftJS
editor.
This is a component that accepts replacement variables and renders a
DraftJS editor for them. This way we can use it later in other preview
editors.
This gives a view of the work that still has to be done.
This prevents duplication of configuration.
This makes sure that we don't do a lot of synchronous work.
This makes it possible to overwrite its styling from outside the
component.
@afercia
Copy link
Contributor

afercia commented Apr 23, 2018

Turns out there are a few issues, some of them because wrong implementations in dra`ft.js and the mentions plugin. Other issues can be improved in our implementation.

Draft.js and mentions plugin issues:
the mention plugins passes the aria-expanded values as strings but then draft.js uses this pattern
var ariaExpanded = ariaRole === 'combobox' ? !!this.props.ariaExpanded : null;

so regardless the passed value is the string 'false' or 'true', it gets cast to boolean true anyways. The HTML attribute on the draft field is always true.

A combobox should have both an aria-haspopup and an aria-owns attributes, however in draft.js 0.10.2 they've made some changes:

* Change `aria-owns` to `aria-controls` in draft.js. ([@jessebeach](https://github.com/jessebeach) in [7f0cab28](https://github.com/facebook/draft-js/commit/7f0cab28386ec4bde8ec6289377bff9e53cd019b))
  * Deprecates support of `ariaOwns` and `ariaOwneeID` props.
* Deprecate use of `ariaHasPopup` prop in draft.js. `ariaExpanded` should be used instead if an input is showing a dropdown with options.([@jessebeach](https://github.com/jessebeach) in [744e9b4e](https://github.com/facebook/draft-js/commit/744e9b4eb4810797a93c66591fea6f9cac533b4b))

as a consequence, aria-haspopup and an aria-owns are missing from the combobox. Note that the mention plugin still passes the related props ariaHasPopup and ariaOwneeID but they're just ignored by draft.js. Note also the mention plugin dynamically changes the value of ariaHasPopup: that's wrong, as aria-haspopup is a property, not a state, and shouldn't change.

The highlighted option in the dropdown should get an aria-selected="true" attribute, this is completely missing in the mentions plugin and in draft.js (Safari and VoiceOver won't read out the highlighted option.

==========

Issues that can be improved in our implementation:

  • the mobile/desktop buttons should use aria-pressed, see how they work in the current snippet
  • the Edit snippet button aria-expanded, see how it works in the current snippet
  • when the suggestions are shown, there should be an audible message to announce the number of results, see for example how the autocompleters work in Gutenberg
  • when clicking the "Close snippet editor", focus should be moved back to the "Edit snippet" button

@afercia
Copy link
Contributor

afercia commented Apr 23, 2018

Acceptance 🚧 Please see comment above.

@afercia afercia assigned atimmer and unassigned afercia Apr 23, 2018
@afercia
Copy link
Contributor

afercia commented Apr 23, 2018

Visually, hovering/focusing the mobile icon makes it "jump".

@afercia
Copy link
Contributor

afercia commented Apr 23, 2018

Important: as far as I know, the slug field isn't meant to accept template variables, correct? In this case, it shouldn't be announced as combobox and shouldn't have any of the HTML attributes related to autocomplete, activedescendant. etc. It should be just an ordinary input field, and exposed as such to assistive technologies.

Positioning issues:
in some edge cases, the list of suggestions might be displayed off screen, for example when the caret in the "textarea" is close to the right edge. Not sure it the mentions plugin has anything to handle the positioning:

screen shot 2018-04-23 at 16 02 10

@atimmer
Copy link
Contributor Author

atimmer commented Apr 23, 2018

Merging this. All the issues uncovered by Andrea I will create as issues to be fixed before release of the plugin.

@atimmer atimmer merged commit 3c538af into develop Apr 23, 2018
@atimmer atimmer deleted the stories/snippet-editor branch April 23, 2018 14:32
@afercia
Copy link
Contributor

afercia commented Apr 23, 2018

I forgot to mention: there are no instructions on how to use the suggestions, e.g. type % and see what happens. I'd say even sighted user would need to be instructed. I'd suggest to try the simplest thing: add a paragraph below the fields type the percentage character % for a list of suggested template variables (or something along those lines) and target the paragraph with aria-describedby on the fields.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants