@freelygive/canvas-utils 0.1.0 → 0.2.0-dev.6dad937b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # @freelygive/canvas-utils
2
+
3
+ Canvas utility components and test helpers for Drupal Canvas projects.
4
+
5
+ ## Components
6
+
7
+ Utility components are scaffolded into your project via
8
+ [@freelygive/npm-scaffold](https://www.npmjs.com/package/@freelygive/npm-scaffold).
9
+
10
+ | Component | Description |
11
+ |---|---|
12
+ | `utils_editor_note` | Editor note component and `isCanvasEditorMode()` detection |
13
+ | `utils_slots` | `getSlotChildren()` for parsing Canvas slot content |
14
+ | `utils_entity` | `useMainEntity()` hook for fetching the page's main entity |
15
+
16
+ ## Test Helpers
17
+
18
+ Built JS entry points for use in Storybook test stories.
19
+
20
+ ### Simulating Canvas Slots
21
+
22
+ Canvas delivers slot content as HTML containing `<canvas-island>` custom
23
+ elements. In Storybook, use the canvas-slots helpers to replicate this format
24
+ so components can be tested under realistic conditions.
25
+
26
+ ```js
27
+ import {
28
+ createCanvasIsland,
29
+ createCanvasSlot,
30
+ } from '@freelygive/canvas-utils/testing/canvas-slots';
31
+
32
+ // Build the HTML that Canvas would produce for a slot containing two items
33
+ const slotHtml = [
34
+ createCanvasIsland({ heading: 'First item', text: '<p>Content one.</p>' }),
35
+ createCanvasIsland({ heading: 'Second item', text: '<p>Content two.</p>' }),
36
+ ].join('');
37
+
38
+ // Wrap it in a React element that getSlotChildren() can parse
39
+ const canvasContent = createCanvasSlot(slotHtml);
40
+
41
+ // Pass to the component as the slot prop
42
+ export const WithCanvasSlot: Story = {
43
+ args: {
44
+ content: canvasContent,
45
+ },
46
+ };
47
+ ```
48
+
49
+ `rawProp(value)` is also exported for cases where you need to construct the
50
+ `["raw", value]` tuple format manually.
51
+
52
+ ### Simulating Editor Mode
53
+
54
+ Canvas editor mode changes component behaviour (e.g. expanding all accordion
55
+ items for editing). Use the `withEditorMode` decorator to enable editor mode
56
+ for the lifetime of a story:
57
+
58
+ ```js
59
+ import { withEditorMode } from '@freelygive/canvas-utils/testing/editor-mode';
60
+
61
+ export const InEditorMode: Story = {
62
+ args: { /* ... */ },
63
+ decorators: [withEditorMode],
64
+ };
65
+ ```
66
+
67
+ The decorator enables editor mode before the first render and cleans up on
68
+ unmount via `useEffect`, so state is restored even when tests are interrupted
69
+ or Storybook navigates away.
70
+
71
+ `enableEditorMode()` and `disableEditorMode()` are also exported for cases
72
+ that need direct control.
73
+
74
+ ## Setup
75
+
76
+ Add the package and configure npm-scaffold in your project's `package.json`:
77
+
78
+ ```json
79
+ {
80
+ "dependencies": {
81
+ "@freelygive/canvas-utils": "^0.1.0"
82
+ },
83
+ "scripts": {
84
+ "postinstall": "npm-scaffold"
85
+ },
86
+ "npmScaffold": {
87
+ "allowed-packages": ["@freelygive/canvas-utils"],
88
+ "base-path": "src"
89
+ }
90
+ }
91
+ ```
92
+
93
+ Running `npm install` will scaffold the utility components into
94
+ `src/components/` as symlinks and add them to `.gitignore`.
95
+
96
+ ## API Reference
97
+
98
+ ### canvas-slots
99
+
100
+ | Export | Description |
101
+ |---|---|
102
+ | `rawProp(value)` | Wraps a value in the `["raw", value]` tuple format used by Canvas island props |
103
+ | `createCanvasIsland(props)` | Creates a `<canvas-island>` HTML string from a props object |
104
+ | `createCanvasSlot(html)` | Creates a React element simulating a Canvas slot for `getSlotChildren()` to parse |
105
+
106
+ ### editor-mode
107
+
108
+ | Export | Description |
109
+ |---|---|
110
+ | `withEditorMode` | Storybook decorator — enables editor mode before render, cleans up on unmount |
111
+ | `enableEditorMode()` | Sets `window.drupalSettings.canvas` to simulate Canvas editor mode |
112
+ | `disableEditorMode()` | Removes `window.drupalSettings.canvas` |
@@ -1,6 +1,8 @@
1
+ import React from 'react';
2
+
1
3
  /**
2
- * Enable Canvas editor mode for a story
3
- * Sets drupalSettings.canvas and cleans up on unmount
4
+ * Enable Canvas editor mode
5
+ * Sets drupalSettings.canvas to simulate the Canvas page editor
4
6
  */
5
7
  declare const enableEditorMode: () => void;
6
8
  /**
@@ -8,5 +10,15 @@ declare const enableEditorMode: () => void;
8
10
  * Removes drupalSettings.canvas
9
11
  */
10
12
  declare const disableEditorMode: () => void;
13
+ /**
14
+ * Storybook decorator that enables Canvas editor mode for the lifetime of a
15
+ * story. Enables before the first render and cleans up on unmount, so editor
16
+ * state is restored even when tests are interrupted or Storybook navigates
17
+ * away.
18
+ *
19
+ * Usage:
20
+ * decorators: [withEditorMode]
21
+ */
22
+ declare const withEditorMode: (Story: React.ComponentType) => React.ReactElement<{}, string | React.JSXElementConstructor<any>> | null;
11
23
 
12
- export { disableEditorMode, enableEditorMode };
24
+ export { disableEditorMode, enableEditorMode, withEditorMode };
@@ -1,4 +1,5 @@
1
1
  // src/testing/editor-mode.ts
2
+ import React, { useEffect, useState } from "react";
2
3
  var enableEditorMode = () => {
3
4
  window.drupalSettings = {
4
5
  ...window.drupalSettings,
@@ -11,8 +12,18 @@ var disableEditorMode = () => {
11
12
  delete win.drupalSettings.canvas;
12
13
  }
13
14
  };
15
+ var withEditorMode = (Story) => {
16
+ const [ready] = useState(() => {
17
+ enableEditorMode();
18
+ return true;
19
+ });
20
+ useEffect(() => disableEditorMode, []);
21
+ if (!ready) return null;
22
+ return React.createElement(Story);
23
+ };
14
24
  export {
15
25
  disableEditorMode,
16
- enableEditorMode
26
+ enableEditorMode,
27
+ withEditorMode
17
28
  };
18
29
  //# sourceMappingURL=editor-mode.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/testing/editor-mode.ts"],"sourcesContent":["/**\n * Enable Canvas editor mode for a story\n * Sets drupalSettings.canvas and cleans up on unmount\n */\nexport const enableEditorMode = () => {\n (\n window as Window & { drupalSettings?: Record<string, unknown> }\n ).drupalSettings = {\n ...(window as Window & { drupalSettings?: Record<string, unknown> })\n .drupalSettings,\n canvas: {},\n };\n};\n\n/**\n * Disable Canvas editor mode\n * Removes drupalSettings.canvas\n */\nexport const disableEditorMode = () => {\n const win = window as Window & { drupalSettings?: { canvas?: unknown } };\n if (win.drupalSettings?.canvas !== undefined) {\n delete win.drupalSettings.canvas;\n }\n};\n"],"mappings":";AAIO,IAAM,mBAAmB,MAAM;AACpC,EACE,OACA,iBAAiB;AAAA,IACjB,GAAI,OACD;AAAA,IACH,QAAQ,CAAC;AAAA,EACX;AACF;AAMO,IAAM,oBAAoB,MAAM;AACrC,QAAM,MAAM;AACZ,MAAI,IAAI,gBAAgB,WAAW,QAAW;AAC5C,WAAO,IAAI,eAAe;AAAA,EAC5B;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/testing/editor-mode.ts"],"sourcesContent":["import React, { useEffect, useState } from 'react';\n\n/**\n * Enable Canvas editor mode\n * Sets drupalSettings.canvas to simulate the Canvas page editor\n */\nexport const enableEditorMode = () => {\n (\n window as Window & { drupalSettings?: Record<string, unknown> }\n ).drupalSettings = {\n ...(window as Window & { drupalSettings?: Record<string, unknown> })\n .drupalSettings,\n canvas: {},\n };\n};\n\n/**\n * Disable Canvas editor mode\n * Removes drupalSettings.canvas\n */\nexport const disableEditorMode = () => {\n const win = window as Window & { drupalSettings?: { canvas?: unknown } };\n if (win.drupalSettings?.canvas !== undefined) {\n delete win.drupalSettings.canvas;\n }\n};\n\n/**\n * Storybook decorator that enables Canvas editor mode for the lifetime of a\n * story. Enables before the first render and cleans up on unmount, so editor\n * state is restored even when tests are interrupted or Storybook navigates\n * away.\n *\n * Usage:\n * decorators: [withEditorMode]\n */\nexport const withEditorMode = (Story: React.ComponentType) => {\n const [ready] = useState(() => {\n enableEditorMode();\n return true;\n });\n useEffect(() => disableEditorMode, []);\n if (!ready) return null;\n return React.createElement(Story);\n};\n"],"mappings":";AAAA,OAAO,SAAS,WAAW,gBAAgB;AAMpC,IAAM,mBAAmB,MAAM;AACpC,EACE,OACA,iBAAiB;AAAA,IACjB,GAAI,OACD;AAAA,IACH,QAAQ,CAAC;AAAA,EACX;AACF;AAMO,IAAM,oBAAoB,MAAM;AACrC,QAAM,MAAM;AACZ,MAAI,IAAI,gBAAgB,WAAW,QAAW;AAC5C,WAAO,IAAI,eAAe;AAAA,EAC5B;AACF;AAWO,IAAM,iBAAiB,CAAC,UAA+B;AAC5D,QAAM,CAAC,KAAK,IAAI,SAAS,MAAM;AAC7B,qBAAiB;AACjB,WAAO;AAAA,EACT,CAAC;AACD,YAAU,MAAM,mBAAmB,CAAC,CAAC;AACrC,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,MAAM,cAAc,KAAK;AAClC;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freelygive/canvas-utils",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-dev.6dad937b",
4
4
  "description": "Canvas utility components and test helpers for Drupal Canvas projects.",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,32 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- /**
3
- * Detect if we're in Canvas page editor mode
4
- * Returns true when the component is being edited in the Canvas UI
5
- */ export const isCanvasEditorMode = ()=>{
6
- var _window_drupalSettings;
7
- if (typeof window === 'undefined') return false;
8
- // Check for drupalSettings.canvas (set in editor mode)
9
- if (((_window_drupalSettings = window.drupalSettings) === null || _window_drupalSettings === void 0 ? void 0 : _window_drupalSettings.canvas) !== undefined) {
10
- return true;
11
- }
12
- try {
13
- var _window_top_location, _window_top;
14
- const pathname = ((_window_top = window.top) === null || _window_top === void 0 ? void 0 : (_window_top_location = _window_top.location) === null || _window_top_location === void 0 ? void 0 : _window_top_location.pathname) || '';
15
- // Check if in Canvas editor (but not in component code editor)
16
- if (pathname.startsWith('/canvas/')) {
17
- return true;
18
- }
19
- } catch (unused) {
20
- // Cross-origin access blocked - likely in an iframe
21
- return false;
22
- }
23
- return false;
24
- };
25
- /**
26
- * Editor note component for displaying messages only visible in Canvas editor mode
27
- * Used to explain runtime behavior that differs from editor preview
28
- */ const EditorNote = ({ children })=>/*#__PURE__*/ _jsx("div", {
29
- className: "border-2 border-dashed border-black/30 bg-cream/50 px-6 py-4 text-sm text-black/70",
30
- children: children
31
- });
32
- export default EditorNote;
@@ -1,74 +0,0 @@
1
- function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
2
- try {
3
- var info = gen[key](arg);
4
- var value = info.value;
5
- } catch (error) {
6
- reject(error);
7
- return;
8
- }
9
- if (info.done) {
10
- resolve(value);
11
- } else {
12
- Promise.resolve(value).then(_next, _throw);
13
- }
14
- }
15
- function _async_to_generator(fn) {
16
- return function() {
17
- var self = this, args = arguments;
18
- return new Promise(function(resolve, reject) {
19
- var gen = fn.apply(self, args);
20
- function _next(value) {
21
- asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value);
22
- }
23
- function _throw(err) {
24
- asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err);
25
- }
26
- _next(undefined);
27
- });
28
- };
29
- }
30
- import { getPageData, JsonApiClient } from 'drupal-canvas';
31
- import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
32
- import useSWR from 'swr';
33
- /**
34
- * Fetch the main entity for the current page using SWR.
35
- * Uses Canvas mainEntity (when available) with fallback to title-based lookup.
36
- * @param {string} entityType - The JSON:API entity type (e.g., 'node--news_article')
37
- * @param {object} options - Fetch options
38
- * @param {string[]} options.includes - Relationships to include
39
- * @param {object} options.fields - Sparse fieldset map (e.g., { 'node--news_article': ['title'] })
40
- * @returns {{ data: object|undefined, error: Error|undefined, isLoading: boolean }}
41
- */ export const useMainEntity = (entityType, { includes = [], fields = {} } = {})=>{
42
- const pageData = getPageData();
43
- const { pageTitle } = pageData;
44
- // mainEntity will be available in future Canvas versions
45
- const mainEntity = pageData.mainEntity;
46
- const entityId = (mainEntity === null || mainEntity === void 0 ? void 0 : mainEntity.uuid) || pageTitle || null;
47
- return useSWR(entityId ? [
48
- 'mainEntity',
49
- entityType,
50
- entityId
51
- ] : null, ()=>_async_to_generator(function*() {
52
- const params = new DrupalJsonApiParams();
53
- if (mainEntity === null || mainEntity === void 0 ? void 0 : mainEntity.uuid) {
54
- params.addFilter('id', mainEntity.uuid);
55
- } else {
56
- params.addFilter('title', pageTitle);
57
- }
58
- if (includes.length > 0) {
59
- params.addInclude(includes);
60
- }
61
- for (const [type, fieldList] of Object.entries(fields)){
62
- params.addFields(type, fieldList);
63
- }
64
- const client = new JsonApiClient();
65
- const queryString = params.getQueryString();
66
- const data = yield client.getCollection(entityType, {
67
- queryString
68
- });
69
- return (data === null || data === void 0 ? void 0 : data[0]) || null;
70
- })());
71
- };
72
- // Empty default export for component compatibility
73
- const UtilsEntity = ()=>null;
74
- export default UtilsEntity;
@@ -1,79 +0,0 @@
1
- import React from 'react';
2
- /**
3
- * Flatten React children, handling nested fragments
4
- * Useful for processing slot content that may contain fragments
5
- * @param {React.ReactNode} children - React children to flatten
6
- * @returns {Array} - Flattened array of children
7
- */ const flattenChildren = (children)=>{
8
- const result = [];
9
- React.Children.forEach(children, (child)=>{
10
- if (!child) return;
11
- // Check if it's a Fragment (type is Symbol(react.fragment))
12
- if ((child === null || child === void 0 ? void 0 : child.type) === React.Fragment) {
13
- result.push(...flattenChildren(child.props.children));
14
- } else {
15
- result.push(child);
16
- }
17
- });
18
- return result;
19
- };
20
- /**
21
- * Parse Canvas slot format to extract component props
22
- * Canvas slots contain HTML with canvas-island elements that hold component data
23
- * @param {object} slot - The Canvas slot object
24
- * @returns {Array|null} - Array of parsed component objects with props, or null
25
- */ const parseCanvasSlot = (slot)=>{
26
- var _slot_props;
27
- // Check if this is Canvas slot format (object with props.value containing HTML)
28
- if (typeof slot !== 'object' || !(slot === null || slot === void 0 ? void 0 : (_slot_props = slot.props) === null || _slot_props === void 0 ? void 0 : _slot_props.value)) {
29
- return null;
30
- }
31
- const html = slot.props.value;
32
- if (typeof html !== 'string' || typeof window === 'undefined') {
33
- return null;
34
- }
35
- try {
36
- // Use DOMParser for reliable HTML parsing
37
- const parser = new DOMParser();
38
- const doc = parser.parseFromString(html, 'text/html');
39
- const islands = doc.querySelectorAll('canvas-island[props]');
40
- if (islands.length === 0) return null;
41
- const results = [];
42
- islands.forEach((island)=>{
43
- try {
44
- const propsAttr = island.getAttribute('props');
45
- if (!propsAttr) return;
46
- const rawProps = JSON.parse(propsAttr);
47
- // Convert from ["raw", value] format to just value
48
- const props = {};
49
- for (const [key, val] of Object.entries(rawProps)){
50
- if (Array.isArray(val) && val[0] === 'raw') {
51
- props[key] = val[1];
52
- } else {
53
- props[key] = val;
54
- }
55
- }
56
- results.push({
57
- props
58
- });
59
- } catch (unused) {
60
- // Skip malformed entries
61
- }
62
- });
63
- return results.length > 0 ? results : null;
64
- } catch (unused) {
65
- return null;
66
- }
67
- };
68
- /**
69
- * Get children from a slot - handles both Canvas slot format and React children
70
- * @param {object|React.ReactNode} slot - The slot content
71
- * @returns {Array} - Array of children (parsed objects or React elements)
72
- */ export const getSlotChildren = (slot)=>{
73
- if (!slot) return [];
74
- const parsed = parseCanvasSlot(slot);
75
- return parsed || flattenChildren(slot);
76
- };
77
- // Empty default export for component compatibility
78
- const UtilsSlots = ()=>null;
79
- export default UtilsSlots;