Browse Source

Upgrade Storybook setup, with latest release, less boilerplate

Thibaud Colas 3 years ago
parent
commit
d149a27392

+ 9 - 1
.eslintrc.js

@@ -92,7 +92,15 @@ module.exports = {
   overrides: [
     {
       // Rules we don’t want to enforce for test and tooling code.
-      files: ['*.test.ts', '*.test.tsx', '*.test.js', 'webpack.config.js'],
+      files: [
+        '*.test.ts',
+        '*.test.tsx',
+        '*.test.js',
+        'webpack.config.js',
+        '*.stories.js',
+        '*.stories.tsx',
+        'storybook/**/*',
+      ],
       rules: {
         '@typescript-eslint/no-empty-function': 'off',
         '@typescript-eslint/no-var-requires': 'off',

+ 3 - 0
MANIFEST.in

@@ -3,3 +3,6 @@ graft wagtail
 prune wagtail/wagtailadmin/static_src
 global-exclude __pycache__
 global-exclude *.py[co]
+global-exclude *.stories.js
+global-exclude *.stories.tsx
+recursive-exclude wagtail/ *.md

+ 35 - 10
client/storybook/TemplatePattern.tsx

@@ -2,18 +2,44 @@ import React, { useRef, useEffect } from 'react';
 
 import { renderPattern, simulateLoading } from 'storybook-django';
 
-const getTemplateName = (template?: string, filename?: string) =>
+const getTemplateName = (template?: string, filename?: string): string =>
   template ||
-  filename?.replace(/.+\/templates\//, '').replace(/\.stories\..+$/, '.html');
+  filename?.replace(/.+\/templates\//, '').replace(/\.stories\..+$/, '.html') ||
+  'template-not-found';
+
+type ContextMapping = { [key: string]: any };
+type TagsMapping = { [key: string]: any };
 
 interface TemplatePatternProps {
   element?: 'div' | 'span';
+  // Path to the template file.
   template?: string;
+  // Path to a Storybook `stories` file, which should be placed next to and named the same as the HTML template.
   filename?: string;
-  context?: { [key: string]: any };
-  tags?: { [key: string]: any };
+  context?: ContextMapping;
+  tags?: TagsMapping;
 }
 
+const PATTERN_LIBRARY_RENDER_URL = '/pattern-library/api/v1/render-pattern';
+
+/**
+ * Retrieves a template pattern’s HTML (or error response) from the server.
+ */
+export const getTemplatePattern = (
+  templateName: string,
+  context: ContextMapping,
+  tags: TagsMapping,
+  callback: (html: string) => void,
+) =>
+  renderPattern(PATTERN_LIBRARY_RENDER_URL, templateName, context, tags)
+    .catch(callback)
+    .then((res) => res.text())
+    .then(callback);
+
+/**
+ * Renders one of our Django templates as if it was a React component.
+ * All props are marked as optional, but either template or filename should be provided.
+ */
 const TemplatePattern = ({
   element = 'div',
   template,
@@ -22,14 +48,13 @@ const TemplatePattern = ({
   tags = {},
 }: TemplatePatternProps) => {
   const ref = useRef(null);
-  const templateName = getTemplateName(template, filename);
 
   useEffect(() => {
-    renderPattern(window.PATTERN_LIBRARY_API, templateName, context, tags)
-      .catch((err) => simulateLoading(ref.current, err))
-      .then((res) => res.text())
-      .then((html) => simulateLoading(ref.current, html));
-  }, []);
+    const templateName = getTemplateName(template, filename);
+    getTemplatePattern(templateName, context, tags, (html) =>
+      simulateLoading(ref.current, html),
+    );
+  });
 
   return React.createElement(element, { ref });
 };

+ 7 - 0
client/storybook/Welcome.stories.mdx

@@ -0,0 +1,7 @@
+import { Meta, Story, Canvas } from '@storybook/addon-docs';
+
+<Meta title="Welcome" />
+
+# Welcome to Wagtail’s design system
+
+Wagtail’s admin interface is built with reusable UI components, structured as a design system for ease of maintenance and third-party reuse.

+ 13 - 3
client/storybook/main.js

@@ -1,12 +1,21 @@
 module.exports = {
-  stories: ['../../client/**/*.stories.*', '../../wagtail/**/*.stories.*'],
-  addons: ['@storybook/addon-docs'],
+  stories: [
+    '../../client/**/*.stories.mdx',
+    '../../client/**/*.stories.@(js|tsx)',
+    {
+      directory: '../../wagtail/admin/templates/wagtailadmin/shared/',
+      titlePrefix: 'Shared',
+      files: '*.stories.*',
+    },
+    '../../wagtail/**/*.stories.*',
+  ],
+  addons: ['@storybook/addon-docs', '@storybook/addon-controls'],
+  framework: '@storybook/react',
   core: {
     builder: 'webpack5',
   },
   webpackFinal: (config) => {
     /* eslint-disable no-param-reassign */
-    config.resolve.fallback.crypto = false;
 
     const rules = [
       {
@@ -33,6 +42,7 @@ module.exports = {
 
     config.module.rules = config.module.rules.concat(rules);
 
+    // Allow using path magic variables to reduce boilerplate in stories.
     config.node = {
       __filename: true,
       __dirname: true,

+ 2 - 2
client/storybook/middleware.js

@@ -5,6 +5,6 @@ const origin = process.env.TEST_ORIGIN ?? 'http://localhost:8000';
 
 module.exports = middleware.createDjangoAPIMiddleware({
   origin,
-  // Must match the urls.py pattern for the pattern library.
-  apiPath: '/pattern-library/',
+  // Must match the patterns in urls.py.
+  apiPath: ['/pattern-library/', '/static/wagtailadmin/'],
 });

+ 0 - 7
client/storybook/preview-body.html

@@ -1,7 +0,0 @@
-<div data-sprite></div>
-<script>
-    // Load icon sprite into the DOM, just like in the Wagtail admin.
-    window.fetch('/pattern-library/api/v1/sprite')
-        .then((res) => res.text())
-        .then((html) => document.querySelector('[data-sprite]').innerHTML = html);
-</script>

+ 42 - 1
client/storybook/preview.js

@@ -4,4 +4,45 @@ import '../../wagtail/admin/static_src/wagtailadmin/scss/core.scss';
 import '../../wagtail/admin/static_src/wagtailadmin/scss/sidebar.scss';
 import './preview.scss';
 
-window.PATTERN_LIBRARY_API = '/pattern-library/api/v1/render-pattern';
+export const parameters = {
+  controls: {
+    hideNoControlsWarning: true,
+    matchers: {
+      color: /(background|color)$/i,
+      date: /Date$/,
+    },
+  },
+};
+
+const cachedIcons = sessionStorage.getItem('WAGTAIL_ICONS');
+window.WAGTAIL_ICONS = cachedIcons ? JSON.parse(cachedIcons) : [];
+
+/**
+ * Loads Wagtail’s icon sprite into the DOM, similarly to the admin.
+ */
+const loadIconSprite = () => {
+  const PATTERN_LIBRARY_SPRITE_URL = '/pattern-library/api/v1/sprite';
+
+  window
+    .fetch(PATTERN_LIBRARY_SPRITE_URL)
+    .then((res) => res.text())
+    .then((html) => {
+      const sprite = document.createElement('div');
+      sprite.innerHTML = html;
+      const symbols = Array.from(sprite.querySelectorAll('symbol'));
+      const icons = symbols.map((elt) => elt.id.replace('icon-', ''));
+
+      window.WAGTAIL_ICONS = icons;
+      sessionStorage.setItem('WAGTAIL_ICONS', JSON.stringify(icons));
+
+      if (document.body) {
+        document.body.appendChild(sprite);
+      } else {
+        window.addEventListener('DOMContentLoaded', () => {
+          document.body.appendChild(sprite);
+        });
+      }
+    });
+};
+
+loadIconSprite();

+ 6 - 7
client/storybook/preview.scss

@@ -5,14 +5,13 @@ body {
   font-size: 1rem;
 }
 
-h1,
-h2 {
-  text-transform: initial;
-}
-
-// Fix compatibility issue with Wagtail’s tag implementation.
+// Fix compatibility issue with Wagtail styles.
 .sbdocs .tag,
-.sbdocs .tag::before {
+.sbdocs .tag::before,
+h1,
+h2,
+code,
+textarea {
   all: revert;
 }
 

+ 1 - 1
client/storybook/stories.d.ts

@@ -3,5 +3,5 @@ declare module '*.md';
 declare module '*.html';
 
 interface Window {
-  PATTERN_LIBRARY_API: string;
+  WAGTAIL_ICONS: string[];
 }

+ 17 - 0
docs/contributing/developing.rst

@@ -282,6 +282,23 @@ This must be done after every change to the source files. To watch the source fi
 
     $ npm start
 
+Using the pattern library
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Wagtail’s UI component library is built with `Storybook <https://storybook.js.org/>`_ and `django-pattern-library <https://github.com/torchbox/django-pattern-library>`_. To run it locally,
+
+.. code-block:: console
+
+    $ export DJANGO_SETTINGS_MODULE=wagtail.tests.settings_ui
+    $ # Assumes the current environment contains a valid installation of Wagtail for local development.
+    $ ./wagtail/tests/manage.py migrate
+    $ ./wagtail/tests/manage.py createcachetable
+    $ ./wagtail/tests/manage.py runserver 0:8000
+    $ # In a separate terminal:
+    $ npm run storybook
+
+The last command will start Storybook at ``http://localhost:6006/``. It will proxy specific requests to Django at ``http://localhost:8000`` by default. Use the ``TEST_ORIGIN`` environment variable to use a different port for Django: ``TEST_ORIGIN=http://localhost:9000 npm run storybook``.
+
 Compiling the documentation
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

File diff suppressed because it is too large
+ 748 - 498
package-lock.json


+ 7 - 6
package.json

@@ -50,10 +50,11 @@
   },
   "devDependencies": {
     "@babel/core": "^7.16.7",
-    "@storybook/addon-docs": "^6.3.12",
-    "@storybook/builder-webpack5": "^6.3.12",
-    "@storybook/manager-webpack5": "^6.3.12",
-    "@storybook/react": "^6.3.12",
+    "@storybook/addon-controls": "^6.4.19",
+    "@storybook/addon-docs": "^6.4.19",
+    "@storybook/builder-webpack5": "^6.4.19",
+    "@storybook/manager-webpack5": "^6.4.19",
+    "@storybook/react": "^6.4.19",
     "@types/draft-js": "^0.10.45",
     "@types/jest": "^26.0.24",
     "@types/react": "^16.14.21",
@@ -111,9 +112,9 @@
   "scripts": {
     "start": "webpack --config ./client/webpack.config.js --mode development --progress --watch",
     "build": "webpack --config ./client/webpack.config.js --mode production",
-    "fix:js": "eslint --ext .js,.ts,.tsx --fix ./",
+    "fix:js": "eslint --ext .js,.ts,.tsx --fix .",
     "format": "prettier --write '**/?(.)*.{css,scss,js,ts,tsx,json,yaml,yml}'",
-    "lint:js": "eslint --ext .js,.ts,.tsx --report-unused-disable-directives ./client",
+    "lint:js": "eslint --ext .js,.ts,.tsx --report-unused-disable-directives .",
     "lint:css": "stylelint **/*.scss",
     "lint:format": "prettier --check '**/?(.)*.{css,scss,js,ts,tsx,json,yaml,yml}'",
     "lint": "npm run lint:js && npm run lint:css && npm run lint:format",

+ 12 - 1
tsconfig.json

@@ -10,6 +10,17 @@
     "allowJs": true,
     "downlevelIteration": true
   },
+  "files": [
+    "client/src/index.ts",
+    "client/src/custom.d.ts",
+    "client/storybook/stories.d.ts"
+  ],
   "include": ["src", "wagtail"],
-  "exclude": ["mode_modules", "static"]
+  "exclude": [
+    "node_modules",
+    "static",
+    // Files with template syntax.
+    "wagtail/admin/templates/wagtailadmin/edit_handlers/inline_panel.js",
+    "wagtail/contrib/search_promotions/templates/wagtailsearchpromotions/includes/searchpromotions_formset.js"
+  ]
 }

+ 14 - 0
wagtail/admin/templates/wagtailadmin/shared/animated_logo.stories.tsx

@@ -0,0 +1,14 @@
+import React from 'react';
+import TemplatePattern from '../../../../../client/storybook/TemplatePattern';
+
+import template from './animated_logo.html';
+
+export default {
+  parameters: {
+    docs: {
+      source: { code: template },
+    },
+  },
+};
+
+export const AnimatedLogo = () => <TemplatePattern filename={__filename} />;

+ 1 - 0
wagtail/admin/templates/wagtailadmin/shared/breadcrumb.md

@@ -0,0 +1 @@
+The breadcrumb component is reused across a lot of Wagtail’s headers.

+ 40 - 0
wagtail/admin/templates/wagtailadmin/shared/breadcrumb.stories.tsx

@@ -0,0 +1,40 @@
+import React from 'react';
+import TemplatePattern from '../../../../../client/storybook/TemplatePattern';
+
+import template from './breadcrumb.html';
+import docs from './breadcrumb.md';
+
+export default {
+  parameters: {
+    docs: {
+      source: { code: template },
+      extractComponentDescription: () => docs,
+    },
+  },
+};
+
+const Template = (args) => (
+  <TemplatePattern filename={__filename} context={args} />
+);
+
+export const Base = Template.bind({});
+
+Base.args = {
+  pages: [
+    {
+      is_root: true,
+      id: 2,
+      get_admin_display_title: 'First item',
+    },
+    {
+      id: 3,
+      get_admin_display_title: 'Second item',
+    },
+    {
+      id: 4,
+      get_admin_display_title: 'Third item',
+    },
+  ],
+  trailing_arrow: true,
+  show_header_buttons: false,
+};

+ 10 - 14
wagtail/admin/templates/wagtailadmin/shared/header.html

@@ -3,20 +3,16 @@
 
     Variables accepted by this template:
 
-    title
-    subtitle
-
-    search_url - if present, display a search box. This is a URL route name (taking no parameters) to be used as the action for that search box
-    query_parameters - a query string (without the '?') to be placed after the search URL
-
-    icon - name of an icon to place against the title
-
-    tabbed - if true, add the classname 'tab-merged'
-    merged - if true, add the classname 'merged'
-
-    action_url - if present, display an 'action' button. This is the URL to be used as the link URL for the button
-    action_text - text for the 'action' button
-    action_icon - icon for the 'action' button, default is 'icon-plus'
+    - `title`
+    - `subtitle`
+    - `search_url` - if present, display a search box. This is a URL route name (taking no parameters) to be used as the action for that search box
+    - `query_parameters` - a query string (without the '?') to be placed after the search URL
+    - `icon` - name of an icon to place against the title
+    - `tabbed` - if true, add the classname 'tab-merged'
+    - `merged` - if true, add the classname 'merged'
+    - `action_url` - if present, display an 'action' button. This is the URL to be used as the link URL for the button
+    - `action_text` - text for the 'action' button
+    - `action_icon` - icon for the 'action' button, default is 'icon-plus'
 
 {% endcomment %}
 <header class="{% if merged %}merged{% endif %} {% if tabbed %}tab-merged{% endif %} {% if search_form %}hasform{% endif %}">

+ 45 - 0
wagtail/admin/templates/wagtailadmin/shared/header.stories.tsx

@@ -0,0 +1,45 @@
+import React from 'react';
+import TemplatePattern from '../../../../../client/storybook/TemplatePattern';
+
+import template from './header.html';
+
+export default {
+  parameters: {
+    docs: {
+      source: { code: template },
+      // Trial generating documentation from comment within the template. To be replaced by a better pattern.
+      extractComponentDescription: () =>
+        template
+          .match(/{% comment %}\n((.|\n)+){% endcomment %}/m)[1]
+          .replace(/ {4}/g, ''),
+    },
+  },
+  argTypes: {
+    icon: {
+      options: window.WAGTAIL_ICONS,
+      control: { type: 'select' },
+      description: 'name of an icon to place against the title',
+    },
+  },
+};
+
+const Template = (args) => (
+  <TemplatePattern filename={__filename} context={args} />
+);
+
+export const Base = Template.bind({});
+
+Base.args = {
+  title: 'Calendar',
+  icon: 'date',
+};
+
+export const Action = Template.bind({});
+
+Action.args = {
+  title: 'Users',
+  subtitle: 'Editors',
+  icon: 'user',
+  action_url: '/test/',
+  action_text: 'Add',
+};

+ 26 - 29
wagtail/admin/templates/wagtailadmin/shared/icons.stories.tsx

@@ -1,52 +1,49 @@
 import React, { useState, useEffect } from 'react';
-import { renderPattern } from 'storybook-django';
-
-const fetchIconTemplate = () =>
-  renderPattern(
-    window.PATTERN_LIBRARY_API,
-    'wagtailadmin/shared/icon.html',
-    {
-      name: '__icon__',
-    },
-    {},
-  ).then((res) => res.text());
+import { getTemplatePattern } from '../../../../../client/storybook/TemplatePattern';
 
 /**
  * Displays all icons within our sprite.
  */
-const Icons = () => {
+const Icons = ({ color }: { color: string }) => {
   const [template, setTemplate] = useState<string>('');
-  const [icons, setIcons] = useState<string[]>([]);
 
   useEffect(() => {
-    const sprite = document.querySelector('[data-sprite]');
-    if (sprite) {
-      const symbols = Array.from(sprite.querySelectorAll('symbol'));
-      setIcons(symbols.map((s) => s.id.replace('icon-', '')));
-    }
-
-    fetchIconTemplate().then((html) => setTemplate(html));
+    getTemplatePattern(
+      'wagtailadmin/shared/icon.html',
+      { name: '__icon__' },
+      {},
+      (html) => setTemplate(html),
+    );
   }, []);
 
   return (
-    <ul>
-      {icons.map((icon) => (
-        <li key={icon}>
+    <>
+      {window.WAGTAIL_ICONS.map((icon) => (
+        <div key={icon}>
           <span
             dangerouslySetInnerHTML={{
-              __html: template.replace(/__icon__/g, icon),
+              __html: template
+                .replace(/__icon__/g, icon)
+                .replace(/<svg/, `<svg style="fill: ${color};"`),
             }}
           />
           <code>{`{% icon name="${icon}" %}`}</code>
-        </li>
+        </div>
       ))}
-    </ul>
+    </>
   );
 };
 
 export default {
-  title: 'Shared / Icons',
-  component: Icons,
+  argTypes: {
+    color: {
+      description: 'Only intended for demo purposes',
+    },
+  },
 };
 
-export const All = () => <Icons />;
+export const icons = (args) => <Icons {...args} />;
+
+icons.args = {
+  color: 'currentColor',
+};

+ 1 - 1
wagtail/utils/setup.py

@@ -13,7 +13,7 @@ from wagtail import __semver__
 class assets_mixin:
     def compile_assets(self):
         try:
-            subprocess.check_call(["npm", "run", "dist"])
+            subprocess.check_call(["npm", "run", "build"])
         except (OSError, subprocess.CalledProcessError) as e:
             print("Error compiling assets: " + str(e))  # noqa
             raise SystemExit(1)

Some files were not shown because too many files changed in this diff