Slate: Pattern for nested block components with async data

Created on 3 Mar 2017  Â·  6Comments  Â·  Source: ianstormtaylor/slate

Hi @ianstormtaylor! This is not as much an issue as a question around recommended pattern.

Problem:

TL;DR: So, l have a use-case of a link, which when entered turns into an embed with the following nested components which are also nested (hence can't use isVoid) . The data for those nested components is fetched in an async call.

<LinkEmbed>
  <EditableTitle />
  <EditableDescription />
</LinkEmbed>

So, the way I was set up originally was having a component for <LinkEmbed> which would accept a url prop. And in componentDidMount, it would make an XHR call to pre-fill the title and description. Once the call returns, it would pass the title and children to its respective components.


Possible Solutions:

In Slate world, when I detect a url, I see two ways, second of which is not possible today.

  1. setBlock to LinkEmbed, and insertBlock the EditableTitle and EditableDescription as well. The possible problem here would be to insert the result of the XHR call back in the components easily? I could theoretically pass the key of the component and onSuccess of the XHR, update the data attribute of the block.
  1. Just setBlock to LinkEmbed. Within the LinkEmbed, be able to spread the relevant properties to each of the children.
<LinkEmbed {...attributes}>
  <EditableTitle {...children.get('EditableTitle')} />
  <EditableDescription {...children.get('EditableDescription')} />
</LinkEmbed>

The problem in the second solution is that there is no way to specify what block types should always comprise LinkEmbed.

So, after this long story, I guess I am wondering if you have some guidance or pattern that you'd recommend or has worked for you in such cases. Is there a third way possible that I missed?

Thank you!

question

Most helpful comment

Hey @oyeanuj I'm not totally sure since I haven't done something like this before, but it seems like there are a few different considerations...

Schema

I'd probably use schema.rules to perform normalizations so that you ensure that a LinkEmbed block always contains a EditableTitle and an EditableDescription. (I do something similar with my own Figure, Image, and FigureCaption blocks.)

Here's an internal validation plugin I wrote for my own use case (probably has bugs) to give you a sense for what that might look like:

  const validations = {
    schema: {
      rules: [
        {
          match: (object) => {
            return (
              (object.kind == 'block' || object.kind == 'document') &&
              (object.type != FIGURE_BLOCK && object.type != EMBED_BLOCK && object.type != IMAGE_BLOCK)
            )
          },
          validate: (block) => {
            const invalids = block.nodes.filter((n) => {
              return n.type == EMBED_BLOCK || n.type == IMAGE_BLOCK
            })
            return invalids.size ? invalids : null
          },
          normalize: (transform, block, invalids) => {
            invalids.forEach(n => transform.wrapBlockByKey(n.key, FIGURE_BLOCK))
          }
        },
        {
          match: (object) => {
            return object.kind == 'block' && object.type != FIGURE_BLOCK
          },
          validate: (block) => {
            const invalids = block.nodes.filter((n) => n.type == FIGURE_CAPTION_BLOCK)
            return invalids.size ? invalids : null
          },
          normalize: (transform, block, invalids) => {
            invalids.forEach(n => transform.setNodeByKey(n.key, DEFAULT_BLOCK))
          }
        },
        {
          match: isFigureCaptionBlock,
          validate: (block) => {
            const invalids = block.nodes.filter(n => n.kind == 'block')
            return invalids.size ? invalids : null
          },
          normalize: (transform, block, invalids) => {
            invalids.forEach(n => transform.removeNodeByKey(n.key))
          }
        },
        {
          match: isFigureBlock,
          validate: (block) => {
            const first = block.nodes.first()
            if (first.kind != 'block') return true
            if (first.type != EMBED_BLOCK && first.type != IMAGE_BLOCK) return true
            return false
          },
          normalize: (transform, block) => {
            transform.removeNodeByKey(block.key)
          }
        },
        {
          match: isFigureBlock,
          validate: (block) => {
            const invalids = block.nodes.filter((n) => {
              return (
                n.type != FIGURE_CAPTION_BLOCK &&
                n.type != EMBED_BLOCK &&
                n.type != IMAGE_BLOCK
              )
            })

            return invalids.size ? invalids : null
          },
          normalize: (transform, block, invalids) => {
            invalids.forEach(n => transform.unwrapBlockByKey(n.key))
          }
        }
      ]
    }
  }

Emitters/Signals

It sounds like you could actually use event emitters to handle the loading case, such that when a link loads it emits an event, all of the associated blocks will setState when they receive that event. (They would bind and unbind in componentDidMount and componentWillUnmount I believe.)

I've been experimenting with using signals in a few of my components so far and it has been nice in terms of not having to worry about everything being handled in a single location.

That said, I also use the "fetch on construct" pattern too.

Here's an example of a component that emits on hover, and then another that listens and does stuff:

  const Hovering = new Signal()
  const Inserting = new Signal()
  const Inspecting = new Signal()

  class LinkInline extends React.Component {

    onMouseEnter = () => {
      const { node, editor } = this.props
      const state = editor.getState()
      if (state.isExpanded) return
      Hovering.dispatch(node)
    }

    onMouseLeave = () => {
      Hovering.dispatch(null)
    }

    render = () => {
      const { attributes, children, node } = this.props
      const href = node.data.get(LINK_INLINE_URL_KEY)
      return (
        <a
          {...attributes}
          href={href}
          target="_blank"
          rel="noreferrer nofollow noopener"
          onMouseEnter={this.onMouseEnter}
          onMouseLeave={this.onMouseLeave}
        >
          {children}
        </a>
      )
    }

  }

  class LinksInspectMenu extends React.Component {

    static propTypes = {
      editor: React.PropTypes.object.isRequired,
      state: React.PropTypes.object.isRequired,
    }

    state = {
      url: '',
      node: null,
      hovering: false,
    }

    componentWillMount = () => {
      Hovering.add(this.onHovering)
      Inspecting.add(this.onInspecting)
    }

    componentWillUnmount = () => {
      Hovering.remove(this.onHovering)
      Inspecting.remove(this.onInspecting)
    }

    onMouseEnter = () => {
      this.setState({ hovering: true })
    }

    onMouseLeave = () => {
      this.close()
    }

    onHovering = debounce((node) => {
      if (!node && this.state.hovering) return
      Inspecting.dispatch(node)
    }, INSPECT_DELAY)

    onInspecting = (node) => {
      if (!this.state.node && node) {
        const url = node.data.get(LINK_INLINE_URL_KEY)
        this.setState({ url })
      }

      this.setState({ node })
    }

    onSubmit = (e) => {
      const { editor, state } = this.props
      const { node, url } = this.state
      const next = state
        .transform()
        .call(setLinkInlineByKey, node.key, url)
        .collapseToEndOf(node)
        .apply()
      e.preventDefault()
      this.close()
      editor.onChange(next)
    }

    onDelete = (e) => {
      const { editor, state } = this.props
      const { node } = this.state
      const next = state
        .transform()
        .unwrapInlineByKey(node.key)
        .apply()
      e.preventDefault()
      this.close()
      editor.onChange(next)
    }

    onCancel = (e) => {
      this.close()
    }

    close = () => {
      Inspecting.dispatch(null)
    }

    render = () => {
      const { url, node } = this.state
      return node && (
        <SlateNodePortal
          node={node}
          portalAnchor="bottom center"
          nodeAnchor="top center"
        >
          <InspectMenu
            onMouseEnter={this.onMouseEnter}
            onMouseLeave={this.onMouseLeave}
          >
            <InspectMenuInput
              placeholder="Enter a new URL…"
              type="url"
              defaultValue={url}
              onChange={e => this.setState({ url: e.target.value })}
              onBlur={this.onCancel}
              onEscape={this.onCancel}
              onEnter={this.onSubmit}
            />
            <MenuLeftButton empty primary>
              <Link
                to={url}
                target="_blank"
                rel="noreferrer nofollow noopener"
              >
                <Icon>launch</Icon>
              </Link>
            </MenuLeftButton>
            <MenuRightButton flushLeft empty danger onClick={this.onDelete}>
              <Icon>close</Icon>
            </MenuRightButton>
          </InspectMenu>
        </SlateNodePortal>
      )
    }

  }

All 6 comments

Hey @oyeanuj I'm not totally sure since I haven't done something like this before, but it seems like there are a few different considerations...

Schema

I'd probably use schema.rules to perform normalizations so that you ensure that a LinkEmbed block always contains a EditableTitle and an EditableDescription. (I do something similar with my own Figure, Image, and FigureCaption blocks.)

Here's an internal validation plugin I wrote for my own use case (probably has bugs) to give you a sense for what that might look like:

  const validations = {
    schema: {
      rules: [
        {
          match: (object) => {
            return (
              (object.kind == 'block' || object.kind == 'document') &&
              (object.type != FIGURE_BLOCK && object.type != EMBED_BLOCK && object.type != IMAGE_BLOCK)
            )
          },
          validate: (block) => {
            const invalids = block.nodes.filter((n) => {
              return n.type == EMBED_BLOCK || n.type == IMAGE_BLOCK
            })
            return invalids.size ? invalids : null
          },
          normalize: (transform, block, invalids) => {
            invalids.forEach(n => transform.wrapBlockByKey(n.key, FIGURE_BLOCK))
          }
        },
        {
          match: (object) => {
            return object.kind == 'block' && object.type != FIGURE_BLOCK
          },
          validate: (block) => {
            const invalids = block.nodes.filter((n) => n.type == FIGURE_CAPTION_BLOCK)
            return invalids.size ? invalids : null
          },
          normalize: (transform, block, invalids) => {
            invalids.forEach(n => transform.setNodeByKey(n.key, DEFAULT_BLOCK))
          }
        },
        {
          match: isFigureCaptionBlock,
          validate: (block) => {
            const invalids = block.nodes.filter(n => n.kind == 'block')
            return invalids.size ? invalids : null
          },
          normalize: (transform, block, invalids) => {
            invalids.forEach(n => transform.removeNodeByKey(n.key))
          }
        },
        {
          match: isFigureBlock,
          validate: (block) => {
            const first = block.nodes.first()
            if (first.kind != 'block') return true
            if (first.type != EMBED_BLOCK && first.type != IMAGE_BLOCK) return true
            return false
          },
          normalize: (transform, block) => {
            transform.removeNodeByKey(block.key)
          }
        },
        {
          match: isFigureBlock,
          validate: (block) => {
            const invalids = block.nodes.filter((n) => {
              return (
                n.type != FIGURE_CAPTION_BLOCK &&
                n.type != EMBED_BLOCK &&
                n.type != IMAGE_BLOCK
              )
            })

            return invalids.size ? invalids : null
          },
          normalize: (transform, block, invalids) => {
            invalids.forEach(n => transform.unwrapBlockByKey(n.key))
          }
        }
      ]
    }
  }

Emitters/Signals

It sounds like you could actually use event emitters to handle the loading case, such that when a link loads it emits an event, all of the associated blocks will setState when they receive that event. (They would bind and unbind in componentDidMount and componentWillUnmount I believe.)

I've been experimenting with using signals in a few of my components so far and it has been nice in terms of not having to worry about everything being handled in a single location.

That said, I also use the "fetch on construct" pattern too.

Here's an example of a component that emits on hover, and then another that listens and does stuff:

  const Hovering = new Signal()
  const Inserting = new Signal()
  const Inspecting = new Signal()

  class LinkInline extends React.Component {

    onMouseEnter = () => {
      const { node, editor } = this.props
      const state = editor.getState()
      if (state.isExpanded) return
      Hovering.dispatch(node)
    }

    onMouseLeave = () => {
      Hovering.dispatch(null)
    }

    render = () => {
      const { attributes, children, node } = this.props
      const href = node.data.get(LINK_INLINE_URL_KEY)
      return (
        <a
          {...attributes}
          href={href}
          target="_blank"
          rel="noreferrer nofollow noopener"
          onMouseEnter={this.onMouseEnter}
          onMouseLeave={this.onMouseLeave}
        >
          {children}
        </a>
      )
    }

  }

  class LinksInspectMenu extends React.Component {

    static propTypes = {
      editor: React.PropTypes.object.isRequired,
      state: React.PropTypes.object.isRequired,
    }

    state = {
      url: '',
      node: null,
      hovering: false,
    }

    componentWillMount = () => {
      Hovering.add(this.onHovering)
      Inspecting.add(this.onInspecting)
    }

    componentWillUnmount = () => {
      Hovering.remove(this.onHovering)
      Inspecting.remove(this.onInspecting)
    }

    onMouseEnter = () => {
      this.setState({ hovering: true })
    }

    onMouseLeave = () => {
      this.close()
    }

    onHovering = debounce((node) => {
      if (!node && this.state.hovering) return
      Inspecting.dispatch(node)
    }, INSPECT_DELAY)

    onInspecting = (node) => {
      if (!this.state.node && node) {
        const url = node.data.get(LINK_INLINE_URL_KEY)
        this.setState({ url })
      }

      this.setState({ node })
    }

    onSubmit = (e) => {
      const { editor, state } = this.props
      const { node, url } = this.state
      const next = state
        .transform()
        .call(setLinkInlineByKey, node.key, url)
        .collapseToEndOf(node)
        .apply()
      e.preventDefault()
      this.close()
      editor.onChange(next)
    }

    onDelete = (e) => {
      const { editor, state } = this.props
      const { node } = this.state
      const next = state
        .transform()
        .unwrapInlineByKey(node.key)
        .apply()
      e.preventDefault()
      this.close()
      editor.onChange(next)
    }

    onCancel = (e) => {
      this.close()
    }

    close = () => {
      Inspecting.dispatch(null)
    }

    render = () => {
      const { url, node } = this.state
      return node && (
        <SlateNodePortal
          node={node}
          portalAnchor="bottom center"
          nodeAnchor="top center"
        >
          <InspectMenu
            onMouseEnter={this.onMouseEnter}
            onMouseLeave={this.onMouseLeave}
          >
            <InspectMenuInput
              placeholder="Enter a new URL…"
              type="url"
              defaultValue={url}
              onChange={e => this.setState({ url: e.target.value })}
              onBlur={this.onCancel}
              onEscape={this.onCancel}
              onEnter={this.onSubmit}
            />
            <MenuLeftButton empty primary>
              <Link
                to={url}
                target="_blank"
                rel="noreferrer nofollow noopener"
              >
                <Icon>launch</Icon>
              </Link>
            </MenuLeftButton>
            <MenuRightButton flushLeft empty danger onClick={this.onDelete}>
              <Icon>close</Icon>
            </MenuRightButton>
          </InspectMenu>
        </SlateNodePortal>
      )
    }

  }

@ianstormtaylor thank you for the detailed response, this is super helpful. Based on this, I will combine a couple of different approaches and report back incase anyone else has similar usecases.

  1. Store the response in the data of the parent LinkEmbed and have EditableTitle, EditableDescription access the parent's data object.

  2. And Use redux-actions (since I am using redux in the app) but otherwise do something similar to how signals are behaving above - to indicate which node to hover upon.

@ianstormtaylor One other follow-up question here:

I noticed from your comment above that you’ve implemented by making the Image the void node. And I'm assuming that Slate knowing about it helps with selection, focus, etc. Are there other advantages of having Slate know about it in the schema?

More generally, what is the rule around what can be inside blocks, and does Slate need to know about all the children of the blocks, i.e. does everything need to be declared as part of the schema? If there is an icon or a button to display - do those need to be declared as void nodes as well?

Figure (not-void, has data attribute which has src, etc for Image)
 - Image ----> (void node or just a component inside Figure component?)
 - ImageCaption (editable)
 - Delete Button ----> (a component inside Figure render? Or should it be inside a void node?)

What I found in the above case was when the delete button is not known by the schema but inside a non-void node, the cursor goes to it.

Any recommendations are much appreciated - happy to clarify in docs as well, if you think it is useful to others!

I'm not totally sure, but I think you'll need to use contenteditable=false to make sure that the cursor won't select it and cause weirdness, and then it should work? Not sure.

All of my buttons for blocks are handled via portals, so they aren't actually in the DOM at the same place, so there are no selection issues.

@ianstormtaylor I can confirm contenteditable=false avoids the weirdness. So, it seems like a block component can contain the following -

  1. NonVoid blocks.
  2. Void blocks.
  3. Elements with contenteditable=false.

And it seems, that the only difference between (2) and (3) is whether one wants the element to be present in the schema - does that sound about right?

PS. Let me know if you think this needs to be added in the docs anywhere as well!

Yup, that sounds right!

Addressing it in the docs somewhere sounds good to me if you find a good place.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

yalu picture yalu  Â·  3Comments

chrpeter picture chrpeter  Â·  3Comments

AlexeiAndreev picture AlexeiAndreev  Â·  3Comments

adrianclay picture adrianclay  Â·  3Comments

vdms picture vdms  Â·  3Comments