@applicaster/zapp-react-native-utils 16.0.0-rc.8 → 16.0.0-rc.80

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 (189) hide show
  1. package/actionsExecutor/ActionExecutor.ts +47 -11
  2. package/actionsExecutor/ActionExecutorContext.tsx +102 -315
  3. package/actionsExecutor/__tests__/feedDecorator.test.ts +61 -0
  4. package/actionsExecutor/actions/__tests__/dismissBottomSheet.test.ts +25 -0
  5. package/actionsExecutor/actions/__tests__/goBack.test.ts +62 -0
  6. package/actionsExecutor/actions/__tests__/goHome.test.ts +19 -0
  7. package/actionsExecutor/actions/__tests__/loadItemsFromSource.test.ts +88 -0
  8. package/actionsExecutor/actions/__tests__/navigateToScreen.test.ts +133 -0
  9. package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +76 -0
  10. package/actionsExecutor/actions/__tests__/openBottomSheet.inlineItems.test.ts +212 -0
  11. package/actionsExecutor/actions/__tests__/showToast.test.ts +88 -0
  12. package/actionsExecutor/actions/appRestart.ts +24 -0
  13. package/actionsExecutor/actions/confirmDialog.ts +53 -0
  14. package/actionsExecutor/actions/dismissBottomSheet.ts +23 -0
  15. package/actionsExecutor/actions/goBack.ts +60 -0
  16. package/actionsExecutor/actions/goHome.ts +31 -0
  17. package/actionsExecutor/actions/index.ts +38 -0
  18. package/actionsExecutor/actions/localStorageRemove.ts +27 -0
  19. package/actionsExecutor/actions/localStorageSet.ts +28 -0
  20. package/actionsExecutor/actions/localStorageToggleFlag.ts +28 -0
  21. package/actionsExecutor/actions/navigateToScreen.ts +137 -0
  22. package/actionsExecutor/actions/openBottomSheet.ts +325 -0
  23. package/actionsExecutor/actions/refreshComponent.ts +85 -0
  24. package/actionsExecutor/actions/screenSetVariable.ts +35 -0
  25. package/actionsExecutor/actions/screenToggleFlag.ts +38 -0
  26. package/actionsExecutor/actions/sendCloudEvent.ts +93 -0
  27. package/actionsExecutor/actions/sessionStorageRemove.ts +19 -0
  28. package/actionsExecutor/actions/sessionStorageSet.ts +20 -0
  29. package/actionsExecutor/actions/sessionStorageToggleFlag.ts +15 -0
  30. package/actionsExecutor/actions/showToast.ts +87 -0
  31. package/actionsExecutor/actions/switchLayout.ts +40 -0
  32. package/actionsExecutor/consts.ts +25 -0
  33. package/actionsExecutor/feedDecorator.ts +6 -6
  34. package/actionsExecutor/types.ts +59 -0
  35. package/analyticsUtils/AnalyticsEvents/sendMenuClickEvent.ts +2 -2
  36. package/analyticsUtils/PlayerAnalyticsManager.ts +12 -2
  37. package/analyticsUtils/__tests__/analyticsMapper.test.ts +35 -0
  38. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testACP_events.json +1 -1
  39. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testBlockUnlistedParams_rules.json +1 -1
  40. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testEmptyRenameKeepsName_events.json +4 -0
  41. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testEmptyRenameKeepsName_rules.json +6 -0
  42. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testEventRegex_events.json +3 -9
  43. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testIgnoreOrdering_events.json +18 -0
  44. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testIgnoreOrdering_rules.json +16 -0
  45. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexEventNoFallback_events.json +4 -0
  46. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexEventNoFallback_rules.json +6 -0
  47. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexMultiGroupRename_events.json +4 -0
  48. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexMultiGroupRename_rules.json +6 -0
  49. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexNonNamedFullMatch_events.json +4 -0
  50. package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexNonNamedFullMatch_rules.json +6 -0
  51. package/analyticsUtils/__tests__/fixtures/index.js +20 -0
  52. package/analyticsUtils/analyticsMapper.ts +4 -1
  53. package/analyticsUtils/playerAnalyticsTracker.ts +26 -3
  54. package/appUtils/HooksManager/index.ts +12 -8
  55. package/appUtils/contextKeysManager/__tests__/getKey/failure.test.ts +1 -1
  56. package/appUtils/contextKeysManager/__tests__/removeKey/failure.test.ts +1 -1
  57. package/appUtils/contextKeysManager/__tests__/setKey/failure/invalidKey.test.ts +4 -4
  58. package/appUtils/contextKeysManager/index.ts +1 -1
  59. package/appUtils/contextKeysManager/utils/index.ts +38 -25
  60. package/appUtils/playerManager/OverlayObserver/OverlaysObserver.ts +143 -53
  61. package/appUtils/playerManager/OverlayObserver/__tests__/liveContent.test.ts +205 -0
  62. package/appUtils/playerManager/OverlayObserver/__tests__/utils.test.ts +49 -0
  63. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.android.tv.ts +4 -0
  64. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ios.tv.ts +4 -0
  65. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ts +11 -0
  66. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.web.ts +4 -0
  67. package/appUtils/playerManager/OverlayObserver/utils.ts +54 -20
  68. package/appUtils/playerManager/__tests__/playerContent.test.ts +403 -0
  69. package/appUtils/playerManager/__tests__/playerFactory.test.ts +1 -1
  70. package/appUtils/playerManager/__tests__/playerNative.test.ts +38 -0
  71. package/appUtils/playerManager/__tests__/usePlayerContent.test.tsx +64 -0
  72. package/appUtils/playerManager/{conts.ts → const.ts} +20 -0
  73. package/appUtils/playerManager/index.ts +1 -1
  74. package/appUtils/playerManager/player.ts +115 -3
  75. package/appUtils/playerManager/playerFactory.ts +1 -1
  76. package/appUtils/playerManager/playerNative.ts +6 -4
  77. package/appUtils/playerManager/usePlayerContent.tsx +43 -0
  78. package/appUtils/playerManager/usePlayerControllerSetup.tsx +1 -1
  79. package/appUtils/playerManager/userLanguagePreference.ts +1 -1
  80. package/arrayUtils/__tests__/{anyThruthy.test.ts → anyTruthy.test.ts} +8 -0
  81. package/arrayUtils/__tests__/arrayUtils.test.ts +165 -108
  82. package/arrayUtils/__tests__/isEmptyArray.test.ts +11 -0
  83. package/arrayUtils/__tests__/isFilledArray.test.ts +12 -0
  84. package/arrayUtils/__tests__/isFirst.test.ts +17 -0
  85. package/arrayUtils/__tests__/isIndexInRange.test.ts +14 -0
  86. package/arrayUtils/__tests__/isLast.test.ts +31 -0
  87. package/arrayUtils/__tests__/makeListOf.test.ts +31 -0
  88. package/arrayUtils/__tests__/makeListOfIndexes.test.ts +20 -0
  89. package/arrayUtils/__tests__/reorderByIds.test.ts +50 -0
  90. package/arrayUtils/__tests__/sample.test.ts +61 -0
  91. package/arrayUtils/index.ts +136 -20
  92. package/bottomSheet/__tests__/scrollContext.test.tsx +35 -0
  93. package/bottomSheet/index.ts +17 -0
  94. package/cellUtils/index.ts +33 -8
  95. package/colorUtils/__tests__/isTransparentColor.test.ts +76 -0
  96. package/colorUtils/__tests__/isValidColor.test.ts +70 -0
  97. package/colorUtils/index.ts +50 -0
  98. package/configurationUtils/__tests__/modalBlocksParser.test.ts +107 -0
  99. package/configurationUtils/manifestKeyParser.ts +38 -9
  100. package/configurationUtils/modalBlocksParser.ts +178 -0
  101. package/entryActions/__tests__/buildEntryActions.test.ts +156 -0
  102. package/entryActions/aliasPresentation.ts +124 -0
  103. package/entryActions/buildEntryActions.ts +166 -0
  104. package/entryActions/index.ts +17 -0
  105. package/entryActions/types.ts +39 -0
  106. package/manifestUtils/__tests__/player.audioControls.test.js +158 -0
  107. package/manifestUtils/_internals/index.js +14 -3
  108. package/manifestUtils/defaultManifestConfigurations/generalContent.js +35 -0
  109. package/manifestUtils/defaultManifestConfigurations/player.js +349 -20
  110. package/manifestUtils/fieldUtils/__tests__/fieldUtils.test.js +84 -0
  111. package/manifestUtils/fieldUtils/index.js +125 -0
  112. package/manifestUtils/index.js +3 -0
  113. package/manifestUtils/keys.js +9 -0
  114. package/manifestUtils/mobileAction/button/index.js +16 -0
  115. package/manifestUtils/mobileAction/container/index.js +3 -1
  116. package/manifestUtils/mobileAction/groups/defaults.js +3 -1
  117. package/manifestUtils/modalBlocks.js +304 -0
  118. package/manifestUtils/platformIsTV.js +1 -0
  119. package/modalState/ContentViewModel.ts +113 -0
  120. package/modalState/EditableCollection.ts +17 -0
  121. package/modalState/EditableCollectionRegistry.ts +51 -0
  122. package/modalState/ModalOrchestrator.ts +211 -0
  123. package/modalState/RemoteEditableCollection.ts +227 -0
  124. package/modalState/__tests__/ContentViewModel.editable.test.ts +69 -0
  125. package/modalState/__tests__/ContentViewModel.test.ts +165 -0
  126. package/modalState/__tests__/EditableCollectionRegistry.test.ts +112 -0
  127. package/modalState/__tests__/ModalOrchestrator.phase3.test.ts +47 -0
  128. package/modalState/__tests__/RemoteEditableCollection.test.ts +326 -0
  129. package/modalState/__tests__/index.test.ts +30 -1
  130. package/modalState/__tests__/useBottomSheetContent.test.ts +349 -0
  131. package/modalState/index.ts +35 -83
  132. package/modalState/store.ts +102 -0
  133. package/modalState/types.ts +159 -0
  134. package/modalState/useBottomSheetContent.ts +104 -0
  135. package/numberUtils/__tests__/isMinusZero.test.ts +20 -0
  136. package/numberUtils/__tests__/isZero.test.ts +27 -0
  137. package/numberUtils/__tests__/requireNumber.test.ts +50 -0
  138. package/numberUtils/__tests__/toNumber.test.ts +27 -0
  139. package/numberUtils/__tests__/toNumberWithDefault.test.ts +64 -0
  140. package/numberUtils/__tests__/toNumberWithDefaultZero.test.ts +48 -0
  141. package/numberUtils/index.ts +27 -8
  142. package/package.json +2 -2
  143. package/pipesUtils/__tests__/buildUrlWithQuery.test.ts +33 -0
  144. package/pipesUtils/__tests__/withPipesEndpoint.test.tsx +56 -0
  145. package/pipesUtils/index.ts +1 -0
  146. package/pipesUtils/withPipesEndpoint.tsx +54 -0
  147. package/playerUtils/__tests__/contentDataKeys.test.ts +297 -0
  148. package/playerUtils/__tests__/resolvePlayerActions.test.ts +138 -0
  149. package/playerUtils/__tests__/resolvePlayerTitleSubtitle.test.ts +180 -0
  150. package/playerUtils/contentDataKeys.ts +134 -0
  151. package/playerUtils/index.ts +58 -15
  152. package/playerUtils/isAudioItem.ts +26 -0
  153. package/playerUtils/resolvePlayerActions.ts +71 -0
  154. package/playerUtils/resolvePlayerTitleSubtitle.ts +76 -0
  155. package/reactHooks/actions/index.ts +117 -2
  156. package/reactHooks/cell-click/index.ts +18 -10
  157. package/reactHooks/feed/__mocks__/useMarkPipesDataStale.ts +4 -0
  158. package/reactHooks/feed/__tests__/useInflatedUrl.test.tsx +25 -0
  159. package/reactHooks/feed/index.ts +5 -1
  160. package/reactHooks/feed/useBatchLoading.ts +9 -1
  161. package/reactHooks/feed/{usePipesCacheReset.ts → useMarkPipesDataStale.ts} +12 -4
  162. package/reactHooks/index.ts +2 -0
  163. package/reactHooks/layout/index.ts +1 -1
  164. package/reactHooks/navigation/__mocks__/index.ts +4 -0
  165. package/reactHooks/navigation/__tests__/useIsScreenActive.test.ts +42 -0
  166. package/reactHooks/navigation/index.ts +1 -1
  167. package/reactHooks/navigation/useIsScreenActive.ts +29 -8
  168. package/reactHooks/state/useComponentScreenState.ts +1 -1
  169. package/reactHooks/usePluginConfiguration.ts +4 -1
  170. package/reactHooks/utils/__tests__/index.test.js +22 -2
  171. package/reactHooks/utils/index.ts +13 -2
  172. package/reactHooks/videoModal/hooks/useVideoModalScreenData.tsx +22 -4
  173. package/riverComponetsMeasurementProvider/index.tsx +12 -8
  174. package/stringUtils/__tests__/stringUtils.test.js +42 -0
  175. package/stringUtils/index.ts +2 -2
  176. package/uiActionsRegistrator/__tests__/resolveActionIdentifiers.test.ts +93 -0
  177. package/uiActionsRegistrator/__tests__/subscribe.test.ts +107 -0
  178. package/uiActionsRegistrator/__tests__/usableActionItems.test.ts +52 -0
  179. package/uiActionsRegistrator/index.ts +25 -0
  180. package/uiActionsRegistrator/registry.ts +335 -0
  181. package/uiActionsRegistrator/resolveActionIdentifiers.ts +100 -0
  182. package/uiActionsRegistrator/usableActionItems.ts +66 -0
  183. package/zappFrameworkUtils/__tests__/localStorageHelper.test.ts +146 -0
  184. package/zappFrameworkUtils/localStorageHelper.ts +19 -15
  185. package/appUtils/playerManager/usePlayerTitleSummaryOverlay.tsx +0 -56
  186. package/playerUtils/__tests__/getPlayerActionButtons.test.ts +0 -54
  187. package/playerUtils/getPlayerActionButtons.ts +0 -17
  188. package/reactHooks/feed/__mocks__/usePipesCacheReset.ts +0 -1
  189. /package/reactHooks/actions/__tests__/{index.test.js → index.test.tsx} +0 -0
