@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 +112 -0
- package/dist/editor-mode.d.ts +15 -3
- package/dist/editor-mode.js +12 -1
- package/dist/editor-mode.js.map +1 -1
- package/package.json +1 -1
- package/src/components/utils_editor_note/dist/index.js +0 -32
- package/src/components/utils_entity/dist/index.js +0 -74
- package/src/components/utils_slots/dist/index.js +0 -79
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` |
|
package/dist/editor-mode.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* Enable Canvas editor mode
|
|
3
|
-
* Sets drupalSettings.canvas
|
|
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 };
|
package/dist/editor-mode.js
CHANGED
|
@@ -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
|
package/dist/editor-mode.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/testing/editor-mode.ts"],"sourcesContent":["/**\n * Enable Canvas editor mode
|
|
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,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;
|