torchbox / storybook-django

Develop Django UI components in isolation, with Storybook
MIT License
109 stars 8 forks source link

Usage with Vue #7

Open laurencedorman opened 2 years ago

laurencedorman commented 2 years ago

How tightly coupled is this with React ? I'm reading through the code but I'm having trouble seeing what would need to be done to get it working with Vue.

thibaudcolas commented 2 years ago

Hey @laurencedorman, not very much.

The current implementation technically is coupled, but the current implementation is very hacky, and could easily be refactored to be framework-agnostic. Here is where most of the magic happens: https://github.com/torchbox/storybook-django/blob/main/src/TemplatePattern.js#L30-L129

This is technically a React component, but we only use it to render a <div>, store a bit of state – and the bulk of the logic is built with vanilla JS executed imperatively in the relevant React lifecycle phases.

Here is a PoC of a Vue version. Note I don’t do Vue very often myself so this likely isn’t correct. In particular, we’d want the API call to be made on every render of the component, not just on mount.

<template>
  <component ref="elt" :is="computedTag"></component>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import Vue from 'vue'

/**
 * Inserts HTML into an element, executing embedded script tags.
 * @param {Element} element
 * @param {string} html
 */
const insertHTMLWithScripts = (element, html) => {
    element.innerHTML = html;

    Array.from(element.querySelectorAll('script')).forEach((script) => {
        const newScript = document.createElement('script');
        Array.from(script.attributes).forEach((attr) =>
            newScript.setAttribute(attr.name, attr.value),
        );

        newScript.appendChild(document.createTextNode(script.innerHTML));
        script.parentNode.replaceChild(newScript, script);
    });
};

export default Vue.extend({
  name: "TemplatePattern",
  props: {
    element: {
      type: String,
      required: true,
    },
    apiPath: {
      type: String,
      required: true,
    },
    template: {
      type: String,
      required: true,
    },
    context: {
      type: Object,
      required: true,
    },
    tags: {
      type: Object,
      required: true,
    },
  },
  data(): {
    error: Error | null;
  } {
    return {
      error: null,
    };
  },
  computed: {
    computedTag(): string {
      return this.element || 'div'
    },
  },
  mounted() {
    //   TODO Should be called whenever the component re-renders, not just on mount.
    this.getRenderedPattern();
  },
  methods: {
    async getRenderedPattern(): Promise<void> {
      const url = this.apiPath || window.PATTERN_LIBRARY_API;
      let template_name = window.PATTERN_LIBRARY_TEMPLATE_DIR
        ? this.template
            .replace(window.PATTERN_LIBRARY_TEMPLATE_DIR, ";;;")
            .replace(/^.*;;;/, "")
        : this.template;
      template_name = template_name.replace(".stories.js", ".html");

      window
        .fetch(url, {
          method: "POST",
          mode: "same-origin",
          cache: "no-cache",
          credentials: "omit",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            template_name,
            config: {
              context,
              tags,
            },
          }),
        })
        .catch(() => {
          if (this.$refs.elt) {
            insertHTMLWithScripts(this.$refs.elt, "Network error");
          }
        })
        .then((res) => {
          if (res.ok) {
            setError(null);

            return res.text();
          }

          return res.text().then((serverError) => {
            let errName = serverError.split("\n")[0];
            let stack = serverError;

            if (serverError.includes("TemplateSyntaxError")) {
              try {
                let templateError;
                templateError = serverError.split("Template error:")[1];
                templateError = templateError.split("Traceback:")[0];
                templateError = templateError
                  .split("\n")
                  .filter((l) => l.startsWith("   "))
                  .map((l) => l.replace(/^\s\s\s/, ""))
                  .join("\n");

                const errCleanup = document.createElement("div");
                errCleanup.innerHTML = templateError;
                stack = errCleanup.innerText;

                let location = serverError
                  .split("\n")
                  .find((l) => l.startsWith("In template"));
                errName = `TemplateSyntaxError ${location ? location : ""}`;
              } catch {}
            }

            const error = new Error(errName);
            error.stack = stack;

            setError(error);

            return "Server error";
          });
        })
        .then((html) => {
          if (this.$refs.elt) {
            insertHTMLWithScripts(this.$refs.elt, html);
            window.document.dispatchEvent(
              new Event("DOMContentLoaded", {
                bubbles: true,
                cancelable: true,
              })
            );
          }
        });
    },
  },
});
</script>
laurencedorman commented 2 years ago

Thank you so much for taking the time to sketch this out for me @thibaudcolas, I’ll get stuck in!

thibaudcolas commented 2 years ago

Here is an up-to-date POC implementation based on the latest release of storybook-django, which includes framework-agnostic APIs:

<template>
  <component ref="elt" :is="computedTag"></component>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import Vue from 'vue'
import { renderPattern, simulateLoading } from 'storybook-django';

const getTemplateName = (template?: string, filename?: string): string =>
  template ||
  filename?.replace(/.+\/templates\//, '').replace(/\.stories\..+$/, '.html') ||
  'template-not-found';

export default Vue.extend({
  name: "TemplatePattern",
  props: {
    element: {
      type: String,
      required: true,
    },
    template: {
      type: String,
      required: false,
    },
    filename: {
      type: String,
      required: false,
    },
    context: {
      type: Object,
      required: true,
    },
    tags: {
      type: Object,
      required: true,
    },
  },
  data(): {
    error: Error | null;
  } {
    return {
      error: null,
    };
  },
  computed: {
    computedTag(): string {
      return this.element || 'div'
    },
  },
  mounted() {
    //   TODO Should be called whenever the component re-renders, not just on mount.
    this.getRenderedPattern();
  },
  methods: {
    async getRenderedPattern(): Promise<void> {
      const templateName = getTemplateName(this.template, this.filename);

      renderPattern(window.PATTERN_LIBRARY_API, template_name, this.context, this.tags)
        .catch((err) => simulateLoading(this.$refs.elt, err))
        .then(res => res.text())
        .then((html) => simulateLoading(this.$refs.elt, html));
    },
  },
});
</script>

I believe it should be possible to add support for Vue directly in storybook-django – will give this a go in a future release.

thibaudcolas commented 2 years ago

I have released a new version of the project, with framework-agnostic APIs (documented in the README), and an optional React component. It seems possible to also add support for Vue 3 in a similar way. Here is the reference React implementation:

https://github.com/torchbox/storybook-django/blob/main/src/react.js

The only thing that is still coupled with the React implementation is data- attributes that are only needed to support automated tests. They’d be pretty straightforward to re-implement in a Vue version.