@@ -0,0 +1,165 @@
1
+ import { ContentViewModel } from "../ContentViewModel";
2
+ import { loadItemsFromSource } from "../../actionsExecutor/actions/openBottomSheet";
3
+
4
+ jest.mock("../../actionsExecutor/actions/openBottomSheet", () => ({
5
+ loadItemsFromSource: jest.fn(),
6
+ }));
7
+
8
+ describe("ContentViewModel", () => {
9
+ beforeEach(() => {
10
+ jest.clearAllMocks();
11
+ });
12
+
13
+ it("should initialize with items and not fetch if itemsUrl is not provided", () => {
14
+ const content = {
15
+ title: "Test Menu",
16
+ items: [{ title: "Item 1" }, { title: "Item 2" }],
17
+ };
18
+
19
+ const viewModel = new ContentViewModel(content);
20
+
21
+ expect(viewModel.getState()).toMatchObject({
22
+ items: content.items,
23
+ isLoading: false,
24
+ role: undefined,
25
+ behavior: undefined,
26
+ });
27
+
28
+ expect(loadItemsFromSource).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it("seeds role and behavior from Content", () => {
32
+ const viewModel = new ContentViewModel({
33
+ title: "Playlists",
34
+ items: [{ title: "A" }],
35
+ role: "collection_selector",
36
+ behavior: { selectMode: "multi", currentSelection: ["a"] },
37
+ });
38
+
39
+ expect(viewModel.getState().role).toBe("collection_selector");
40
+
41
+ expect(viewModel.getState().behavior).toEqual({
42
+ selectMode: "multi",
43
+ currentSelection: ["a"],
44
+ });
45
+ });
46
+
47
+ it("should trigger fetch on init if items is empty and itemsUrl is present", async () => {
48
+ const mockItems = [{ title: "Fetched Item" }];
49
+
50
+ (loadItemsFromSource as jest.Mock).mockResolvedValueOnce({
51
+ items: mockItems,
52
+ role: "collection_selector",
53
+ behavior: { selectMode: "multi", currentSelection: [] },
54
+ });
55
+
56
+ const content = {
57
+ title: "Test Menu",
58
+ items: [],
59
+ itemsUrl: "http://example.com/items",
60
+ };
61
+
62
+ const viewModel = new ContentViewModel(content);
63
+
64
+ // Initial state should be loading
65
+ expect(viewModel.getState().isLoading).toBe(true);
66
+
67
+ // Wait for the asynchronous refetch to complete
68
+ await new Promise(process.nextTick);
69
+
70
+ expect(loadItemsFromSource).toHaveBeenCalledWith(
71
+ expect.stringMatching(/^http:\/\/example\.com\/items\?_t=\d+$/)
72
+ );
73
+
74
+ expect(viewModel.getState()).toMatchObject({
75
+ items: mockItems,
76
+ isLoading: false,
77
+ role: "collection_selector",
78
+ behavior: { selectMode: "multi", currentSelection: [] },
79
+ });
80
+ });
81
+
82
+ it("updates role and behavior on refetch", async () => {
83
+ (loadItemsFromSource as jest.Mock)
84
+ .mockResolvedValueOnce({
85
+ items: [{ title: "First" }],
86
+ role: "collection_selector",
87
+ behavior: { selectMode: "multi", currentSelection: [] },
88
+ })
89
+ .mockResolvedValueOnce({
90
+ items: [{ title: "Second", isSelected: true }],
91
+ role: "collection_selector",
92
+ behavior: { selectMode: "multi", currentSelection: ["2"] },
93
+ });
94
+
95
+ const viewModel = new ContentViewModel({
96
+ title: "Test Menu",
97
+ items: [],
98
+ itemsUrl: "http://example.com/items",
99
+ });
100
+
101
+ await new Promise(process.nextTick);
102
+
103
+ await viewModel.refetch();
104
+
105
+ expect(viewModel.getState().items[0].title).toBe("Second");
106
+ expect(viewModel.getState().behavior?.currentSelection).toEqual(["2"]);
107
+ expect(viewModel.getState().role).toBe("collection_selector");
108
+ });
109
+
110
+ it("should notify subscribers of state changes", async () => {
111
+ const mockItems = [{ title: "Fetched Item" }];
112
+
113
+ (loadItemsFromSource as jest.Mock).mockResolvedValue({
114
+ items: mockItems,
115
+ });
116
+
117
+ const content = {
118
+ title: "Test Menu",
119
+ items: [],
120
+ itemsUrl: "http://example.com/items",
121
+ };
122
+
123
+ const viewModel = new ContentViewModel(content);
124
+
125
+ // Wait for constructor's refetch to finish
126
+ await new Promise(process.nextTick);
127
+
128
+ const listener = jest.fn();
129
+ const unsubscribe = viewModel.subscribe(listener);
130
+
131
+ await viewModel.refetch();
132
+
133
+ expect(listener).toHaveBeenCalled();
134
+ expect(viewModel.getState().items).toEqual(mockItems);
135
+
136
+ unsubscribe();
137
+ listener.mockClear();
138
+
139
+ await viewModel.refetch();
140
+ expect(listener).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it("should handle error states during fetching", async () => {
144
+ const error = new Error("Network Error");
145
+ (loadItemsFromSource as jest.Mock).mockRejectedValueOnce(error);
146
+
147
+ const content = {
148
+ title: "Test Menu",
149
+ items: [],
150
+ itemsUrl: "http://example.com/items",
151
+ };
152
+
153
+ const viewModel = new ContentViewModel(content);
154
+
155
+ await new Promise(process.nextTick);
156
+
157
+ expect(viewModel.getState()).toMatchObject({
158
+ items: [],
159
+ isLoading: false,
160
+ error: "Network Error",
161
+ role: undefined,
162
+ behavior: undefined,
163
+ });
164
+ });
165
+ });
@@ -0,0 +1,112 @@
1
+ import {
2
+ EditableCollectionRegistry,
3
+ resolveEditableCollection,
4
+ } from "../EditableCollectionRegistry";
5
+ import { RemoteEditableCollection } from "../RemoteEditableCollection";
6
+ import { EditableCollection } from "../EditableCollection";
7
+ import { BehaviorSubject } from "rxjs";
8
+ import type { Content, MenuItem } from "../types";
9
+
10
+ function fakeCollection(): EditableCollection {
11
+ return {
12
+ items$: new BehaviorSubject<MenuItem[]>([]).asObservable(),
13
+ operations: [],
14
+ };
15
+ }
16
+
17
+ const refetch = jest.fn().mockResolvedValue(undefined);
18
+
19
+ describe("EditableCollectionRegistry", () => {
20
+ beforeEach(() => {
21
+ // reset the static registry between tests
22
+ (EditableCollectionRegistry as any).providers = {};
23
+ jest.clearAllMocks();
24
+ });
25
+
26
+ it("registers and resolves a provider factory (new instance per call)", () => {
27
+ const instance = fakeCollection();
28
+ const factory = jest.fn(() => instance);
29
+ EditableCollectionRegistry.register("@{provider/x}", factory);
30
+
31
+ const resolved = EditableCollectionRegistry.getProvider("@{provider/x}");
32
+
33
+ expect(resolved).toBe(instance);
34
+ expect(factory).toHaveBeenCalledTimes(1);
35
+ });
36
+
37
+ it("returns null for an unknown provider token", () => {
38
+ expect(EditableCollectionRegistry.getProvider("@{provider/missing}")).toBe(
39
+ null
40
+ );
41
+ });
42
+ });
43
+
44
+ describe("resolveEditableCollection", () => {
45
+ beforeEach(() => {
46
+ (EditableCollectionRegistry as any).providers = {};
47
+ jest.clearAllMocks();
48
+ });
49
+
50
+ it("returns null when there are no dynamicCollection options", () => {
51
+ const content = { items: [] } as unknown as Content;
52
+
53
+ expect(resolveEditableCollection(content, refetch)).toBe(null);
54
+ });
55
+
56
+ it("resolves a local provider by token", () => {
57
+ const instance = fakeCollection();
58
+ EditableCollectionRegistry.register("@{provider/queue}", () => instance);
59
+
60
+ const content = {
61
+ items: [],
62
+ dynamicCollection: {
63
+ operations: ["remove"],
64
+ provider: "@{provider/queue}",
65
+ },
66
+ } as unknown as Content;
67
+
68
+ expect(resolveEditableCollection(content, refetch)).toBe(instance);
69
+ });
70
+
71
+ it("returns null (and does not throw) for an unregistered provider token", () => {
72
+ const content = {
73
+ items: [],
74
+ dynamicCollection: {
75
+ operations: ["remove"],
76
+ provider: "@{provider/unregistered}",
77
+ },
78
+ } as unknown as Content;
79
+
80
+ expect(resolveEditableCollection(content, refetch)).toBe(null);
81
+ });
82
+
83
+ it("builds a RemoteEditableCollection when a postUrl is present", () => {
84
+ const content = {
85
+ items: [],
86
+ dynamicCollection: {
87
+ operations: ["remove"],
88
+ postUrl: "https://x/cloud-events",
89
+ },
90
+ } as unknown as Content;
91
+
92
+ expect(resolveEditableCollection(content, refetch)).toBeInstanceOf(
93
+ RemoteEditableCollection
94
+ );
95
+ });
96
+
97
+ it("prefers a provider token over postUrl", () => {
98
+ const instance = fakeCollection();
99
+ EditableCollectionRegistry.register("@{provider/queue}", () => instance);
100
+
101
+ const content = {
102
+ items: [],
103
+ dynamicCollection: {
104
+ operations: ["remove"],
105
+ provider: "@{provider/queue}",
106
+ postUrl: "https://x/cloud-events",
107
+ },
108
+ } as unknown as Content;
109
+
110
+ expect(resolveEditableCollection(content, refetch)).toBe(instance);
111
+ });
112
+ });
@@ -0,0 +1,47 @@
1
+ import { modalOrchestrator } from "../ModalOrchestrator";
2
+ import { modalStore } from "../store";
3
+
4
+ describe("ModalOrchestrator phase 3", () => {
5
+ beforeEach(() => {
6
+ modalOrchestrator.close();
7
+ modalStore.getState().dismissModal();
8
+ });
9
+
10
+ it("does not forward styleVariant", () => {
11
+ modalOrchestrator.open({
12
+ content: {
13
+ title: "Playlists",
14
+ items: [{ title: "Favorites" }],
15
+ role: "collection_selector",
16
+ behavior: { selectMode: "multi", currentSelection: [] },
17
+ },
18
+ });
19
+
20
+ const props = (modalStore.getState().modalState.screen as any)
21
+ ?.modalBottomSheetContentProps;
22
+
23
+ expect(props.styleVariant).toBeUndefined();
24
+ expect(props.viewModel).toBeDefined();
25
+ expect(props.viewModel.getState().role).toBe("collection_selector");
26
+ expect(props.title).toBe("Playlists");
27
+ });
28
+
29
+ it("forwards header title and subtitle as summary", () => {
30
+ modalOrchestrator.open({
31
+ header: {
32
+ title: "Sleep Timer",
33
+ subtitle: "remaining",
34
+ },
35
+ content: {
36
+ title: "Sleep Timer",
37
+ items: [{ id: "5", title: "5 minutes" }],
38
+ },
39
+ });
40
+
41
+ const props = (modalStore.getState().modalState.screen as any)
42
+ ?.modalBottomSheetContentProps;
43
+
44
+ expect(props.title).toBe("Sleep Timer");
45
+ expect(props.summary).toBe("remaining");
46
+ });
47
+ });
@@ -0,0 +1,326 @@
1
+ import { RemoteEditableCollection } from "../RemoteEditableCollection";
2
+ import { actionExecutor } from "../../actionsExecutor/ActionExecutor";
3
+ import type { Content, MenuItem } from "../types";
4
+
5
+ jest.mock("../../actionsExecutor/ActionExecutor", () => ({
6
+ actionExecutor: { handleAction: jest.fn() },
7
+ ActionResult: { Success: "success", Error: "error" },
8
+ }));
9
+
10
+ const handleAction = actionExecutor.handleAction as jest.Mock;
11
+
12
+ /**
13
+ * BehaviorSubject emits its current value synchronously on subscribe, so this
14
+ * returns the latest items snapshot.
15
+ */
16
+ function currentItems(col: RemoteEditableCollection): MenuItem[] {
17
+ let items: MenuItem[] = [];
18
+ const sub = col.items$.subscribe((i) => (items = i));
19
+ sub.unsubscribe();
20
+
21
+ return items;
22
+ }
23
+
24
+ const removeAction = { type: "sendCloudEvent", options: { foo: "bar" } };
25
+
26
+ const itemWithRemove: MenuItem = {
27
+ id: "1",
28
+ title: "A",
29
+ entryActions: [{ button: { alias: "remove_item" }, actions: [removeAction] }],
30
+ };
31
+
32
+ const itemPlain: MenuItem = { id: "2", title: "B" };
33
+
34
+ function makeCollection(
35
+ items: MenuItem[],
36
+ refetch = jest.fn().mockResolvedValue(undefined),
37
+ operations = ["remove", "reorder"]
38
+ ) {
39
+ const content = {
40
+ items,
41
+ dynamicCollection: { operations, postUrl: "https://x/cloud-events" },
42
+ } as unknown as Content;
43
+
44
+ return { col: new RemoteEditableCollection(content, refetch), refetch };
45
+ }
46
+
47
+ describe("RemoteEditableCollection", () => {
48
+ beforeEach(() => jest.clearAllMocks());
49
+
50
+ it("exposes the declared operations and initial items", () => {
51
+ const { col } = makeCollection([itemWithRemove, itemPlain]);
52
+
53
+ expect(col.operations).toEqual(["remove", "reorder"]);
54
+ expect(currentItems(col)).toEqual([itemWithRemove, itemPlain]);
55
+ });
56
+
57
+ describe("remove", () => {
58
+ it("optimistically removes, dispatches the aliased action, then refetches", async () => {
59
+ handleAction.mockResolvedValue("success");
60
+ const { col, refetch } = makeCollection([itemWithRemove, itemPlain]);
61
+
62
+ await col.remove(0);
63
+
64
+ expect(handleAction).toHaveBeenCalledWith(removeAction, {
65
+ entry: itemWithRemove,
66
+ });
67
+
68
+ expect(refetch).toHaveBeenCalledTimes(1);
69
+ expect(currentItems(col)).toEqual([itemPlain]);
70
+ });
71
+
72
+ it("rolls back to the snapshot when the action dispatch fails", async () => {
73
+ handleAction.mockRejectedValue(new Error("network"));
74
+ const { col, refetch } = makeCollection([itemWithRemove, itemPlain]);
75
+
76
+ await col.remove(0);
77
+
78
+ // optimistic removal reverted
79
+ expect(currentItems(col)).toEqual([itemWithRemove, itemPlain]);
80
+ // refetch never reached on failure
81
+ expect(refetch).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("lets the refetched server result replace the optimistic list", async () => {
85
+ handleAction.mockResolvedValue("success");
86
+ const serverItems: MenuItem[] = [{ id: "99", title: "server truth" }];
87
+
88
+ const refetch = jest.fn();
89
+ const { col } = makeCollection([itemWithRemove, itemPlain], refetch);
90
+
91
+ // Emulate ContentViewModel.refetch pushing authoritative items via sync()
92
+ refetch.mockImplementation(async () => {
93
+ col.sync(serverItems);
94
+ });
95
+
96
+ await col.remove(0);
97
+
98
+ expect(currentItems(col)).toEqual(serverItems);
99
+ });
100
+
101
+ it("is a no-op for an out-of-range index", async () => {
102
+ const { col, refetch } = makeCollection([itemWithRemove]);
103
+
104
+ await col.remove(5);
105
+
106
+ expect(handleAction).not.toHaveBeenCalled();
107
+ expect(refetch).not.toHaveBeenCalled();
108
+ expect(currentItems(col)).toEqual([itemWithRemove]);
109
+ });
110
+
111
+ it("skips dispatch (no throw) when no matching alias exists", async () => {
112
+ const { col, refetch } = makeCollection([itemPlain, itemWithRemove]);
113
+
114
+ await col.remove(0); // itemPlain has no entryActions
115
+
116
+ expect(handleAction).not.toHaveBeenCalled();
117
+ // still optimistic-removes and refetches (server is source of truth)
118
+ expect(refetch).toHaveBeenCalledTimes(1);
119
+ expect(currentItems(col)).toEqual([itemWithRemove]);
120
+ });
121
+ });
122
+
123
+ describe("move", () => {
124
+ it("optimistically reorders and dispatches reorder_item with itemIds", async () => {
125
+ handleAction.mockResolvedValue("success");
126
+
127
+ const itemA: MenuItem = {
128
+ id: "2",
129
+ title: "B",
130
+ entryActions: [
131
+ { button: { alias: "reorder_item" }, actions: [removeAction] },
132
+ ],
133
+ };
134
+
135
+ const moved: MenuItem = {
136
+ id: "3",
137
+ title: "C",
138
+ entryActions: [
139
+ { button: { alias: "reorder_item" }, actions: [removeAction] },
140
+ ],
141
+ };
142
+
143
+ const { col, refetch } = makeCollection([moved, itemA]);
144
+
145
+ await col.move(["2", "3"]);
146
+
147
+ expect(currentItems(col)).toEqual([itemA, moved]);
148
+
149
+ expect(handleAction).toHaveBeenCalledWith(
150
+ {
151
+ ...removeAction,
152
+ options: {
153
+ ...removeAction.options,
154
+ itemIds: ["2", "3"],
155
+ data: {
156
+ ...removeAction.options?.data,
157
+ itemIds: ["2", "3"],
158
+ },
159
+ },
160
+ },
161
+ { entry: itemA }
162
+ );
163
+
164
+ expect(refetch).toHaveBeenCalledTimes(1);
165
+ });
166
+
167
+ it("rolls back on failure", async () => {
168
+ handleAction.mockRejectedValue(new Error("nope"));
169
+
170
+ const itemA: MenuItem = {
171
+ id: "2",
172
+ title: "B",
173
+ entryActions: [
174
+ { button: { alias: "reorder_item" }, actions: [removeAction] },
175
+ ],
176
+ };
177
+
178
+ const moved: MenuItem = {
179
+ id: "3",
180
+ title: "C",
181
+ entryActions: [
182
+ { button: { alias: "reorder_item" }, actions: [removeAction] },
183
+ ],
184
+ };
185
+
186
+ const { col } = makeCollection([moved, itemA]);
187
+
188
+ await col.move(["2", "3"]);
189
+
190
+ expect(currentItems(col)).toEqual([moved, itemA]);
191
+ });
192
+
193
+ it("is a no-op for empty or non-existent IDs", async () => {
194
+ const { col, refetch } = makeCollection([itemWithRemove, itemPlain]);
195
+
196
+ await col.move([]);
197
+
198
+ expect(refetch).not.toHaveBeenCalled();
199
+ expect(currentItems(col)).toEqual([itemWithRemove, itemPlain]);
200
+ });
201
+ });
202
+
203
+ describe("sync", () => {
204
+ it("replaces the current items with the provided list", () => {
205
+ const { col } = makeCollection([itemWithRemove]);
206
+ const next: MenuItem[] = [{ id: "7", title: "next" }];
207
+
208
+ col.sync(next);
209
+
210
+ expect(currentItems(col)).toEqual(next);
211
+ });
212
+
213
+ it("suppresses sync updates while a local mutation is in-flight", async () => {
214
+ let resolveAction: () => void = () => {};
215
+
216
+ handleAction.mockImplementation(
217
+ () =>
218
+ new Promise<void>((resolve) => {
219
+ resolveAction = resolve;
220
+ })
221
+ );
222
+
223
+ const { col } = makeCollection([itemWithRemove, itemPlain]);
224
+ const staleServerItems: MenuItem[] = [{ id: "stale", title: "Stale" }];
225
+
226
+ // Initiate optimistic removal (mutation in-flight)
227
+ const removePromise = col.remove(0);
228
+
229
+ // Verify optimistic state applied locally
230
+ expect(currentItems(col)).toEqual([itemPlain]);
231
+
232
+ // Attempt sync while mutation is in-flight
233
+ col.sync(staleServerItems);
234
+
235
+ // Verify sync was suppressed and optimistic state remained
236
+ expect(currentItems(col)).toEqual([itemPlain]);
237
+
238
+ // Resolve the in-flight mutation
239
+ resolveAction();
240
+ await removePromise;
241
+
242
+ // After mutation completes, sync applies normally
243
+ const freshServerItems: MenuItem[] = [{ id: "fresh", title: "Fresh" }];
244
+ col.sync(freshServerItems);
245
+
246
+ expect(currentItems(col)).toEqual(freshServerItems);
247
+ });
248
+ });
249
+
250
+ describe("deleteCollection", () => {
251
+ it("optimistically clears items and calls refetch", async () => {
252
+ handleAction.mockResolvedValue("success");
253
+ const { col, refetch } = makeCollection([itemWithRemove, itemPlain]);
254
+
255
+ await col.deleteCollection();
256
+
257
+ expect(currentItems(col)).toEqual([]);
258
+ expect(refetch).toHaveBeenCalledTimes(1);
259
+ });
260
+
261
+ it("dispatches aliased action when first item contains delete_collection alias", async () => {
262
+ handleAction.mockResolvedValue("success");
263
+
264
+ const deleteAction = {
265
+ type: "sendCloudEvent",
266
+ options: { subject: "delete_collection" },
267
+ };
268
+
269
+ const itemWithDelete: MenuItem = {
270
+ id: "100",
271
+ title: "Collection Item",
272
+ entryActions: [
273
+ {
274
+ button: { alias: "delete_collection" },
275
+ actions: [deleteAction],
276
+ },
277
+ ],
278
+ };
279
+
280
+ const { col, refetch } = makeCollection([itemWithDelete]);
281
+
282
+ await col.deleteCollection();
283
+
284
+ expect(handleAction).toHaveBeenCalledWith(deleteAction, {
285
+ entry: itemWithDelete,
286
+ });
287
+
288
+ expect(refetch).toHaveBeenCalledTimes(1);
289
+ expect(currentItems(col)).toEqual([]);
290
+ });
291
+ });
292
+
293
+ describe("add", () => {
294
+ it("dispatches actions from dynamicCollection.events.add and refetches", async () => {
295
+ handleAction.mockResolvedValue("success");
296
+
297
+ const addAction = {
298
+ type: "sendCloudEvent",
299
+ options: {
300
+ url: "https://x/cloud-events",
301
+ type: "com.applicaster.collection.create.v1",
302
+ subject: "create_collection",
303
+ },
304
+ };
305
+
306
+ const content = {
307
+ items: [],
308
+ dynamicCollection: {
309
+ operations: ["add"],
310
+ postUrl: "https://x/cloud-events",
311
+ events: {
312
+ add: [addAction],
313
+ },
314
+ },
315
+ } as any;
316
+
317
+ const refetch = jest.fn().mockResolvedValue(undefined);
318
+ const col = new RemoteEditableCollection(content, refetch);
319
+
320
+ await col.add();
321
+
322
+ expect(handleAction).toHaveBeenCalledWith(addAction);
323
+ expect(refetch).toHaveBeenCalledTimes(1);
324
+ });
325
+ });
326
+ });
@@ -1,4 +1,4 @@
1
- import { modalStore } from "../";
1
+ import { modalStore, getMenuItemActions } from "../";
2
2
 
3
3
  describe("Modal State", () => {
4
4
  it("should have initial state", () => {
@@ -48,4 +48,33 @@ describe("Modal State", () => {
48
48
  });
49
49
  });
50
50
  });
51
+
52
+ describe("getMenuItemActions", () => {
53
+ it("returns actions array when actions is provided", () => {
54
+ const actions = [{ type: "action1" }, { type: "action2" }];
55
+ const item = { id: "1", title: "Item", actions };
56
+
57
+ expect(getMenuItemActions(item)).toEqual(actions);
58
+ });
59
+
60
+ it("returns [action] when single action is provided", () => {
61
+ const action = { type: "action1" };
62
+ const item = { id: "1", title: "Item", action };
63
+
64
+ expect(getMenuItemActions(item)).toEqual([action]);
65
+ });
66
+
67
+ it("returns action when action is passed as an array", () => {
68
+ const actions = [{ type: "action1" }];
69
+ const item = { id: "1", title: "Item", action: actions as any };
70
+
71
+ expect(getMenuItemActions(item)).toEqual(actions);
72
+ });
73
+
74
+ it("returns empty array when no action or actions are provided", () => {
75
+ expect(getMenuItemActions({ id: "1", title: "Item" })).toEqual([]);
76
+ expect(getMenuItemActions(null)).toEqual([]);
77
+ expect(getMenuItemActions(undefined)).toEqual([]);
78
+ });
79
+ });
51
80
  });