@livepreso/content-react 2.3.1 → 3.0.0
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/.lintstagedrc.js +4 -4
- package/.rush/temp/package-deps_build.json +26 -0
- package/.rush/temp/package-deps_test.json +26 -0
- package/.rush/temp/shrinkwrap-deps.json +306 -10
- package/CHANGELOG.json +34 -0
- package/CHANGELOG.md +15 -1
- package/babel.config.js +8 -0
- package/jest.config.js +11 -0
- package/lib/components/OverlayPortal.js +5 -1
- package/lib/hooks/cmsvals.js +46 -5
- package/lib/hooks/cmsvals.spec.js +87 -0
- package/lib/hooks/cwe-editable.js +5 -3
- package/lib/hooks/index.js +3 -1
- package/lib/hooks/prep-editable.js +7 -6
- package/lib/hooks/prep-editable.spec.js +78 -0
- package/lib/hooks/slide.js +2 -2
- package/lib/hooks/use-actions.js +112 -0
- package/lib/hooks/use-actions.spec.js +150 -0
- package/lib/hooks/use-editable-field-key.js +7 -2
- package/lib/hooks/use-root-slide.js +2 -2
- package/lib/hooks/use-slide-context.js +8 -4
- package/lib/hooks/use-target-key.js +25 -0
- package/lib/hooks/use-target-key.spec.js +23 -0
- package/lib/utils.js +7 -2
- package/package.json +10 -4
- package/test/project.json +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
# Change Log - @livepreso/content-react
|
|
2
2
|
|
|
3
|
-
This log was last generated on
|
|
3
|
+
This log was last generated on Tue, 03 Nov 2026 22:02:25 GMT and should not be manually modified.
|
|
4
|
+
|
|
5
|
+
## 3.0.0
|
|
6
|
+
Tue, 03 Nov 2026 22:02:25 GMT
|
|
7
|
+
|
|
8
|
+
### Breaking changes
|
|
9
|
+
|
|
10
|
+
- Editable reads and writes go through the deck interface. Requires a deck built with webpack-deck 4.
|
|
11
|
+
|
|
12
|
+
### Minor changes
|
|
13
|
+
|
|
14
|
+
- Adds useActions for calling a deck's actions from a component, and useTargetKey for the key a component renders under.
|
|
15
|
+
- usePrepEditable supports a field edited on the everyone channel.
|
|
16
|
+
- A template placement is read-only while previewing.
|
|
17
|
+
- useActions registers its field with the host when the component mounts, and releases it when it unmounts.
|
|
4
18
|
|
|
5
19
|
## 2.3.1
|
|
6
20
|
Thu, 17 Sep 2026 01:30:15 GMT
|
package/babel.config.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Only the test run uses this. A deck's build resolves its babel config from
|
|
2
|
+
// its own root, so nothing here reaches the bundle.
|
|
3
|
+
module.exports = {
|
|
4
|
+
presets: [
|
|
5
|
+
["@babel/preset-env", { targets: { node: "current" } }],
|
|
6
|
+
["@babel/preset-react", { runtime: "automatic" }],
|
|
7
|
+
],
|
|
8
|
+
};
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// No jsdom and no renderer: the hooks under test hold no state of their own, so
|
|
2
|
+
// a spec calls them directly with the context hook they read stubbed out. A
|
|
3
|
+
// hook that later needs `useState` or `useMemo` will throw when called this
|
|
4
|
+
// way, which is the point at which one is worth setting up.
|
|
5
|
+
module.exports = {
|
|
6
|
+
testEnvironment: "node",
|
|
7
|
+
moduleNameMapper: {
|
|
8
|
+
// A deck's build resolves this to the project file it was built from.
|
|
9
|
+
"^@project$": "<rootDir>/test/project.json",
|
|
10
|
+
},
|
|
11
|
+
};
|
|
@@ -6,5 +6,9 @@ export function OverlayPortal({ children }) {
|
|
|
6
6
|
if ((!entered && !exiting) || exited) {
|
|
7
7
|
return;
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
const overlay = window.Bridge.UI.overlay;
|
|
10
|
+
if (!overlay) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
return createPortal(children, overlay);
|
|
10
14
|
}
|
package/lib/hooks/cmsvals.js
CHANGED
|
@@ -54,6 +54,24 @@ function useDefaultCMSValueFromPresomanager(id, { slidePath = null } = {}) {
|
|
|
54
54
|
return cmsVals?.find?.((c) => c.key === key)?.value ?? null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function isShowing(presentationKey) {
|
|
58
|
+
return Bridge.presenter.getPresentation().current?.key === presentationKey;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the app allows this field to be edited.
|
|
63
|
+
*
|
|
64
|
+
* True where nothing says otherwise, so a deck older than the check keeps
|
|
65
|
+
* working.
|
|
66
|
+
*/
|
|
67
|
+
export function useCanEditCMSValKey(id) {
|
|
68
|
+
const { slidePath } = useSlide();
|
|
69
|
+
|
|
70
|
+
// Neighbouring slides are mounted too, so the key resolves against this slide
|
|
71
|
+
// rather than whichever one the presenter is showing.
|
|
72
|
+
return Bridge.CMSVals?.canEditKey?.(id, { slidePath }) ?? true;
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
export function useCMSVal(
|
|
58
76
|
id,
|
|
59
77
|
{ enabled = true, slidePath: slidePathOverride = null } = {},
|
|
@@ -64,10 +82,11 @@ export function useCMSVal(
|
|
|
64
82
|
const {
|
|
65
83
|
isUserTemplate,
|
|
66
84
|
isUserTemplateLibrary,
|
|
85
|
+
presentationKey,
|
|
67
86
|
slidePath: ownSlidePath,
|
|
68
87
|
} = useSlide();
|
|
69
88
|
|
|
70
|
-
//
|
|
89
|
+
// A caller that passes no path gets the path of the slide it renders in.
|
|
71
90
|
const slidePath = slidePathOverride ?? ownSlidePath;
|
|
72
91
|
const [userTemplateValue, setUserTemplateValue] = useUserTemplateCMS(id);
|
|
73
92
|
const defaultFromPM = useDefaultCMSValueFromPresomanager(id, { slidePath });
|
|
@@ -95,6 +114,12 @@ export function useCMSVal(
|
|
|
95
114
|
return defaultFromPM;
|
|
96
115
|
}
|
|
97
116
|
|
|
117
|
+
// Neighbouring slides are mounted too, and the presenter resolves a key
|
|
118
|
+
// against the slide that is showing.
|
|
119
|
+
if (!slidePath && !isShowing(presentationKey)) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
98
123
|
return Bridge.CMSVals.get(id, { slidePath });
|
|
99
124
|
}, [
|
|
100
125
|
enabled,
|
|
@@ -102,11 +127,14 @@ export function useCMSVal(
|
|
|
102
127
|
defaultFromPM,
|
|
103
128
|
isUserTemplate,
|
|
104
129
|
isUserTemplateLibrary,
|
|
130
|
+
userTemplateValue,
|
|
105
131
|
isActiveSlide,
|
|
106
132
|
isPresomanager,
|
|
133
|
+
// Read off the article, so it can arrive after the first render.
|
|
134
|
+
presentationKey,
|
|
107
135
|
]);
|
|
108
136
|
|
|
109
|
-
const [value, setCMSValue] = useState(getCmsVal
|
|
137
|
+
const [value, setCMSValue] = useState(getCmsVal);
|
|
110
138
|
|
|
111
139
|
function updateCMSVal(value) {
|
|
112
140
|
if (isUserTemplate) {
|
|
@@ -114,15 +142,28 @@ export function useCMSVal(
|
|
|
114
142
|
} else {
|
|
115
143
|
Bridge.CMSVals.set(id, value, { slidePath });
|
|
116
144
|
}
|
|
117
|
-
setCMSValue(value);
|
|
118
145
|
}
|
|
119
146
|
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const stored = getCmsVal();
|
|
149
|
+
|
|
150
|
+
// undefined means the value cannot be read from here, so a slide part
|
|
151
|
+
// way out of view keeps what it had instead of blanking.
|
|
152
|
+
if (stored !== undefined) {
|
|
153
|
+
setCMSValue(stored);
|
|
154
|
+
}
|
|
155
|
+
}, [getCmsVal]);
|
|
156
|
+
|
|
120
157
|
useEffect(() => {
|
|
121
158
|
if (!enabled || !isActiveSlide) {
|
|
122
159
|
return;
|
|
123
160
|
}
|
|
124
161
|
|
|
125
162
|
function onCMSValChanged(key, newValue) {
|
|
163
|
+
if (!slidePath && !isShowing(presentationKey)) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
126
167
|
const fullKey = Bridge.CMSVals.getFullKey(id, { slidePath });
|
|
127
168
|
|
|
128
169
|
if (key !== fullKey) {
|
|
@@ -132,10 +173,10 @@ export function useCMSVal(
|
|
|
132
173
|
setCMSValue(newValue);
|
|
133
174
|
}
|
|
134
175
|
|
|
135
|
-
Bridge.
|
|
176
|
+
Bridge.CMSVals.on("changed", onCMSValChanged);
|
|
136
177
|
|
|
137
178
|
return () => {
|
|
138
|
-
Bridge.
|
|
179
|
+
Bridge.CMSVals.off("changed", onCMSValChanged);
|
|
139
180
|
};
|
|
140
181
|
}, [enabled, id, getCmsVal]);
|
|
141
182
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { act, renderHook } from "@testing-library/react";
|
|
6
|
+
|
|
7
|
+
import { useCMSVal } from "./cmsvals.js";
|
|
8
|
+
import { useModes } from "./use-modes.js";
|
|
9
|
+
import { useSlide, useSlideModel } from "./slide.js";
|
|
10
|
+
import { useSlideContext } from "./use-slide-context.js";
|
|
11
|
+
import { useTransitions } from "./use-transitions";
|
|
12
|
+
|
|
13
|
+
jest.mock("./use-modes.js", () => ({ useModes: jest.fn() }));
|
|
14
|
+
jest.mock("./slide.js", () => ({
|
|
15
|
+
useSlide: jest.fn(),
|
|
16
|
+
useSlideModel: jest.fn(),
|
|
17
|
+
}));
|
|
18
|
+
jest.mock("./use-slide-context.js", () => ({ useSlideContext: jest.fn() }));
|
|
19
|
+
jest.mock("./use-transitions", () => ({ useTransitions: jest.fn() }));
|
|
20
|
+
|
|
21
|
+
function stub({ showing = "a-preso", stored = null } = {}) {
|
|
22
|
+
const listeners = new Set();
|
|
23
|
+
let current = showing ? { key: showing } : null;
|
|
24
|
+
|
|
25
|
+
const presentation = {
|
|
26
|
+
on: (name, listener) => listeners.add(listener),
|
|
27
|
+
off: (name, listener) => listeners.delete(listener),
|
|
28
|
+
get current() {
|
|
29
|
+
return current;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
global.Bridge = {
|
|
34
|
+
CMSVals: {
|
|
35
|
+
get: jest.fn(() => stored),
|
|
36
|
+
set: jest.fn(),
|
|
37
|
+
getFullKey: (id) => `stored-${id}`,
|
|
38
|
+
on: (name, listener) => listeners.add(listener),
|
|
39
|
+
off: (name, listener) => listeners.delete(listener),
|
|
40
|
+
},
|
|
41
|
+
presenter: { getPresentation: () => presentation },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
useTransitions.mockReturnValue({ entered: true, entering: false });
|
|
45
|
+
useModes.mockReturnValue({ isCdk: true });
|
|
46
|
+
useSlideModel.mockReturnValue(null);
|
|
47
|
+
useSlideContext.mockReturnValue([null, jest.fn()]);
|
|
48
|
+
useSlide.mockReturnValue({ presentationKey: showing });
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
report: (key, value) => {
|
|
52
|
+
for (const listener of listeners) {
|
|
53
|
+
listener(key, value);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("useCMSVal", () => {
|
|
60
|
+
it("should read again once the slide it is on says which one it is", () => {
|
|
61
|
+
stub({ showing: "a-preso", stored: "what the store holds" });
|
|
62
|
+
useSlide.mockReturnValue({ presentationKey: null });
|
|
63
|
+
|
|
64
|
+
const { result, rerender } = renderHook(() => useCMSVal("a-note"));
|
|
65
|
+
|
|
66
|
+
expect(result.current[0]).toBeUndefined();
|
|
67
|
+
|
|
68
|
+
useSlide.mockReturnValue({ presentationKey: "a-preso" });
|
|
69
|
+
rerender();
|
|
70
|
+
|
|
71
|
+
expect(result.current[0]).toBe("what the store holds");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should show what the store reported rather than what was written", () => {
|
|
75
|
+
const { report } = stub({ stored: "what the store holds" });
|
|
76
|
+
|
|
77
|
+
const { result } = renderHook(() => useCMSVal("a-note"));
|
|
78
|
+
|
|
79
|
+
act(() => result.current[1]("typed"));
|
|
80
|
+
|
|
81
|
+
expect(result.current[0]).toBe("what the store holds");
|
|
82
|
+
|
|
83
|
+
act(() => report("stored-a-note", "what the store kept"));
|
|
84
|
+
|
|
85
|
+
expect(result.current[0]).toBe("what the store kept");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -45,16 +45,18 @@ export function useCWEEditableInfo(id) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
useEffect(() => {
|
|
48
|
-
if (isPresomanager)
|
|
48
|
+
if (isPresomanager) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
49
51
|
|
|
50
52
|
function onContextChange() {
|
|
51
53
|
updateValue();
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
Bridge.
|
|
56
|
+
Bridge.Context.on("changed", onContextChange);
|
|
55
57
|
|
|
56
58
|
return () => {
|
|
57
|
-
Bridge.
|
|
59
|
+
Bridge.Context.off("changed", onContextChange);
|
|
58
60
|
};
|
|
59
61
|
}, [isPresomanager, value, getValue]);
|
|
60
62
|
|
package/lib/hooks/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { useTransitions } from "./use-transitions.js";
|
|
|
9
9
|
export { useSubslides } from "./use-subslides.js";
|
|
10
10
|
export { useSlideDone } from "./use-slide-done.js";
|
|
11
11
|
export { useEditableFieldKey } from "./use-editable-field-key.js";
|
|
12
|
+
export { useTargetKey } from "./use-target-key.js";
|
|
12
13
|
export { useEditableInitialState } from "./use-editable-initial-state.js";
|
|
13
14
|
export {
|
|
14
15
|
usePrepEditable,
|
|
@@ -17,4 +18,5 @@ export {
|
|
|
17
18
|
usePrepEditableImage,
|
|
18
19
|
} from "./prep-editable.js";
|
|
19
20
|
export { useCWEEditableInfo, useCWEEditableValue } from "./cwe-editable.js";
|
|
20
|
-
export { useCMSVal } from "./cmsvals.js";
|
|
21
|
+
export { useCMSVal, useCanEditCMSValKey } from "./cmsvals.js";
|
|
22
|
+
export { useActions } from "./use-actions.js";
|
|
@@ -9,13 +9,14 @@ import { useEditableFieldKey } from "./use-editable-field-key";
|
|
|
9
9
|
* of the context to allow the user template magic to work, and this is the
|
|
10
10
|
* function to use to make that seamless.
|
|
11
11
|
*/
|
|
12
|
-
export function usePrepEditable(editableKey) {
|
|
13
|
-
const { isPresomanager } = useModes();
|
|
12
|
+
export function usePrepEditable(editableKey, { isGlobal = false } = {}) {
|
|
13
|
+
const { isPresomanager, isReadOnly: isPageReadOnly } = useModes();
|
|
14
14
|
const { slideID, isUserTemplateLibrary } = useSlide();
|
|
15
15
|
|
|
16
16
|
const key = useEditableFieldKey(editableKey, { isPrep: true });
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// Not owned by a slide, so every slide using this key sees one value.
|
|
19
|
+
const prepKey = isGlobal ? key : `${slideID}--${key}`;
|
|
19
20
|
const keyPath = `_editableContent.${prepKey}`;
|
|
20
21
|
|
|
21
22
|
const [obj, setObj] = useSlideContext(
|
|
@@ -25,7 +26,7 @@ export function usePrepEditable(editableKey) {
|
|
|
25
26
|
value: null,
|
|
26
27
|
},
|
|
27
28
|
{
|
|
28
|
-
enabled: !isPresomanager || isUserTemplateLibrary,
|
|
29
|
+
enabled: !isPageReadOnly && (!isPresomanager || isUserTemplateLibrary),
|
|
29
30
|
},
|
|
30
31
|
);
|
|
31
32
|
|
|
@@ -70,7 +71,7 @@ export function usePrepEditableValue(id, defaultValue = null) {
|
|
|
70
71
|
* Returns the URL of a prep editable dynamic image on the current slide.
|
|
71
72
|
*/
|
|
72
73
|
export function usePrepEditableImage(editableKey) {
|
|
73
|
-
const { isPresomanager } = useModes();
|
|
74
|
+
const { isPresomanager, isReadOnly: isPageReadOnly } = useModes();
|
|
74
75
|
const { slideID, isUserTemplateLibrary } = useSlide();
|
|
75
76
|
|
|
76
77
|
const key = useEditableFieldKey(editableKey, { isPrep: true });
|
|
@@ -82,7 +83,7 @@ export function usePrepEditableImage(editableKey) {
|
|
|
82
83
|
keyPath,
|
|
83
84
|
{ image: null },
|
|
84
85
|
{
|
|
85
|
-
enabled: !isPresomanager || isUserTemplateLibrary,
|
|
86
|
+
enabled: !isPageReadOnly && (!isPresomanager || isUserTemplateLibrary),
|
|
86
87
|
},
|
|
87
88
|
);
|
|
88
89
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useModes } from "./use-modes.js";
|
|
2
|
+
import { usePrepEditable } from "./prep-editable.js";
|
|
3
|
+
import { useSlide } from "./slide.js";
|
|
4
|
+
import { useSlideContext } from "./use-slide-context.js";
|
|
5
|
+
|
|
6
|
+
jest.mock("./use-modes.js", () => ({ useModes: jest.fn() }));
|
|
7
|
+
jest.mock("./slide.js", () => ({ useSlide: jest.fn() }));
|
|
8
|
+
jest.mock("./use-slide-context.js", () => ({ useSlideContext: jest.fn() }));
|
|
9
|
+
|
|
10
|
+
function stub() {
|
|
11
|
+
useModes.mockReturnValue({ isPresomanager: false });
|
|
12
|
+
useSlide.mockReturnValue({
|
|
13
|
+
slideID: "opening",
|
|
14
|
+
isPMTemplate: false,
|
|
15
|
+
isUserTemplate: false,
|
|
16
|
+
isUserTemplateLibrary: false,
|
|
17
|
+
});
|
|
18
|
+
useSlideContext.mockReturnValue([{ value: "Stored" }, jest.fn()]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("usePrepEditable", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
stub();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should keep a value against the slide it was written on", () => {
|
|
28
|
+
usePrepEditable("note");
|
|
29
|
+
|
|
30
|
+
expect(useSlideContext).toHaveBeenCalledWith(
|
|
31
|
+
"_editableContent.opening--note",
|
|
32
|
+
expect.anything(),
|
|
33
|
+
expect.anything(),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should offer a prep editable on a user template being edited", () => {
|
|
38
|
+
useModes.mockReturnValue({ isPresomanager: true, isReadOnly: false });
|
|
39
|
+
useSlide.mockReturnValue({
|
|
40
|
+
slideID: "opening",
|
|
41
|
+
isUserTemplateLibrary: true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
usePrepEditable("note");
|
|
45
|
+
|
|
46
|
+
expect(useSlideContext).toHaveBeenCalledWith(
|
|
47
|
+
expect.anything(),
|
|
48
|
+
expect.anything(),
|
|
49
|
+
{ enabled: true },
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should offer none on a template preview that stores nothing", () => {
|
|
54
|
+
useModes.mockReturnValue({ isPresomanager: true, isReadOnly: true });
|
|
55
|
+
useSlide.mockReturnValue({
|
|
56
|
+
slideID: "opening",
|
|
57
|
+
isUserTemplateLibrary: true,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
usePrepEditable("note");
|
|
61
|
+
|
|
62
|
+
expect(useSlideContext).toHaveBeenCalledWith(
|
|
63
|
+
expect.anything(),
|
|
64
|
+
expect.anything(),
|
|
65
|
+
{ enabled: false },
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should keep a global value under its bare key", () => {
|
|
70
|
+
usePrepEditable("note", { isGlobal: true });
|
|
71
|
+
|
|
72
|
+
expect(useSlideContext).toHaveBeenCalledWith(
|
|
73
|
+
"_editableContent.note",
|
|
74
|
+
expect.anything(),
|
|
75
|
+
expect.anything(),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
package/lib/hooks/slide.js
CHANGED
|
@@ -6,8 +6,8 @@ export function useSlide() {
|
|
|
6
6
|
const {
|
|
7
7
|
slideID,
|
|
8
8
|
slideKey,
|
|
9
|
-
presentationKey,
|
|
10
9
|
slidePath,
|
|
10
|
+
presentationKey,
|
|
11
11
|
isSlideReady,
|
|
12
12
|
doneRegister,
|
|
13
13
|
isCurrentSlide,
|
|
@@ -22,8 +22,8 @@ export function useSlide() {
|
|
|
22
22
|
return {
|
|
23
23
|
slideID,
|
|
24
24
|
slideKey,
|
|
25
|
-
presentationKey,
|
|
26
25
|
slidePath,
|
|
26
|
+
presentationKey,
|
|
27
27
|
isSlideReady,
|
|
28
28
|
doneRegister,
|
|
29
29
|
isCurrentSlide,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useEffect, useMemo, useReducer } from "react";
|
|
2
|
+
import { bindActions, provideRendered } from "@livepreso/webpack-deck";
|
|
3
|
+
|
|
4
|
+
import { useSlide } from "./slide.js";
|
|
5
|
+
import { useTargetKey } from "./use-target-key.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The queries and mutations this editable declares, bound to
|
|
9
|
+
* wherever it is stored.
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* const { queries, mutations } = useActions(editable, props);
|
|
13
|
+
*
|
|
14
|
+
* value={queries.get()} onChange={(html) => mutations.set({ html })}
|
|
15
|
+
* await queries.getAsync() await mutations.set({ html })
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* A person editing the content reaches the mutation an assistant calls, which
|
|
19
|
+
* is why a component is never handed a plain setter.
|
|
20
|
+
*/
|
|
21
|
+
export function useActions(
|
|
22
|
+
source,
|
|
23
|
+
args,
|
|
24
|
+
{ defaultValue = null, field, rendered, onValidationError } = {},
|
|
25
|
+
) {
|
|
26
|
+
const { slidePath } = useSlide();
|
|
27
|
+
const targetKey = useTargetKey();
|
|
28
|
+
|
|
29
|
+
const bound = useMemo(
|
|
30
|
+
() =>
|
|
31
|
+
bindActions(source, args, {
|
|
32
|
+
targetKey,
|
|
33
|
+
slidePath,
|
|
34
|
+
defaultValue,
|
|
35
|
+
field,
|
|
36
|
+
isRegistered: false,
|
|
37
|
+
}),
|
|
38
|
+
// The declaration and the args are the same objects every render, and the
|
|
39
|
+
// binding reads its stores on every call, so only where it stores can
|
|
40
|
+
// change under it.
|
|
41
|
+
[targetKey, slidePath],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
useRendered(bound.key, rendered);
|
|
45
|
+
|
|
46
|
+
// Lists a field the build missed while it is on screen, so it can be edited.
|
|
47
|
+
useEffect(() => bound.register(), [bound]);
|
|
48
|
+
|
|
49
|
+
const [, render] = useReducer((count) => count + 1, 0);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
bound.on("changed", render);
|
|
53
|
+
|
|
54
|
+
return () => bound.off("changed", render);
|
|
55
|
+
}, [bound]);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!onValidationError) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
bound.on("validation-error", onValidationError);
|
|
63
|
+
|
|
64
|
+
return () => bound.off("validation-error", onValidationError);
|
|
65
|
+
}, [bound, onValidationError]);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
channel: bound.channel,
|
|
69
|
+
errors: bound.errors,
|
|
70
|
+
queries: bound.queries,
|
|
71
|
+
mutations: bound.mutations,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Holds what a field has while it is on screen, for the entries that name it.
|
|
77
|
+
*
|
|
78
|
+
* The nodes are read on every render because a ref is filled after the render
|
|
79
|
+
* that created it, so reading during one would hold null.
|
|
80
|
+
*/
|
|
81
|
+
function useRendered(fieldKey, refs) {
|
|
82
|
+
const names = Object.keys(refs ?? {});
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (names.length === 0) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return provideRendered(
|
|
90
|
+
fieldKey,
|
|
91
|
+
Object.fromEntries(
|
|
92
|
+
names.map((name) => [name, readRenderedValue(refs[name])]),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A ref gives its element. A flag such as `always: true` passes through, so an entry can
|
|
100
|
+
* require the slide on screen.
|
|
101
|
+
*/
|
|
102
|
+
function readRenderedValue(value) {
|
|
103
|
+
if (isRef(value)) {
|
|
104
|
+
return value.current;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isRef(value) {
|
|
111
|
+
return typeof value === "object" && value !== null && "current" in value;
|
|
112
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { act, renderHook } from "@testing-library/react";
|
|
6
|
+
import {
|
|
7
|
+
defineEditableSource,
|
|
8
|
+
defineActions,
|
|
9
|
+
prop,
|
|
10
|
+
} from "@livepreso/webpack-deck";
|
|
11
|
+
|
|
12
|
+
import { useActions } from "./use-actions.js";
|
|
13
|
+
import { useSlide } from "./slide.js";
|
|
14
|
+
import { useTargetKey } from "./use-target-key.js";
|
|
15
|
+
|
|
16
|
+
jest.mock("./slide.js", () => ({ useSlide: jest.fn() }));
|
|
17
|
+
jest.mock("./use-target-key.js", () => ({ useTargetKey: jest.fn() }));
|
|
18
|
+
|
|
19
|
+
function Field() {}
|
|
20
|
+
|
|
21
|
+
function aNoteField() {
|
|
22
|
+
return defineEditableSource(Field, {
|
|
23
|
+
fields: { id: prop("name") },
|
|
24
|
+
actions: defineActions({
|
|
25
|
+
defaultState: "",
|
|
26
|
+
mime: "text/plain",
|
|
27
|
+
description: "A note.",
|
|
28
|
+
queries: { get: ({ state }) => state },
|
|
29
|
+
mutations: { set: ({ input }) => input.text.trim() },
|
|
30
|
+
}),
|
|
31
|
+
everyone: { when: prop("isCompany"), key: "{targetKey}-{id}" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stub({
|
|
36
|
+
state = null,
|
|
37
|
+
channel = "everyone",
|
|
38
|
+
applyResult = { errors: {} },
|
|
39
|
+
} = {}) {
|
|
40
|
+
const listeners = new Set();
|
|
41
|
+
|
|
42
|
+
const versionOne = {
|
|
43
|
+
read: jest.fn().mockReturnValue({ state, channel, provided: {} }),
|
|
44
|
+
apply: jest.fn().mockResolvedValue(applyResult),
|
|
45
|
+
on: (name, listener) => listeners.add(listener),
|
|
46
|
+
off: (name, listener) => listeners.delete(listener),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
global.Bridge = { Interface: { open: () => versionOne } };
|
|
50
|
+
|
|
51
|
+
useTargetKey.mockReturnValue("a-target");
|
|
52
|
+
useSlide.mockReturnValue({ slidePath: "a-deck/a-section/a-target" });
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
versionOne,
|
|
56
|
+
raiseChanged: (changed) => {
|
|
57
|
+
for (const listener of listeners) {
|
|
58
|
+
listener(changed);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function aDrawnField() {
|
|
65
|
+
return defineEditableSource(Field, {
|
|
66
|
+
fields: { id: prop("name") },
|
|
67
|
+
actions: defineActions({
|
|
68
|
+
queries: { readDrawn: ({ rendered }) => rendered.drawn },
|
|
69
|
+
}),
|
|
70
|
+
everyone: { key: "{targetKey}-{id}" },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function renderField(options) {
|
|
75
|
+
return renderHook(() =>
|
|
76
|
+
useActions(aNoteField(), { name: "a-field", isCompany: true }, options),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("useActions", () => {
|
|
81
|
+
it("should give back what the binding reads", () => {
|
|
82
|
+
stub({ state: "Stored" });
|
|
83
|
+
|
|
84
|
+
expect(renderField().result.current.queries.get()).toBe("Stored");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should say which channel the field stores on", () => {
|
|
88
|
+
stub({ channel: "personal" });
|
|
89
|
+
|
|
90
|
+
expect(renderField().result.current.channel).toBe("personal");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should read the channel fresh once the presenter has answered it", () => {
|
|
94
|
+
const { raiseChanged, versionOne } = stub({ channel: null });
|
|
95
|
+
const field = renderField();
|
|
96
|
+
|
|
97
|
+
// While the presenter reports no channel, the field takes the declared one.
|
|
98
|
+
expect(field.result.current.channel).toBe("everyone");
|
|
99
|
+
|
|
100
|
+
versionOne.read.mockReturnValue({
|
|
101
|
+
state: "Stored",
|
|
102
|
+
channel: "personal",
|
|
103
|
+
provided: {},
|
|
104
|
+
});
|
|
105
|
+
act(() =>
|
|
106
|
+
raiseChanged({
|
|
107
|
+
slidePath: "a-deck/a-section/a-target",
|
|
108
|
+
field: "a-target-a-field",
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(field.result.current.channel).toBe("personal");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should render again once a mutation's errors change", async () => {
|
|
116
|
+
stub({ applyResult: { errors: { "too-long": {} } } });
|
|
117
|
+
const field = renderField();
|
|
118
|
+
|
|
119
|
+
await act(() => field.result.current.mutations.set({ text: "New" }));
|
|
120
|
+
|
|
121
|
+
expect(field.result.current.errors.set).toEqual({ "too-long": {} });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it.each([
|
|
125
|
+
["a ref, as its element", { current: "an-element" }, "an-element"],
|
|
126
|
+
["a plain value, as it is", true, true],
|
|
127
|
+
])("should hand a query reading rendered %s", (_label, drawn, expected) => {
|
|
128
|
+
stub();
|
|
129
|
+
|
|
130
|
+
const field = renderHook(() =>
|
|
131
|
+
useActions(aDrawnField(), { name: "a-field" }, { rendered: { drawn } }),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(field.result.current.queries.readDrawn()).toBe(expected);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should tell onValidationError about a call that failed a rule", async () => {
|
|
138
|
+
stub({ applyResult: { errors: { "too-long": {} } } });
|
|
139
|
+
const onValidationError = jest.fn();
|
|
140
|
+
const field = renderField({ onValidationError });
|
|
141
|
+
|
|
142
|
+
await act(() => field.result.current.mutations.set({ text: "New" }));
|
|
143
|
+
|
|
144
|
+
expect(onValidationError).toHaveBeenCalledWith({
|
|
145
|
+
operation: "set",
|
|
146
|
+
input: { text: "New" },
|
|
147
|
+
errors: { "too-long": {} },
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|