Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
September 26, 2021 04:35 pm GMT

An extensible React "Plug & Play" Widget

TLDR;

I'm building a blogging widget that allows authors to further engage their audience by creating interactive and gamified experiences right within their post. This article is part of a series that looks at how this is done.

In this article I'll look at how the Widget allows extension functionality to be created by authors so that they can add their own interactive, configurable extensions and we can build a library of useful tools anyone can use! The extension functionality works without having to access the core project and can be easily developed and deployed using any framework that can output Javascript and interact with the DOM.

Motivation

I'm building the interactive widget below, vote on what you'd like to interact with or embed in your own post:

Requirements

The key principle here is to create an API that can be used to easily add an extension to the widget so that an author can create powerful new functionality to "plug in". I don't want to enforce a technology stack choice on the developer so they should be able to write in anything from vanilla Javascript to a fully fledged framework.

The developer needs to build two things, an editor component that will allow a post author to configure the extension widget and a runtime that will be rendered inside the post and perform whatever actions are required.

The key features need to be:

  • Create and expose an API that allows a developer to register an extension for both editor and runtime concerns
  • Expose an API that allows a plugin developer to record information relevant to the reader, the article and the widget (for example a vote in a poll)
  • Provide a way of notifying the plugin developer about existing responses related to the article and changes to the data
  • Provide an API to allow the plugin developer to award points and badges to the reader
  • Provide a way for the plugin developer to have their extension code loaded when the plugin is to be used

Configuration Interface

I've built a configuration interface for the main Widget that allows the injection of the custom editor instances and saves all of the necessary data. To configure a widget the user works with a number of screens:

Author Home page

The homepage gives an author access to their profile, their articles and their comments. Each article or comment has a configuration for the widget.

Author article list

The author makes an entry for each post and can use the summary view to see how many times the content has been viewed (including unique user views) and the number of times it has been interacted with.

Widget configuration

The author can configure the main and footer widgets for their embed. They choose an available widget from a drop down list and its editor is displayed in line (here the example is for the simple HTML plugin).

Additional plugin configuration

If the widget is a custom built one then they can specify the files it should load on the "Advanced" tab. The entries here are for all of the Javascript files to load - while developing these could be hosted on a local development server or it could be hosted on GitHub or anywhere else, so long as the files are served as Javascript and not text. Many build systems output more than one file for inclusion in the core package (for instance a vendors file and a main source bundle) and they can all be listed here or the urls included in a .bundle file that is then used here.

Runtime Script Loading

Ok so to start with the system needs to load the extension code specified in the "Advanced" tab. It does this by splitting the list of files on
and then checking if the file is one of three types (+ 1 filter):

  • A .editor.* file - which will only be loaded if the widget is in the configuration system
  • A .js file - in which case a <script/> tag is created and the src set to be the file. This means the file must be served with the correct mime type (which GitHub raw files are not, unless you use a CDN extension which will cache the file, making it unwise during development).
  • A .jsx or a .babel.js file - in which case browser babel is loaded and then an additional <script/> tag with a type of text/babel is created with the src attribute set to the file and an environment of env and react added to it. This allows lightweight React plugins as React is used to build the outer layer. It's a big fancy, and I won't go into too much more detail here apart from to say, that if one .jsx file imports another then it also needs to be specified here. Note that GitHub raw files are fine in this case.
  • A .bundle file - in which case the file is downloaded and the same process is applied to the contents of the file.

It is expected that plugins will be developed as bundled projects if using a framework and the output Javascript included. I've tested it with Webpack and Rollup, you just need to be sure to include all of the files that would have been included in the index.html.

Implementation

