@applicaster/zapp-react-native-utils 16.0.0-alpha.1263110541 → 16.0.0-alpha.1314750780

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.
Files changed (120) hide show
  1. package/actionUtils/index.ts +3 -1
  2. package/actionsExecutor/ActionExecutor.ts +10 -1
  3. package/actionsExecutor/ActionExecutorContext.tsx +13 -1
  4. package/actionsExecutor/__tests__/ActionExecutor.test.ts +71 -0
  5. package/actionsExecutor/__tests__/StorageActions.test.ts +98 -0
  6. package/actionsExecutor/__tests__/feedDecorator.test.ts +30 -0
  7. package/actionsExecutor/__tests__/screenToggleFlag.test.ts +90 -0
  8. package/actionsExecutor/actions/__tests__/dismissBottomSheet.test.ts +3 -0
  9. package/actionsExecutor/actions/__tests__/navigateToScreen.test.ts +186 -5
  10. package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +24 -0
  11. package/actionsExecutor/actions/__tests__/openBottomSheet.inlineItems.test.ts +273 -1
  12. package/actionsExecutor/actions/__tests__/presentScreen.test.ts +127 -0
  13. package/actionsExecutor/actions/__tests__/showAlert.test.ts +49 -0
  14. package/actionsExecutor/actions/__tests__/showToast.test.ts +2 -2
  15. package/actionsExecutor/actions/dismissBottomSheet.ts +2 -1
  16. package/actionsExecutor/actions/index.ts +4 -0
  17. package/actionsExecutor/actions/navigateToScreen.ts +33 -43
  18. package/actionsExecutor/actions/openBottomSheet.ts +36 -16
  19. package/actionsExecutor/actions/presentScreen.ts +78 -0
  20. package/actionsExecutor/actions/screenToggleFlag.ts +1 -3
  21. package/actionsExecutor/actions/showAlert.ts +51 -0
  22. package/actionsExecutor/actions/showToast.ts +2 -2
  23. package/actionsExecutor/consts.ts +2 -0
  24. package/actionsExecutor/feedDecorator.ts +6 -0
  25. package/actionsExecutor/types.ts +7 -0
  26. package/appUtils/contextKeysManager/__tests__/getKey/failure.test.ts +1 -22
  27. package/appUtils/contextKeysManager/__tests__/getKey/success/fullSearch.test.ts +1 -25
  28. package/appUtils/contextKeysManager/__tests__/getKey/success/searchInSecure.test.ts +1 -7
  29. package/appUtils/contextKeysManager/__tests__/removeKey/failure.test.ts +0 -25
  30. package/appUtils/contextKeysManager/__tests__/removeKey/success.test.ts +0 -6
  31. package/appUtils/contextKeysManager/__tests__/setKey/failure/failedToSaveInStorage.test.ts +0 -40
  32. package/appUtils/contextKeysManager/__tests__/setKey/{success/saveItemAndReference.test.ts → success.test.ts} +2 -42
  33. package/appUtils/contextKeysManager/index.ts +28 -129
  34. package/appUtils/playerManager/OverlayObserver/OverlaysObserver.ts +4 -11
  35. package/appUtils/playerManager/OverlayObserver/__tests__/getUpcomingQueue.test.ts +67 -0
  36. package/appUtils/playerManager/OverlayObserver/__tests__/utils.test.ts +9 -2
  37. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ts +15 -2
  38. package/appUtils/playerManager/OverlayObserver/utils.ts +2 -5
  39. package/appUtils/playerManager/__tests__/playerDiagnostics.test.ts +62 -0
  40. package/appUtils/playerManager/__tests__/playerNative.test.ts +76 -0
  41. package/appUtils/playerManager/__tests__/playerNativeCommand.test.ts +62 -0
  42. package/appUtils/playerManager/__tests__/playerToolbox.test.ts +281 -0
  43. package/appUtils/playerManager/__tests__/usePlayerControllerSetup.test.tsx +62 -0
  44. package/appUtils/playerManager/const.ts +13 -0
  45. package/appUtils/playerManager/index.ts +7 -0
  46. package/appUtils/playerManager/player.ts +25 -0
  47. package/appUtils/playerManager/playerNative.ts +22 -0
  48. package/appUtils/playerManager/playerNativeCommand.ts +3 -6
  49. package/appUtils/playerManager/playerToolbox.ts +196 -0
  50. package/appUtils/playerManager/usePlayerControllerSetup.tsx +6 -0
  51. package/arrayUtils/__tests__/reorderByIds.test.ts +13 -0
  52. package/arrayUtils/index.ts +26 -43
  53. package/cellUtils/__tests__/getButtonsEnabled.test.ts +28 -0
  54. package/cellUtils/__tests__/getCellNavigationEnabled.test.ts +36 -0
  55. package/cellUtils/__tests__/getIsCellNavigationEnabled.test.ts +35 -0
  56. package/cellUtils/__tests__/isCellNavigationEnabled.test.ts +21 -0
  57. package/cellUtils/index.ts +40 -0
  58. package/configurationUtils/__tests__/manifestKeyParser.test.ts +21 -0
  59. package/configurationUtils/__tests__/modalBlocksParser.test.ts +297 -1
  60. package/configurationUtils/manifestKeyParser.ts +12 -1
  61. package/configurationUtils/modalBlocksParser.ts +78 -4
  62. package/entryActions/__tests__/buildEntryActions.test.ts +97 -18
  63. package/entryActions/aliasPresentation.ts +26 -42
  64. package/entryActions/buildEntryActions.ts +1 -0
  65. package/entryActions/index.ts +0 -2
  66. package/focusManager/FocusManager.ts +26 -0
  67. package/focusManager/__tests__/FocusManager.test.ts +9 -0
  68. package/localizationUtils/index.ts +12 -6
  69. package/logger/__tests__/xrayToolbox.test.ts +69 -0
  70. package/logger/xrayToolbox.ts +67 -0
  71. package/manifestUtils/defaultManifestConfigurations/generalContent.js +6 -0
  72. package/manifestUtils/defaultManifestConfigurations/player.js +8 -0
  73. package/manifestUtils/keys.js +7 -2
  74. package/manifestUtils/mobileAction/container/__tests__/mobileActionButtonsContainer.test.js +21 -0
  75. package/manifestUtils/mobileAction/groups/__tests__/buildMobileActionButtonGroups.test.js +1 -1
  76. package/manifestUtils/mobileAction/groups/defaults.js +2 -1
  77. package/modalState/ModalOrchestrator.ts +23 -9
  78. package/modalState/RemoteEditableCollection.ts +9 -1
  79. package/modalState/__tests__/ModalOrchestrator.phase3.test.ts +39 -0
  80. package/modalState/__tests__/RemoteEditableCollection.test.ts +90 -1
  81. package/modalState/__tests__/index.test.ts +30 -1
  82. package/modalState/__tests__/useModalStoreState.test.ts +142 -0
  83. package/modalState/index.ts +8 -1
  84. package/modalState/store.ts +9 -2
  85. package/modalState/types.ts +42 -2
  86. package/modalState/useBottomSheetContent.ts +24 -17
  87. package/navigationUtils/xrayNavigator.ts +12 -0
  88. package/package.json +3 -2
  89. package/pluginUtils/__tests__/pluginUtils.test.js +32 -0
  90. package/pluginUtils/index.ts +29 -0
  91. package/quickBrickAssets/__tests__/index.test.ts +18 -0
  92. package/quickBrickAssets/index.ts +35 -0
  93. package/reactHooks/__tests__/getPluginConfiguration.test.ts +82 -0
  94. package/reactHooks/actions/index.ts +29 -23
  95. package/reactHooks/cell-click/__tests__/index.test.js +131 -1
  96. package/reactHooks/cell-click/index.ts +30 -15
  97. package/reactHooks/connection/index.tsx +8 -5
  98. package/reactHooks/feed/__tests__/feedInvalidation.test.tsx +338 -0
  99. package/reactHooks/feed/__tests__/useBatchLoading.test.tsx +8 -2
  100. package/reactHooks/feed/__tests__/useMarkPipesDataStale.test.tsx +243 -0
  101. package/reactHooks/feed/useFeedLoader.tsx +28 -1
  102. package/reactHooks/feed/useLoadPipesDataDispatch.ts +12 -11
  103. package/reactHooks/feed/useMarkPipesDataStale.ts +68 -23
  104. package/reactHooks/navigation/__tests__/index.test.tsx +176 -1
  105. package/reactHooks/navigation/__tests__/usePresentScreen.test.ts +154 -0
  106. package/reactHooks/navigation/index.ts +2 -0
  107. package/reactHooks/navigation/usePresentScreen.ts +157 -0
  108. package/reactHooks/navigation/useRoute.ts +22 -5
  109. package/reactHooks/usePluginConfiguration.ts +84 -46
  110. package/riverComponetsMeasurementProvider/index.tsx +3 -1
  111. package/uiActionsRegistrator/__tests__/subscribe.test.ts +26 -0
  112. package/uiActionsRegistrator/registry.ts +16 -0
  113. package/appUtils/contextKeysManager/__tests__/getKey/success/byReference.test.ts +0 -56
  114. package/appUtils/contextKeysManager/__tests__/getKeyByReference/failure.test.ts +0 -265
  115. package/appUtils/contextKeysManager/__tests__/getKeyByReference/success.test.ts +0 -303
  116. package/appUtils/contextKeysManager/__tests__/removeReferenceForKey/failure.test.ts +0 -82
  117. package/appUtils/contextKeysManager/__tests__/removeReferenceForKey/success.test.ts +0 -41
  118. package/appUtils/contextKeysManager/__tests__/setKey/success/saveItemButNotReference.test.ts +0 -208
  119. package/appUtils/contextKeysManager/__tests__/setReferenceForKey/failure.test.ts +0 -93
  120. package/appUtils/contextKeysManager/__tests__/setReferenceForKey/success.test.ts +0 -45
