@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/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Change Log - @livepreso/content-react
2
2
 
3
- This log was last generated on Thu, 17 Sep 2026 01:30:15 GMT and should not be manually modified.
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
@@ -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
- return createPortal(children, window.Bridge.UI.overlay);
9
+ const overlay = window.Bridge.UI.overlay;
10
+ if (!overlay) {
11
+ return;
12
+ }
13
+ return createPortal(children, overlay);
10
14
  }
@@ -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
- // Defaults to the calling slide's path.
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.presenter.getPresentation().on("cmsValChanged", onCMSValChanged);
176
+ Bridge.CMSVals.on("changed", onCMSValChanged);
136
177
 
137
178
  return () => {
138
- Bridge.presenter.getPresentation().off("cmsValChanged", onCMSValChanged);
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) return;
48
+ if (isPresomanager) {
49
+ return;
50
+ }
49
51
 
50
52
  function onContextChange() {
51
53
  updateValue();
52
54
  }
53
55
 
54
- Bridge.presenter.getPresentation().on("change:context", onContextChange);
56
+ Bridge.Context.on("changed", onContextChange);
55
57
 
56
58
  return () => {
57
- Bridge.presenter.getPresentation().off("change:context", onContextChange);
59
+ Bridge.Context.off("changed", onContextChange);
58
60
  };
59
61
  }, [isPresomanager, value, getValue]);
60
62
 
@@ -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
- const prepKey = `${slideID}--${key}`;
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
+ });
@@ -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
+ });