export async function loadPlugins(plugins) {    let hadBabel = false    for (let url of plugins) {        let type = "text/javascript"        if (url.endsWith(".bundle")) {            const response = await fetch(url)            if (!response.ok) {                console.warn("Could not load bundle", url)                continue            }            const usedBabel = await loadPlugins(                (                    await response.text()                )                    .split("
") .map((c) => c.trim()) .filter((c) => !!c) ) hadBabel = hadBabel || usedBabel continue } if (document.body.querySelector(`script[src~="${url}"]`)) continue const script = document.createElement("script") if (url.includes(".babel") || url.includes(".jsx")) { hadBabel = true type = "text/babel" script.setAttribute("data-presets", "env,react") script.setAttribute("data-plugins", "transform-modules-umd") await loadBabel() } script.type = type script.src = `${url}` document.body.appendChild(script) } return hadBabel}function loadBabel() { return new Promise((resolve) => { const babelUrl = "https://unpkg.com/@babel/standalone/babel.min.js" if (document.body.querySelector(`script[src='${babelUrl}']`)) { return resolve() } const script = document.createElement("script") script.src = babelUrl script.onload = () => { resolve() } document.body.appendChild(script) })}

Registering New Plugins

Loading the code is one thing, but once loaded it needs to be able to interact with the outer widget. To accomplish this the outer widget exposes an API on window in a variable called Framework4C. This API provides all of the core functions required by a plugin.

window.Framework4C = {    Accessibility: {        reduceMotion  //User prefers reduced motion    },    Material,   // The whole of Material UI core    showNotification,  // A function to show a toast    theme,  // A material UI theme    React,  // React 17    ReactDOM, // ReactDOM 17    Plugins: {         register,         PluginTypes,     }, // Function to register plugins    Interaction: {        awardPoints,        respond,        respondUnique,        addAchievement,    } // Response functions}

To get involved in the process the only thing that the newly loaded code needs to do is to call register passing a valid PluginTypes value and a function that will render the editor or the runtime within a specified parent DOM element.

Widget Types

Registering A Plugin

Each plugin comprises an editor and a runtime.

The Editor

An editor is provided with a place to store configuration data and a function to call to say that the data has been changed. It is the job of the editor to set up any parameters that the runtime will need - these are all entirely at the discretion of the developer.

const {   Plugins: { PluginTypes, register },} = window.Framework4Cregister(PluginTypes.MAIN, "Remote", editor, null /* Ignore Runtime */)function editor({ parent, settings, onChange }) {    /* Render the editor underneath parent */}

If you were going to use React to render the editor you'd use ReactDOM.render passing the parent element. If you were using Vue you'd createApp and mount it inside the parent:

import { createApp } from "vue"import App from "./App.vue"import Render from "./Render.vue"const {    Plugins: { register, PluginTypes }} = window.Framework4C || { Plugins: {} }register(PluginTypes.MAIN, "Vue Example", editor)function editor({ parent, settings, onChange }) {    createApp({        ...App,        data() {            // Initialize props for reactivity            settings.message = settings.message || ""            return settings        },        updated() {            onChange()        }    }).mount(parent)}

To register an editor we simply call the register function, specifying the type of plugin and pass a callback for when it is time to render the plugin's editor.

Poll Editor UI

Here's an example of the UI for the editor that made the poll on the article.

Poll Editor

Poll Editor Code

import {    Box,    Button,    ButtonGroup,    CardContent,    CssBaseline,    DialogActions,    DialogContent,    DialogTitle,    IconButton,    TextField,    ThemeProvider,    Typography} from "@material-ui/core"import { nanoid } from "nanoid"import randomColor from "randomcolor"import React, { useState } from "react"import reactDom from "react-dom"import { FaEllipsisV } from "react-icons/fa"import { MdDelete } from "react-icons/md"import { Bound, useBoundContext } from "../lib/Bound"import { BoundTextField } from "../lib/bound-components"import { BoundColorField } from "../lib/ColorField"import { downloadObject } from "../lib/downloadObject"import { ListItemBox } from "../lib/ListItemBox"import { Odometer } from "../lib/odometer"import { PluginTypes, register } from "../lib/plugins"import { setFromEvent } from "../lib/setFromEvent"import { Sortable, SortableItem } from "../lib/Sortable"import { theme } from "../lib/theme"import { UploadButton } from "../lib/uploadButton"import { useDialog } from "../lib/useDialog"import { useEvent } from "../lib/useEvent"import { useRefresh } from "../lib/useRefresh"register(PluginTypes.MAIN, "Poll", editor)function editor({ parent, ...props }) {    reactDom.render(<Editor {...props} />, parent)}function Editor({ settings, onChange, response }) {    const refresh = useRefresh(onChange)    return (        <ThemeProvider theme={theme}>            <CssBaseline />            <Bound                refresh={refresh}                target={settings}                onChange={onChange}                response={response}            >                <Box mt={2}>                    <PollConfig />                </Box>            </Bound>        </ThemeProvider>    )}function PollConfig() {    const { target, refresh } = useBoundContext()    const answers = (target.answers = target.answers || [])    const getName = useDialog(DownloadName)    return (        <>            <ListItemBox>                <Box flex={1} />                <ButtonGroup size="small">                    <UploadButton                        accept="*.poll.json"                        variant="outlined"                        color="primary"                        onFile={load}                    >                        Load                    </UploadButton>                    <Button onClick={save} variant="outlined" color="secondary">                        Save                    </Button>                </ButtonGroup>            </ListItemBox>            <CardContent>                <BoundTextField field="question" />            </CardContent>            <CardContent>                <BoundTextField field="description" />            </CardContent>            <CardContent>                <BoundColorField field="questionColor" default="white" />            </CardContent>            <CardContent>                <Typography variant="overline" component="h3" gutterBottom>                    Answers                </Typography>                <Sortable items={answers} onDragEnd={refresh}>                    {answers.map((answer) => (                        <Answer                            answers={answers}                            key={answer.id}                            answer={answer}                        />                    ))}                </Sortable>            </CardContent>            <Button color="primary" onClick={addAnswer}>                + Answer            </Button>        </>    )    async function save() {        const name = await getName()        if (name) {            downloadObject(target, `${name}.poll.json`)        }    }    function load(data) {        if (data) {            Object.assign(target, data)            refresh()        }    }    function addAnswer() {        answers.push({ id: nanoid(), answer: "", color: randomColor() })        refresh()    }}export function DownloadName({ ok, cancel }) {    const [name, setName] = useState("")    return (        <>            <DialogTitle>Name</DialogTitle>            <DialogContent>                <TextField                    autoFocus                    value={name}                    onChange={setFromEvent(setName)}                    fullWidth                />            </DialogContent>            <DialogActions>                <Button onClick={cancel}>Cancel</Button>                <Button                    onClick={() => ok(name)}                    color="secondary"                    variant="contained"                >                    Create                </Button>            </DialogActions>        </>    )}export function Answer({ answers, answer }) {    const { refresh, response } = useBoundContext()    const [dragProps, setDragProps] = useState({})    useEvent("response", useRefresh())    const votes = Object.values(response?.responses?.Poll || {}).reduce(        (c, a) => (a === answer.id ? c + 1 : c),        0    )    return (        <SortableItem            borderRadius={4}            bgcolor="#fff8"            setDragProps={setDragProps}            m={1}            display="flex"            alignItems="center"            id={answer.id}        >            <Bound target={answer} refresh={refresh}>                <Box                    aria-label="Drag handle"                    mr={1}                    color="#444"                    fontSize={16}                    {...dragProps}                >                    <FaEllipsisV />                </Box>                <Box flex={0.6} mr={1}>                    <BoundTextField                        field="answer"                        InputProps={{                            endAdornment: (                                <Box                                    ml={1}                                    textAlign="right"                                    color="#666"                                    whiteSpace="nowrap"                                >                                    <small>                                        <Odometer>{votes}</Odometer> vote                                        <span                                            style={{                                                opacity: votes === 1 ? 0 : 1                                            }}                                        >                                            s                                        </span>                                    </small>                                </Box>                            )                        }}                    />                </Box>                <Box flex={0.4} mr={1}>                    <BoundTextField field="legend" />                </Box>                <Box flex={0.5} mr={1}>                    <BoundColorField field="color" default="#999999" />                </Box>                <IconButton                    aria-label="Delete"                    onClick={remove}                    color="secondary"                >                    <MdDelete />                </IconButton>            </Bound>        </SortableItem>    )    function remove() {        const idx = answers.indexOf(answer)        if (idx !== -1) {            answers.splice(idx, 1)            refresh()        }    }}

The Runtime

The runtime is used to render the component when it is viewed by a reader, presumably it takes the configuration information provided by the author and uses that to create the desired user interface.

The runtime is also supplied a parent DOM element, but it is also supplied with the settings made in the editor, the article that is being viewed, the current user and a response object that contains all of the responses. This response object may be updated after the initial render and a window event of response is raised passing the updated data.

Implementation

As far as the framework is concerned, the register function just records the callback for the editor and the runtime in a data structure and raises a change event. These entries are looked up for rendering.

import { raise } from "./raise"export const PluginTypes = {    MAIN: "main",    FOOTER: "footer",    NOTIFICATION: "notification"}export const Plugins = {    [PluginTypes.MAIN]: {},    [PluginTypes.FOOTER]: {},    [PluginTypes.NOTIFICATION]: {}}export function register(type, name, editor, runtime) {    const existing = Plugins[type][name] || {}    Plugins[type][name] = {        name,        editor: editor || existing.editor,        type,        runtime: runtime || existing.runtime    }    raise("plugins-updated")}

Runtime Responses

The plugin system gives you the ability to capture responses from the user and store them. All of the responses for the current article are provided to you, so for instance you can show the results of a poll or a quiz. Using these methods you can record information and display it to the reader in the way you want.

The system also raises events on window when the response changes so you can show real-time updates as data changes due to any current readers.

The most common way to capture a users response is to use the API call respondUnique(articleId, type, response). This API call will record a response object unique to the current user. The type parameter is an arbitrary string you use to differentiate your plugins response from others. The response passed is an object or value that will be recorded for the user and then made available to all plugin instances for the current article.

A response object populated due to a call passing MyResponseType as the type might look like this.

{   MyReponseType: {       UserId1: 1 /* something you recorded */,       UserId2: { answer: 2 } /* something you recorded for user 2 */        }}

So to display summaries or totals for a poll or a quiz you would calculate them by iterating over the unique user responses and calculating the answer.

If you call respondUnique multiple times, only the last value will be recorded for the current user, this is normally what you want for a poll or a quiz.

await respondUnique(article.uid, "Poll", answer.id)

You may also call respond with the same parameters. In this case, the response structure will contain an array of all of the responses for each user.

{   MyReponseType: {       UserId1: [{ /* something you recorded */ }, {/* another thing */}],       UserId2: [{ /* something you recorded for user 2 */ }]        }}

Runtime Rendering

The runtime rendering of the whole widget relies on calling the registered functions. The Widget builds a container DOM structure and then calls a function called renderPlugin passing in the settings. I'll put the whole code for this in a foldaway so you can examine it if you like, we'll concentrate on renderPlugin.

function renderPlugin(    parent,    type,    pluginName,    settings = {},    article,    user,    response,    previewMode) {    if (!settings || !pluginName || !type || !parent || !article || !user)        return    const plugin = Plugins[type][pluginName]    if (!plugin || !plugin.runtime) return    plugin.runtime({        parent,        article,        settings,        type,        pluginName,        user,        response,        previewMode    })}

Rendering the plugin is simply a matter of looking up the plugin required in the registered list and then calling its runtime function. The outer holder handles monitoring Firestore for changes to the response information and raising the custom event should it happen.

renderWidget

import { addAchievement, db, view } from "../lib/firebase"import logo from "../assets/4C_logo.jpg"import { Plugins, PluginTypes } from "../lib/plugins"import { raise } from "../lib/raise"import { merge } from "../lib/merge"let response = { notLoaded: true }let lastMainexport async function renderWidget(    parent,    id,    user = { isAnonymous: true },    useArticle = null) {    const definitionRef = db.collection("articles").doc(id)    const definitionDoc = (parent._definitionDoc =        parent._definitionDoc || (await definitionRef.get()))    if (!definitionDoc.exists && !useArticle) {        // Do some fallback        return null    }    if (parent._uid !== user.uid) {        if (!useArticle) {            view(id).catch(console.error)        }    }    // Get the actual data of the document    const article = useArticle || definitionDoc.data()    if (lastMain !== article[PluginTypes.MAIN]) {        article.overrideBottomBackground = null        article.overrideGradientFrom = null        article.overrideGradientTo = null    }    lastMain = article[PluginTypes.MAIN]    const removeListener = (parent._removeListener =        parent._removeListener ||        db            .collection("responses")            .doc(id)            .onSnapshot((update) => {                response.notLoaded = false                const updatedData = update.data()                Object.assign(response, updatedData)                setTimeout(() => {                    response.notLoaded = false                    raise(`response-${id}`, response)                    raise(`response`, response)                })            }))    parent._uid = user.uid    const author = await (        await db.collection("userprofiles").doc(article.author).get()    ).data()    const holder = makeContainer(parent, article, user)    holder.logoWidget.style.backgroundImage = `url(${logo})`    if (author?.photoURL) {        holder.avatarWidget.style.backgroundImage = `url(${author.photoURL})`    }    if (author.profileURL) {        holder.avatarWidget.role = "button"        holder.avatarWidget.style.cursor = "pointer"        holder.avatarWidget["aria-label"] = "Link to authors profile page"        holder.avatarWidget.onclick = () => {            if (author.displayName) {                addAchievement(                    15,                    `Visited profile of ${author.displayName}`                ).catch(console.error)            }            window.open(author.profileURL, "_blank", "noreferrer noopener")        }    }    article.pluginSettings = article.pluginSettings || {}    renderPlugin(        holder.mainWidget,        PluginTypes.MAIN,        article[PluginTypes.MAIN],        article.pluginSettings[article[PluginTypes.MAIN]] || {},        article,        user,        response,        !!useArticle    )    renderPlugin(        holder.footerWidget,        PluginTypes.FOOTER,        article[PluginTypes.FOOTER],        article.pluginSettings[article[PluginTypes.FOOTER]] || {},        article,        user,        response,        !!useArticle    )    renderPlugin(        holder.notificationWidget,        PluginTypes.NOTIFICATION,        article[PluginTypes.NOTIFICATION] || "defaultNotification",        article.pluginSettings[article[PluginTypes.NOTIFICATION]] || {},        article,        user,        response,        !!useArticle    )    return () => {        parent._removeListener = null        removeListener()    }}function renderPlugin(    parent,    type,    pluginName,    settings = {},    article,    user,    response,    previewMode) {    if (!settings || !pluginName || !type || !parent || !article || !user)        return    const plugin = Plugins[type][pluginName]    if (!plugin || !plugin.runtime) return    plugin.runtime({        parent,        article,        settings,        type,        pluginName,        user,        response,        previewMode    })}function makeContainer(parent, article) {    const isNarrow = window.innerWidth < 500    parent = parent || document.body    parent.style.background = `linear-gradient(45deg, ${        article?.overrideGradientFrom ?? article?.gradientFrom ?? "#fe6b8b"    } 30%, ${        article?.overrideGradientTo ?? article?.gradientTo ?? "#ff8e53"    } 90%)`    if (parent._madeContainer) {        parent._madeContainer.bottom.style.background =            article.overrideBottomBackground ||            article.bottomBackground ||            "#333"        parent._madeContainer.bottom.style.color =            article.overrideBottomColor || article.bottomColor || "#fff"        parent._madeContainer.bottom.style.display = isNarrow ? "none" : "flex"        parent._madeContainer.notificationWidget.style.display = isNarrow            ? "none"            : "flex"        return parent._madeContainer    }    window.addEventListener("resize", () => makeContainer(parent, article))    const main = document.createElement("main")    Object.assign(main.style, {        display: "flex",        flexDirection: "column",        width: "100%",        height: "100%",        overflow: "hidden"    })    const top = document.createElement("div")    Object.assign(top.style, {        flex: 1,        width: "100%",        display: "flex",        justifyContent: "stretch",        overflow: "hidden"    })    main.appendChild(top)    const mainWidget = document.createElement("section")    Object.assign(mainWidget.style, {        width: "66%",        flex: 1,        overflowY: "auto",        display: "flex",        flexDirection: "column",        alignItems: "stretch",        justifyContent: "stretch",        position: "relative"    })    top.appendChild(mainWidget)    const notificationWidget = document.createElement("section")    Object.assign(notificationWidget.style, {        width: "34%",        display: isNarrow ? "none" : "block",        maxWidth: "250px",        overflowY: "hidden",        overflowX: "visible"    })    top.appendChild(notificationWidget)    const middle = document.createElement("div")    Object.assign(middle.style, {        height: "0px"    })    main.appendChild(middle)    const bottom = document.createElement("div")    Object.assign(bottom.style, {        height: "76px",        background:            article.overrideBottomBackground ||            article.bottomBackground ||            "#333",        color: article.overrideBottomColor || article.bottomColor || "#fff",        marginLeft: "-4px",        marginRight: "-4px",        marginBottom: "-4px",        boxShadow: "0 0 8px 0px #000A",        padding: "8px",        paddingTop: "4px",        display: isNarrow ? "none" : "flex",        paddingRight: window.padRightToolbar ? "142px" : undefined,        flexGrow: 0,        flexShrink: 0,        alignItems: "center",        width: "calc(100% + 8px)",        overflow: "hidden",        position: "relative"    })    main.appendChild(bottom)    const avatarWidget = document.createElement("div")    merge(avatarWidget.style, {        borderRadius: "100%",        width: "64px",        height: "64px",        backgroundRepeat: "no-repeat",        backgroundSize: "cover"    })    avatarWidget["aria-label"] = "Author avatar"    bottom.appendChild(avatarWidget)    const footerWidget = document.createElement("section")    Object.assign(footerWidget.style, {        flex: 1    })    bottom.appendChild(footerWidget)    const logoWidget = document.createElement("a")    merge(logoWidget, {        href: "https://4c.rocks",        onclick: () => addAchievement(25, "Visited 4C Rocks"),        target: "_blank",        "aria-label": "Link to 4C Rocks site"    })    merge(logoWidget.style, {        display: "block",        width: "64px",        height: "64px",        borderRadius: "8px",        backgroundSize: "contain"    })    bottom.appendChild(logoWidget)    parent.appendChild(main)    return (parent._madeContainer = {        main,        bottom,        mainWidget,        footerWidget,        logoWidget,        avatarWidget,        notificationWidget    })}

Examples

If you've previously voted then you'll see the results, otherwise please vote to see what others think:

Conclusion

In this instalment we've seen how to load custom code into a widget, irrespective of the framework used and then how to use this code to make a pluggable UI.

4C Blogging Widget

Open source widget for https://4c.rocks

Guide


Original Link: https://dev.to/miketalbot/react-plug-play-widget-ui-2mg4

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To