@@ -3,5 +3,7 @@ import { isTV } from "../reactUtils";
3
3
 
4
4
  export const resolveDefaultAssetUri = (source: string | number): string =>
5
5
  isTV()
6
- ? (source as string)
6
+ ? typeof source === "number"
7
+ ? Image.resolveAssetSource(source)?.uri || ""
8
+ : (source as string)
7
9
  : Image.resolveAssetSource(source as number)?.uri || "";
@@ -56,7 +56,6 @@ export class ActionExecutor {
56
56
  context?: ActionExecutionContext
57
57
  ): Promise<ActionResult> => {
58
58
  for (const action of actionTypes) {
59
- // TODO: Handle action result error
60
59
  const result = await this.handleAction(action, context);
61
60
 
62
61
  if (result === ActionResult.Cancel) {
@@ -64,6 +63,16 @@ export class ActionExecutor {
64
63
 
65
64
  return result;
66
65
  }
66
+
67
+ if (result === ActionResult.Error) {
68
+ // Everything after a failed step was written for a state that never
69
+ // happened — the event was refused, the screen never opened — so
70
+ // running it is how a chain ends up telling the user their mail was
71
+ // sent when it was not.
72
+ log_error(`handleActions: action: ${action.type} failed`, { action });
73
+
74
+ return result;
75
+ }
67
76
  }
68
77
 
69
78
  return ActionResult.Success;
@@ -9,7 +9,7 @@ import { ActionExecutionContext, ActionHandler } from "./types";
9
9
  import { batchRemoveAllFromNamespaceForStorage } from "../zappFrameworkUtils/localStorageHelper";
10
10
  import { sessionStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/SessionStorage";
11
11
 
12
- import { useNavigation, useRivers } from "../reactHooks";
12
+ import { useNavigation, usePresentScreen, useRivers } from "../reactHooks";
13
13
  import { useContentTypes } from "@applicaster/zapp-react-native-redux/hooks";
14
14
  import { useSubscriberFor } from "../reactHooks/useSubscriberFor";
15
15
  import { APP_EVENTS } from "../appUtils/events";
@@ -18,6 +18,7 @@ import { createLogger } from "../logger";
18
18
  import {
19
19
  appRestartAction,
20
20
  confirmDialogAction,
21
+ showAlertAction,
21
22
  createGoBackAction,
22
23
  createGoHomeAction,
23
24
  createNavigateToScreenAction,
@@ -25,6 +26,7 @@ import {
25
26
  localStorageRemoveAction,
26
27
  localStorageSetAction,
27
28
  localStorageToggleFlagAction,
29
+ createPresentScreenAction,
28
30
  openBottomSheetAction,
29
31
  refreshComponentAction,
30
32
  screenSetVariableAction,
@@ -109,6 +111,8 @@ const prepareDefaultActions = (actionExecutor) => {
109
111
  confirmDialogAction
110
112
  );
111
113
 
114
+ actionExecutor.registerAction(ACTION_TYPES.SHOW_ALERT, showAlertAction);
115
+
112
116
  actionExecutor.registerAction(
113
117
  ACTION_TYPES.SEND_CLOUD_EVENT,
114
118
  sendCloudEventAction
@@ -157,6 +161,7 @@ export function withActionExecutor(Component) {
157
161
  const navigator = useNavigation();
158
162
  const rivers = useRivers();
159
163
  const contentTypes = useContentTypes();
164
+ const presentScreen = usePresentScreen();
160
165
 
161
166
  const handlers = useMemo(() => {
162
167
  return {
@@ -191,6 +196,13 @@ export function withActionExecutor(Component) {
191
196
  );
192
197
  }, [navigator, rivers, contentTypes]);
193
198
 
199
+ useEffect(() => {
200
+ return _actionExecutor.registerAction(
201
+ ACTION_TYPES.PRESENT_SCREEN,
202
+ createPresentScreenAction(presentScreen)
203
+ );
204
+ }, [presentScreen]);
205
+
194
206
  useEffect(() => {
195
207
  return _actionExecutor.registerAction(
196
208
  ACTION_TYPES.GO_BACK,
@@ -0,0 +1,71 @@
1
+ import { ActionExecutor, ActionResult } from "../ActionExecutor";
2
+
3
+ jest.mock("../../logger", () => ({
4
+ createLogger: () => ({
5
+ log_error: jest.fn(),
6
+ log_debug: jest.fn(),
7
+ log_info: jest.fn(),
8
+ }),
9
+ }));
10
+
11
+ describe("handleActions", () => {
12
+ const executor = () => {
13
+ const ran: string[] = [];
14
+ const actionExecutor = new ActionExecutor();
15
+
16
+ const register = (type: string, result?: ActionResult) =>
17
+ actionExecutor.registerAction(type, async () => {
18
+ ran.push(type);
19
+
20
+ return result as ActionResult;
21
+ });
22
+
23
+ register("ok", ActionResult.Success);
24
+ register("fails", ActionResult.Error);
25
+ register("cancels", ActionResult.Cancel);
26
+ register("recover", ActionResult.Success);
27
+ register("after", ActionResult.Success);
28
+
29
+ return { actionExecutor, ran };
30
+ };
31
+
32
+ // Everything after a failed step was written for a state that never
33
+ // happened: the event was refused, the screen never opened.
34
+ it("stops when an action fails", async () => {
35
+ const { actionExecutor, ran } = executor();
36
+
37
+ const result = await actionExecutor.handleActions([
38
+ { type: "fails" },
39
+ { type: "after" },
40
+ ]);
41
+
42
+ expect(ran).toEqual(["fails"]);
43
+ expect(result).toBe(ActionResult.Error);
44
+ });
45
+
46
+ it("still stops on a cancel", async () => {
47
+ const { actionExecutor, ran } = executor();
48
+
49
+ const result = await actionExecutor.handleActions([
50
+ { type: "cancels" },
51
+ { type: "after" },
52
+ ]);
53
+
54
+ expect(ran).toEqual(["cancels"]);
55
+ expect(result).toBe(ActionResult.Cancel);
56
+ });
57
+
58
+ // An action nobody registered never ran, which is a misconfigured feed
59
+ // rather than a step that failed. It is stepped over, as it always was.
60
+ it("steps over an action with no handler", async () => {
61
+ const { actionExecutor, ran } = executor();
62
+
63
+ const result = await actionExecutor.handleActions([
64
+ { type: "nobody-registered-this" },
65
+ { type: "after" },
66
+ ]);
67
+
68
+ expect(ran).toEqual(["after"]);
69
+ expect(result).toBe(ActionResult.Success);
70
+ });
71
+ });
@@ -0,0 +1,98 @@
1
+ import { ActionResult } from "../ActionExecutor";
2
+ import { localStorageToggleFlag } from "../StorageActions";
3
+ import { TOGGLE_FLAG_MAX_ITEMS_REACHED_EVENT } from "../consts";
4
+ import { postEvent } from "../../reactHooks/useSubscriberFor";
5
+
6
+ jest.mock("../ActionExecutorContext", () => ({
7
+ log_error: jest.fn(),
8
+ log_info: jest.fn(),
9
+ }));
10
+
11
+ jest.mock("../../reactHooks/useSubscriberFor", () => ({
12
+ postEvent: jest.fn(),
13
+ }));
14
+
15
+ // In-memory stand-in for the storage-backed provider; the limit check under
16
+ // test lives in storageToggleFlag, not in the provider.
17
+ const mockStore: Record<string, string[]> = {};
18
+
19
+ jest.mock("../../storage/StorageMultiSelectProvider", () => ({
20
+ StorageMultiSelectProvider: {
21
+ getProvider: (keyNamespace: string) => ({
22
+ getSelectedAsync: async () => [...(mockStore[keyNamespace] ?? [])],
23
+ addItem: async (item: string) => {
24
+ mockStore[keyNamespace] = [...(mockStore[keyNamespace] ?? []), item];
25
+ },
26
+ removeItem: async (item: string) => {
27
+ mockStore[keyNamespace] = (mockStore[keyNamespace] ?? []).filter(
28
+ (it) => it !== item
29
+ );
30
+ },
31
+ }),
32
+ },
33
+ }));
34
+
35
+ const KEY = "user_preferences.teams";
36
+
37
+ // Entries of a team selection feed with explicit tap actions
38
+ // (localStorageToggleFlag with key and selector).
39
+ const teamEntry = (n: number) =>
40
+ ({
41
+ id: `team-${n}`,
42
+ title: `Team ${n}`,
43
+ type: { value: "action" },
44
+ extensions: { tag: `TEAM ${n}` },
45
+ }) as unknown as ZappEntry;
46
+
47
+ const tapAction = (extraOptions = {}) =>
48
+ ({
49
+ type: "localStorageToggleFlag",
50
+ options: { key: KEY, selector: "extensions.tag", ...extraOptions },
51
+ }) as unknown as ActionType;
52
+
53
+ const tapTeams = async (count: number, extraOptions = {}) => {
54
+ const results: ActionResult[] = [];
55
+
56
+ for (let n = 1; n <= count; n++) {
57
+ results.push(
58
+ await localStorageToggleFlag(
59
+ { entry: teamEntry(n) },
60
+ tapAction(extraOptions)
61
+ )
62
+ );
63
+ }
64
+
65
+ return results;
66
+ };
67
+
68
+ describe("localStorageToggleFlag max_items", () => {
69
+ beforeEach(() => {
70
+ delete mockStore[KEY];
71
+ jest.clearAllMocks();
72
+ });
73
+
74
+ it("blocks the 11th team when max_items is in the action options", async () => {
75
+ const results = await tapTeams(11, { max_items: "10" });
76
+
77
+ expect(results.slice(0, 10)).toEqual(Array(10).fill(ActionResult.Success));
78
+ expect(results[10]).toBe(ActionResult.Cancel);
79
+ expect(mockStore[KEY]).toHaveLength(10);
80
+
81
+ expect(postEvent).toHaveBeenCalledWith(
82
+ TOGGLE_FLAG_MAX_ITEMS_REACHED_EVENT,
83
+ [expect.objectContaining({ maxItems: "10", tag: "TEAM 11" })]
84
+ );
85
+ });
86
+
87
+ it("still lets a selected team be removed at the limit", async () => {
88
+ await tapTeams(10, { max_items: "10" });
89
+
90
+ const result = await localStorageToggleFlag(
91
+ { entry: teamEntry(1) },
92
+ tapAction({ max_items: "10" })
93
+ );
94
+
95
+ expect(result).toBe(ActionResult.Success);
96
+ expect(mockStore[KEY]).toHaveLength(9);
97
+ });
98
+ });
@@ -59,3 +59,33 @@ describe("decorateFeed", () => {
59
59
  expect(decorated.entry[0].extensions.tap_actions).toBeUndefined();
60
60
  });
61
61
  });
62
+
63
+ describe("decorateFeed max_items", () => {
64
+ const toggleAction = (feed: ZappFeed) =>
65
+ decorateFeed(feed).entry[0].extensions.tap_actions.actions[0];
66
+
67
+ it.each([
68
+ ["screen", "screenToggleFlag"],
69
+ ["session", "sessionStorageToggleFlag"],
70
+ ["ctx", "localStorageToggleFlag"],
71
+ ])("passes max_items to the %s toggle action", (scope, type) => {
72
+ const feed = preferenceFeed("multi");
73
+ feed.extensions.preference_editor_options.scope = scope;
74
+ feed.extensions.preference_editor_options.max_items = "10";
75
+
76
+ const toggle = toggleAction(feed);
77
+
78
+ expect(toggle.type).toBe(type);
79
+ expect(toggle.options).toEqual({ key: "agree", max_items: 10 });
80
+ });
81
+
82
+ it.each([undefined, 0, "0", "ten", -1])(
83
+ "does not set a limit for max_items %p",
84
+ (maxItems) => {
85
+ const feed = preferenceFeed("multi");
86
+ feed.extensions.preference_editor_options.max_items = maxItems;
87
+
88
+ expect(toggleAction(feed).options).toEqual({ key: "agree" });
89
+ }
90
+ );
91
+ });
@@ -0,0 +1,90 @@
1
+ import { ActionExecutor, ActionResult } from "../ActionExecutor";
2
+ import { screenToggleFlagAction } from "../actions/screenToggleFlag";
3
+
4
+ jest.mock("../ActionExecutorContext", () => ({
5
+ log_error: jest.fn(),
6
+ log_info: jest.fn(),
7
+ }));
8
+
9
+ jest.mock("../StorageActions", () => ({
10
+ onMaxTagsReached: jest.fn(),
11
+ }));
12
+
13
+ // not used here, but it pulls the native bridge logger in at import time
14
+ jest.mock("../../storage/ScreenSingleValueProvider", () => ({}));
15
+
16
+ jest.mock("../../logger", () => ({
17
+ createLogger: () => ({
18
+ log_error: jest.fn(),
19
+ log_debug: jest.fn(),
20
+ log_info: jest.fn(),
21
+ }),
22
+ }));
23
+
24
+ const mockSelected: string[] = [];
25
+
26
+ jest.mock("../../storage/ScreenStateMultiSelectProvider", () => ({
27
+ ScreenMultiSelectProvider: {
28
+ getProvider: () => ({
29
+ getSelectedAsync: async () => [...mockSelected],
30
+ addItem: async (item: string) => {
31
+ mockSelected.push(item);
32
+ },
33
+ removeItem: async (item: string) => {
34
+ mockSelected.splice(mockSelected.indexOf(item), 1);
35
+ },
36
+ }),
37
+ },
38
+ }));
39
+
40
+ const toggleAction = (options = {}) =>
41
+ ({
42
+ type: "screenToggleFlag",
43
+ options: { key: "teams", selector: "extensions.tag", ...options },
44
+ }) as unknown as ActionType;
45
+
46
+ const context = (tag: string) => ({
47
+ screenRoute: "/screen",
48
+ screenStateStore: {},
49
+ entry: { id: tag, extensions: { tag } },
50
+ });
51
+
52
+ describe("screenToggleFlagAction at the max_items limit", () => {
53
+ beforeEach(() => {
54
+ mockSelected.length = 0;
55
+ });
56
+
57
+ it("returns the cancellation so the chained actions do not run", async () => {
58
+ const afterToggle = jest.fn();
59
+ const executor = new ActionExecutor();
60
+
61
+ executor.registerAction("screenToggleFlag", screenToggleFlagAction);
62
+
63
+ executor.registerAction("sendCloudEvent", async () => {
64
+ afterToggle();
65
+
66
+ return ActionResult.Success;
67
+ });
68
+
69
+ mockSelected.push("A", "B");
70
+
71
+ const result = await executor.handleActions(
72
+ [toggleAction({ max_items: 2 }), { type: "sendCloudEvent" }],
73
+ context("C") as never
74
+ );
75
+
76
+ expect(result).toBe(ActionResult.Cancel);
77
+ expect(afterToggle).not.toHaveBeenCalled();
78
+ expect(mockSelected).toEqual(["A", "B"]);
79
+ });
80
+
81
+ it("lets the chain continue below the limit", async () => {
82
+ const result = await screenToggleFlagAction(
83
+ toggleAction({ max_items: 2 }),
84
+ context("A") as never
85
+ );
86
+
87
+ expect(result).toBe(ActionResult.Success);
88
+ expect(mockSelected).toEqual(["A"]);
89
+ });
90
+ });
@@ -4,6 +4,9 @@ import { ActionResult } from "../../ActionExecutor";
4
4
 
5
5
  jest.mock("../../../modalState", () => ({
6
6
  dismissModal: jest.fn(),
7
+ modalOrchestrator: {
8
+ close: jest.fn(),
9
+ },
7
10
  }));
8
11
 
9
12
  describe("dismissBottomSheetAction", () => {
@@ -83,9 +83,7 @@ describe("navigateToScreen navigationOptions", () => {
83
83
  );
84
84
  });
85
85
 
86
- // Entries cannot go through replace(), which only accepts a river, so the
87
- // non-push path falls back to replaceTop().
88
- it("forwards navigationOptions to replaceTop when navigating with an entry", async () => {
86
+ it("forwards navigationOptions to replace when navigating with an entry", async () => {
89
87
  const navigator = createNavigator();
90
88
  const entry = { id: "entry-id" } as unknown as ZappEntry;
91
89
 
@@ -100,12 +98,12 @@ describe("navigateToScreen navigationOptions", () => {
100
98
 
101
99
  await createNavigateToScreenAction(navigator, rivers, contentTypes)(action);
102
100
 
103
- expect(navigator.replaceTop).toHaveBeenCalledWith(
101
+ expect(navigator.replace).toHaveBeenCalledWith(
104
102
  { id: "entry-id", type: { value: "parentLock" } },
105
103
  { pinFlow: "change-pin" }
106
104
  );
107
105
 
108
- expect(navigator.replace).not.toHaveBeenCalled();
106
+ expect(navigator.replaceTop).not.toHaveBeenCalled();
109
107
  });
110
108
 
111
109
  // Regression guard: the no-options path must keep working unchanged.
@@ -131,3 +129,186 @@ describe("navigateToScreen navigationOptions", () => {
131
129
  expect(result).toBe(ActionResult.Success);
132
130
  });
133
131
  });
132
+
133
+ describe("navigateToScreen target resolution", () => {
134
+ function run(options, context?) {
135
+ const navigator = createNavigator();
136
+
137
+ const result = createNavigateToScreenAction(
138
+ navigator,
139
+ rivers,
140
+ contentTypes
141
+ )({ type: "navigateToScreen", options }, context);
142
+
143
+ return { navigator, result };
144
+ }
145
+
146
+ it("overrides entry.type with typeMapping when both are provided", async () => {
147
+ const entry = {
148
+ id: "entry-id",
149
+ type: { value: "video" },
150
+ } as unknown as ZappEntry;
151
+
152
+ const { navigator, result } = run({
153
+ typeMapping: "parentLock",
154
+ navigationAction: "push",
155
+ entry,
156
+ });
157
+
158
+ expect(await result).toBe(ActionResult.Success);
159
+
160
+ expect(navigator.push).toHaveBeenCalledWith(
161
+ { id: "entry-id", type: { value: "parentLock" } },
162
+ undefined
163
+ );
164
+
165
+ expect(entry.type).toEqual({ value: "video" });
166
+ });
167
+
168
+ it("navigates with the entry's own type when typeMapping is not provided", async () => {
169
+ const entry = {
170
+ id: "entry-id",
171
+ type: { value: "video" },
172
+ } as unknown as ZappEntry;
173
+
174
+ const { navigator, result } = run({ navigationAction: "push", entry });
175
+
176
+ expect(await result).toBe(ActionResult.Success);
177
+
178
+ expect(navigator.push).toHaveBeenCalledWith(
179
+ { id: "entry-id", type: { value: "video" } },
180
+ undefined
181
+ );
182
+ });
183
+
184
+ it("resolves the context entry when entry is @{entry/} and typeMapping is not provided", async () => {
185
+ const entry = {
186
+ id: "context-entry-id",
187
+ type: { value: "video" },
188
+ } as unknown as ZappEntry;
189
+
190
+ const { navigator, result } = run({ entry: "@{entry/}" }, { entry });
191
+
192
+ expect(await result).toBe(ActionResult.Success);
193
+
194
+ expect(navigator.replace).toHaveBeenCalledWith(
195
+ { id: "context-entry-id", type: { value: "video" } },
196
+ undefined
197
+ );
198
+ });
199
+
200
+ it("returns an error when neither typeMapping nor entry type is provided", async () => {
201
+ const entry = { id: "entry-id" } as unknown as ZappEntry;
202
+
203
+ const { navigator, result } = run({ navigationAction: "push", entry });
204
+
205
+ expect(await result).toBe(ActionResult.Error);
206
+ expect(navigator.push).not.toHaveBeenCalled();
207
+ expect(navigator.replace).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it("returns an error when neither typeMapping nor entry is provided", async () => {
211
+ const { navigator, result } = run({ navigationAction: "push" });
212
+
213
+ expect(await result).toBe(ActionResult.Error);
214
+ expect(navigator.push).not.toHaveBeenCalled();
215
+ });
216
+
217
+ it("returns an error when typeMapping has no screen_id mapping", async () => {
218
+ const callback = jest.fn();
219
+
220
+ const { navigator, result } = run(
221
+ { typeMapping: "unknownType", navigationAction: "push" },
222
+ { callback }
223
+ );
224
+
225
+ expect(await result).toBe(ActionResult.Error);
226
+ expect(navigator.push).not.toHaveBeenCalled();
227
+ expect(callback).not.toHaveBeenCalled();
228
+ });
229
+
230
+ it("returns an error when the mapped screen_id has no river", async () => {
231
+ const navigator = createNavigator();
232
+ const callback = jest.fn();
233
+
234
+ const result = await createNavigateToScreenAction(navigator, rivers, {
235
+ orphan: { screen_id: "missing-screen-id" },
236
+ } as unknown as ZappContentTypes)(
237
+ {
238
+ type: "navigateToScreen",
239
+ options: { typeMapping: "orphan", navigationAction: "push" },
240
+ },
241
+ { callback }
242
+ );
243
+
244
+ expect(result).toBe(ActionResult.Error);
245
+ expect(navigator.push).not.toHaveBeenCalled();
246
+ expect(callback).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it("aborts the action pipeline before navigating to a resolved river", async () => {
250
+ const callback = jest.fn();
251
+
252
+ const { navigator, result } = run(
253
+ { typeMapping: "parentLock" },
254
+ { callback }
255
+ );
256
+
257
+ expect(await result).toBe(ActionResult.Success);
258
+
259
+ expect(callback).toHaveBeenCalledWith({
260
+ success: false,
261
+ error: null,
262
+ abort: true,
263
+ });
264
+
265
+ expect(navigator.replace).toHaveBeenCalledWith(river, undefined);
266
+ });
267
+ });
268
+
269
+ describe("navigateToScreen entry context", () => {
270
+ it("sets the entry context for the destination before navigating", async () => {
271
+ const navigator = createNavigator();
272
+ const setEntryContext = jest.fn();
273
+ const entry = { id: "entry-id" } as unknown as ZappEntry;
274
+
275
+ const action = {
276
+ type: "navigateToScreen",
277
+ options: {
278
+ typeMapping: "parentLock",
279
+ navigationAction: "push",
280
+ entry: "@{entry/}",
281
+ },
282
+ };
283
+
284
+ await createNavigateToScreenAction(
285
+ navigator,
286
+ rivers,
287
+ contentTypes
288
+ )(action, { entry, setEntryContext });
289
+
290
+ expect(setEntryContext).toHaveBeenCalledWith(entry);
291
+
292
+ expect(
293
+ (setEntryContext as jest.Mock).mock.invocationCallOrder[0]
294
+ ).toBeLessThan((navigator.push as jest.Mock).mock.invocationCallOrder[0]);
295
+ });
296
+
297
+ it("does not set the entry context when it navigates without an entry", async () => {
298
+ const navigator = createNavigator();
299
+ const setEntryContext = jest.fn();
300
+
301
+ const action = {
302
+ type: "navigateToScreen",
303
+ options: { typeMapping: "parentLock", navigationAction: "push" },
304
+ };
305
+
306
+ await createNavigateToScreenAction(
307
+ navigator,
308
+ rivers,
309
+ contentTypes
310
+ )(action, { setEntryContext });
311
+
312
+ expect(setEntryContext).not.toHaveBeenCalled();
313
+ });
314
+ });
@@ -60,6 +60,30 @@ describe("openBottomSheetAction custom content", () => {
60
60
  });
61
61
  });
62
62
 
63
+ it("passes themePluginId through to the name sheet", async () => {
64
+ const CustomContent = function CustomContent() {
65
+ return null;
66
+ };
67
+
68
+ await openBottomSheetAction({
69
+ type: "openBottomSheet",
70
+ options: {
71
+ header: { title: "Create New Playlist" },
72
+ themePluginId: "playlist-experience",
73
+ content: {
74
+ component: CustomContent,
75
+ props: { buttonLabel: "Create" },
76
+ },
77
+ },
78
+ } as any);
79
+
80
+ const args = (openBottomSheetModal as jest.Mock).mock.calls[0][0];
81
+
82
+ expect(args.modalBottomSheetContentProps.themePluginId).toBe(
83
+ "playlist-experience"
84
+ );
85
+ });
86
+
63
87
  it("returns Error when neither list data nor content.component is provided", async () => {
64
88
  const result = await openBottomSheetAction({
65
89
  type: "openBottomSheet",