@livepreso/content-react 2.3.1 → 3.0.1
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/shrinkwrap-deps.json +306 -10
- package/CHANGELOG.json +46 -0
- package/CHANGELOG.md +20 -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
|
@@ -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
|
+
});
|
|
@@ -13,7 +13,12 @@ export function useEditableFieldKey(key) {
|
|
|
13
13
|
const presomanagerTemplateKey = getPMTemplateFieldKey(key, slideID);
|
|
14
14
|
const userTemplateKey = getUserTemplateFieldKey(key, slideID);
|
|
15
15
|
|
|
16
|
-
if (isUserTemplate)
|
|
17
|
-
|
|
16
|
+
if (isUserTemplate) {
|
|
17
|
+
return userTemplateKey;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (isPMTemplate) {
|
|
21
|
+
return presomanagerTemplateKey;
|
|
22
|
+
}
|
|
18
23
|
return key;
|
|
19
24
|
}
|
|
@@ -164,8 +164,8 @@ export function useRootSlide(article) {
|
|
|
164
164
|
// If for whatever reason this isn't in the dom, we fall back to the ID.
|
|
165
165
|
presentationKey,
|
|
166
166
|
|
|
167
|
-
//
|
|
168
|
-
// "deck/section/slide"
|
|
167
|
+
// Reads data-slide-path from the article, in the form
|
|
168
|
+
// "deck/section/slide", which is the form Bridge commands expect.
|
|
169
169
|
slidePath,
|
|
170
170
|
|
|
171
171
|
isCurrentSlide: isCurrent,
|