@applicaster/zapp-react-native-ui-components 15.0.0-rc.99 → 16.0.0-alpha.6593152532

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 (138) hide show
  1. package/Components/Cell/TvOSCellComponent.tsx +1 -3
  2. package/Components/GeneralContentScreen/GeneralContentScreen.tsx +39 -28
  3. package/Components/GeneralContentScreen/__tests__/GeneralContentScreen.test.tsx +104 -0
  4. package/Components/GeneralContentScreen/utils/__tests__/getScreenDataSource.test.ts +19 -0
  5. package/Components/GeneralContentScreen/utils/getScreenDataSource.ts +9 -0
  6. package/Components/HandlePlayable/HandlePlayable.tsx +16 -29
  7. package/Components/HandlePlayable/utils.ts +31 -0
  8. package/Components/HookRenderer/HookRenderer.tsx +40 -10
  9. package/Components/HookRenderer/__tests__/HookRenderer.test.tsx +60 -0
  10. package/Components/Layout/TV/NavBarContainer.tsx +1 -10
  11. package/Components/Layout/TV/__tests__/__snapshots__/NavBarContainer.test.tsx.snap +7 -12
  12. package/Components/Layout/TV/__tests__/__snapshots__/ScreenContainer.test.tsx.snap +7 -12
  13. package/Components/Layout/TV/__tests__/__snapshots__/index.test.tsx.snap +5 -0
  14. package/Components/MasterCell/CONFIG_BUILDER_TO_REACT_COMPONENT.md +144 -0
  15. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/model.test.ts +80 -0
  16. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/placement.test.ts +187 -0
  17. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/selectors.test.ts +45 -0
  18. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/__tests__/style.test.ts +49 -0
  19. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/components/ActionButtonController.tsx +165 -0
  20. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/components/__tests__/ActionButtonController.test.tsx +405 -0
  21. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/components/index.ts +1 -0
  22. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/model.ts +47 -0
  23. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/placement.ts +170 -0
  24. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/selectors.ts +26 -0
  25. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/style.ts +29 -0
  26. package/Components/MasterCell/DefaultComponents/ActionButtonsCore/types.ts +37 -0
  27. package/Components/MasterCell/DefaultComponents/Button.tsx +0 -15
  28. package/Components/MasterCell/DefaultComponents/ButtonContainerView/components/HorizontalSeparator.tsx +8 -0
  29. package/Components/MasterCell/DefaultComponents/ButtonContainerView/index.tsx +15 -0
  30. package/Components/MasterCell/DefaultComponents/ButtonContainerView/index.tv.android.tsx +58 -0
  31. package/Components/MasterCell/DefaultComponents/{tv/ButtonContainerView/index.tsx → ButtonContainerView/index.tv.tsx} +3 -11
  32. package/Components/MasterCell/DefaultComponents/ButtonContainerView/index.web.ts +1 -0
  33. package/Components/MasterCell/DefaultComponents/ButtonContainerView/types.ts +40 -0
  34. package/Components/MasterCell/DefaultComponents/DataProvider/index.tsx +163 -0
  35. package/Components/MasterCell/DefaultComponents/FocusableView/index.android.tsx +2 -23
  36. package/Components/MasterCell/DefaultComponents/FocusableView/index.tsx +4 -22
  37. package/Components/MasterCell/DefaultComponents/Image/Image.android.tsx +3 -1
  38. package/Components/MasterCell/DefaultComponents/LiveImage/__tests__/prepareEntry.test.ts +352 -0
  39. package/Components/MasterCell/DefaultComponents/LiveImage/executePreloadHooks.ts +136 -0
  40. package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +33 -16
  41. package/Components/MasterCell/DefaultComponents/PressableView.tsx +34 -0
  42. package/Components/MasterCell/DefaultComponents/Text/hooks/useText.ts +11 -0
  43. package/Components/MasterCell/DefaultComponents/Text/index.tsx +2 -6
  44. package/Components/MasterCell/DefaultComponents/__tests__/DataProvider.test.tsx +141 -0
  45. package/Components/MasterCell/DefaultComponents/index.ts +9 -3
  46. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/ActionButton.tsx +135 -0
  47. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/Asset.ts +33 -0
  48. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/AssetComponent.tsx +22 -0
  49. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/Button.ts +125 -0
  50. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/Spacer.ts +16 -0
  51. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/TextLabel.ts +67 -0
  52. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/TextLabelsContainer.ts +37 -0
  53. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/PressableView.test.tsx +393 -0
  54. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/builders.test.ts +141 -0
  55. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/index.test.ts +343 -0
  56. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/helpers.ts +105 -0
  57. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/index.ts +122 -0
  58. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/utils/__tests__/insertButtons.test.ts +118 -0
  59. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/utils/index.ts +238 -0
  60. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/Asset.ts +4 -18
  61. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/Button.ts +24 -73
  62. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/TextLabelsContainer.ts +37 -18
  63. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/TvActionButton.tsx +27 -0
  64. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/__tests__/index.test.ts +89 -0
  65. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/__tests__/renderedTree.test.tsx +231 -0
  66. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/index.ts +47 -52
  67. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/__tests__/getPluginIdentifier.test.ts +35 -171
  68. package/Components/MasterCell/DefaultComponents/tv/TvActionButtons/utils/index.ts +98 -145
  69. package/Components/MasterCell/MappingFunctions/index.js +3 -2
  70. package/Components/MasterCell/README.md +4 -0
  71. package/Components/MasterCell/__tests__/__snapshots__/dataAdapter.test.js.snap +24 -0
  72. package/Components/MasterCell/__tests__/configInflater.test.js +1 -0
  73. package/Components/MasterCell/__tests__/elementMapper.test.js +46 -0
  74. package/Components/MasterCell/dataAdapter.ts +4 -1
  75. package/Components/MasterCell/elementMapper.tsx +52 -7
  76. package/Components/MasterCell/utils/__tests__/cloneChildrenWithIds.test.tsx +43 -0
  77. package/Components/MasterCell/utils/__tests__/useFilterChildren.test.tsx +80 -0
  78. package/Components/MasterCell/utils/index.ts +85 -15
  79. package/Components/Navigator/StackNavigator.tsx +6 -0
  80. package/Components/PlayerContainer/PlayerContainer.tsx +2 -19
  81. package/Components/PreloaderWrapper/__tests__/index.test.tsx +26 -0
  82. package/Components/PreloaderWrapper/index.tsx +15 -0
  83. package/Components/River/ComponentsMap/ComponentsMap.tsx +2 -16
  84. package/Components/River/RefreshControl.tsx +19 -82
  85. package/Components/River/River.tsx +9 -82
  86. package/Components/River/RiverItem.tsx +26 -20
  87. package/Components/River/hooks/__tests__/usePullToRefresh.test.ts +132 -0
  88. package/Components/River/hooks/index.ts +1 -0
  89. package/Components/River/hooks/usePullToRefresh.ts +51 -0
  90. package/Components/Screen/__tests__/Screen.test.tsx +1 -0
  91. package/Components/Screen/hooks.ts +73 -3
  92. package/Components/Screen/index.tsx +7 -1
  93. package/Components/ScreenFeedLoader/ScreenFeedLoader.tsx +46 -0
  94. package/Components/ScreenFeedLoader/__tests__/ScreenFeedLoader.test.tsx +94 -0
  95. package/Components/ScreenFeedLoader/index.ts +1 -0
  96. package/Components/ScreenResolver/__tests__/screenResolver.test.js +24 -0
  97. package/Components/ScreenResolver/hooks/index.ts +3 -0
  98. package/Components/ScreenResolver/hooks/useGetComponent.ts +15 -0
  99. package/Components/ScreenResolver/hooks/useScreenComponentResolver.tsx +90 -0
  100. package/Components/ScreenResolver/index.tsx +15 -117
  101. package/Components/ScreenResolver/utils/__tests__/getScreenTypeProps.test.ts +45 -0
  102. package/Components/ScreenResolver/utils/getScreenTypeProps.ts +43 -0
  103. package/Components/ScreenResolver/utils/index.ts +1 -0
  104. package/Components/ScreenResolver/withDefaultScreenContext.tsx +16 -0
  105. package/Components/ScreenResolverFeedProvider/ScreenResolverFeedProvider.tsx +25 -0
  106. package/Components/ScreenResolverFeedProvider/__tests__/ScreenResolverFeedProvider.test.tsx +44 -0
  107. package/Components/ScreenResolverFeedProvider/index.ts +1 -0
  108. package/Components/ScreenRevealManager/withScreenRevealManager.tsx +4 -1
  109. package/Components/TopCutoffOverlay/__tests__/TopCutoffOverlay.test.tsx +201 -0
  110. package/Components/TopCutoffOverlay/hooks/__tests__/useMarginTop.test.ts +130 -0
  111. package/Components/TopCutoffOverlay/hooks/index.ts +1 -0
  112. package/Components/TopCutoffOverlay/hooks/useMarginTop.ts +59 -0
  113. package/Components/TopCutoffOverlay/index.tsx +55 -0
  114. package/Components/Transitioner/Scene.tsx +9 -15
  115. package/Components/VideoLive/LiveImageManager.ts +199 -54
  116. package/Components/VideoLive/PlayerLiveImageComponent.tsx +31 -33
  117. package/Components/VideoLive/__tests__/PlayerLiveImageComponent.test.tsx +2 -17
  118. package/Components/Viewport/ViewportAware/__tests__/viewportAware.test.js +0 -2
  119. package/Components/Viewport/ViewportAware/index.tsx +16 -7
  120. package/Components/ZappUIComponent/index.tsx +12 -6
  121. package/Components/default-cell-renderer/viewTrees/mobile/index.ts +0 -3
  122. package/Components/index.js +1 -1
  123. package/Contexts/ScreenContext/__tests__/index.test.tsx +57 -0
  124. package/Contexts/ScreenContext/index.tsx +46 -1
  125. package/Contexts/ZappPipesContext/ZappPipesContextFactory.tsx +18 -7
  126. package/Decorators/ZappPipesDataConnector/ResolverSelector.tsx +25 -7
  127. package/Decorators/ZappPipesDataConnector/__tests__/ResolverSelector.test.tsx +212 -5
  128. package/Decorators/ZappPipesDataConnector/__tests__/UrlFeedResolver.test.tsx +39 -21
  129. package/Decorators/ZappPipesDataConnector/resolvers/UrlFeedResolver.tsx +18 -7
  130. package/package.json +5 -5
  131. package/Components/MasterCell/DefaultComponents/Text/utils/__tests__/withAdjustedLineHeight.test.ts +0 -46
  132. package/Components/MasterCell/DefaultComponents/Text/utils/index.ts +0 -21
  133. package/Components/MasterCell/DefaultComponents/tv/ButtonContainerView/index.android.tsx +0 -135
  134. package/Components/MasterCell/DefaultComponents/tv/ButtonContainerView/types.ts +0 -25
  135. package/Components/PlayerContainer/ErrorDisplay/ErrorDisplay.tsx +0 -57
  136. package/Components/PlayerContainer/ErrorDisplay/index.ts +0 -9
  137. package/Components/PlayerContainer/useRestrictMobilePlayback.tsx +0 -101
  138. /package/Components/HookRenderer/{index.tsx → index.ts} +0 -0
@@ -0,0 +1,405 @@
1
+ import React from "react";
2
+ import { Text } from "react-native";
3
+ import { render, fireEvent } from "@testing-library/react-native";
4
+ import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
5
+
6
+ import { ActionButtonController } from "../ActionButtonController";
7
+
8
+ jest.mock("@applicaster/zapp-react-native-utils/reactHooks/actions", () => ({
9
+ useActions: jest.fn(),
10
+ }));
11
+
12
+ const mockUseActions = useActions as jest.Mock;
13
+
14
+ const entry = { id: "entry-1" } as ZappEntry;
15
+
16
+ describe("ActionButtonController", () => {
17
+ beforeEach(() => {
18
+ jest.clearAllMocks();
19
+ });
20
+
21
+ it("returns null when action is unavailable", () => {
22
+ const actionContext = {
23
+ isActionAvailable: jest.fn(() => false),
24
+ initialEntryState: jest.fn(),
25
+ };
26
+
27
+ mockUseActions.mockReturnValue(actionContext);
28
+
29
+ const child = jest.fn(() => <Text testID="content">content</Text>);
30
+
31
+ const { queryByTestId } = render(
32
+ <ActionButtonController
33
+ action={{ identifier: "navigation_action" }}
34
+ entry={entry}
35
+ >
36
+ {child}
37
+ </ActionButtonController>
38
+ );
39
+
40
+ expect(queryByTestId("content")).toBeNull();
41
+ expect(child).not.toHaveBeenCalled();
42
+ expect(actionContext.isActionAvailable).toHaveBeenCalledWith(entry);
43
+ });
44
+
45
+ it("passes action context, action state and press handler to children", () => {
46
+ const actionContext = {
47
+ isActionAvailable: jest.fn(() => true),
48
+ initialEntryState: jest.fn(() => ({ active: true })),
49
+ invokeAction: jest.fn(),
50
+ addListener: jest.fn(() => jest.fn()),
51
+ };
52
+
53
+ mockUseActions.mockReturnValue(actionContext);
54
+
55
+ const child = jest.fn(({ actionState, onPress, isActive }) => (
56
+ <Text testID="content" onPress={onPress}>
57
+ {String(actionState?.active)}-{String(isActive)}
58
+ </Text>
59
+ ));
60
+
61
+ const { getByTestId } = render(
62
+ <ActionButtonController
63
+ action={{ identifier: "navigation_action" }}
64
+ entry={entry}
65
+ >
66
+ {child}
67
+ </ActionButtonController>
68
+ );
69
+
70
+ expect(actionContext.initialEntryState).toHaveBeenCalledWith(entry);
71
+
72
+ expect(child).toHaveBeenCalledWith(
73
+ expect.objectContaining({
74
+ actionContext,
75
+ actionState: { active: true },
76
+ entry,
77
+ isActive: true,
78
+ onPress: expect.any(Function),
79
+ })
80
+ );
81
+
82
+ fireEvent.press(getByTestId("content"));
83
+
84
+ expect(actionContext.invokeAction).toHaveBeenCalledWith(
85
+ entry,
86
+ expect.objectContaining({
87
+ updateState: expect.any(Function),
88
+ })
89
+ );
90
+ });
91
+
92
+ it("updates state from addListener", () => {
93
+ let listener;
94
+
95
+ const actionContext = {
96
+ isActionAvailable: jest.fn(() => true),
97
+ initialEntryState: jest.fn(() => ({ active: false })),
98
+ addListener: jest.fn((_entryId, callback) => {
99
+ listener = callback;
100
+
101
+ return jest.fn();
102
+ }),
103
+ invokeAction: jest.fn(),
104
+ };
105
+
106
+ mockUseActions.mockReturnValue(actionContext);
107
+
108
+ const { getByText } = render(
109
+ <ActionButtonController
110
+ action={{ identifier: "navigation_action" }}
111
+ entry={entry}
112
+ >
113
+ {({ actionState }) => <Text>{String(actionState?.active)}</Text>}
114
+ </ActionButtonController>
115
+ );
116
+
117
+ expect(getByText("false")).toBeTruthy();
118
+
119
+ listener({ active: true });
120
+
121
+ expect(getByText("true")).toBeTruthy();
122
+ });
123
+
124
+ it("subscribes via addListener and unsubscribes on unmount", () => {
125
+ const unsubscribe = jest.fn();
126
+
127
+ const actionContext = {
128
+ isActionAvailable: jest.fn(() => true),
129
+ initialEntryState: jest.fn(() => ({ active: false })),
130
+ addListener: jest.fn(() => unsubscribe),
131
+ };
132
+
133
+ mockUseActions.mockReturnValue(actionContext);
134
+
135
+ const { unmount } = render(
136
+ <ActionButtonController
137
+ action={{ identifier: "navigation_action" }}
138
+ entry={entry}
139
+ >
140
+ {() => <Text testID="content">content</Text>}
141
+ </ActionButtonController>
142
+ );
143
+
144
+ expect(actionContext.addListener).toHaveBeenCalledWith(
145
+ String(entry.id),
146
+ expect.any(Function)
147
+ );
148
+
149
+ unmount();
150
+
151
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
152
+ });
153
+
154
+ it("subscribes via addListeners and unsubscribes on unmount when addListener is missing", () => {
155
+ const unsubscribe = jest.fn();
156
+
157
+ const actionContext = {
158
+ isActionAvailable: jest.fn(() => true),
159
+ addListeners: jest.fn(() => unsubscribe),
160
+ initialEntryState: undefined,
161
+ };
162
+
163
+ mockUseActions.mockReturnValue(actionContext);
164
+
165
+ const { unmount } = render(
166
+ <ActionButtonController
167
+ action={{ identifier: "navigation_action" }}
168
+ entry={entry}
169
+ >
170
+ {() => <Text testID="content">content</Text>}
171
+ </ActionButtonController>
172
+ );
173
+
174
+ expect(actionContext.addListeners).toHaveBeenCalledWith(
175
+ expect.any(Function)
176
+ );
177
+
178
+ unmount();
179
+
180
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
181
+ });
182
+
183
+ it("subscribes when action context becomes available after initial render", () => {
184
+ const actionContext = {
185
+ isActionAvailable: jest.fn(() => true),
186
+ initialEntryState: jest.fn(() => ({ active: false })),
187
+ addListener: jest.fn(() => jest.fn()),
188
+ invokeAction: jest.fn(),
189
+ };
190
+
191
+ mockUseActions
192
+ .mockReturnValueOnce(undefined)
193
+ .mockReturnValue(actionContext);
194
+
195
+ const { rerender } = render(
196
+ <ActionButtonController
197
+ action={{ identifier: "navigation_action" }}
198
+ entry={entry}
199
+ >
200
+ {() => <Text testID="content">content</Text>}
201
+ </ActionButtonController>
202
+ );
203
+
204
+ expect(actionContext.addListener).not.toHaveBeenCalled();
205
+
206
+ rerender(
207
+ <ActionButtonController
208
+ action={{ identifier: "navigation_action" }}
209
+ entry={entry}
210
+ >
211
+ {() => <Text testID="content">content</Text>}
212
+ </ActionButtonController>
213
+ );
214
+
215
+ expect(actionContext.addListener).toHaveBeenCalledWith(
216
+ String(entry.id),
217
+ expect.any(Function)
218
+ );
219
+ });
220
+
221
+ it("falls back to legacy toggle behavior without initialEntryState", () => {
222
+ const addFavourite = jest.fn();
223
+ const removeFavourite = jest.fn();
224
+
225
+ const actionContext = {
226
+ state: [entry],
227
+ masterCell: {
228
+ isSelected: jest.fn(() => true),
229
+ },
230
+ addFavourite,
231
+ removeFavourite,
232
+ isActionAvailable: jest.fn(() => true),
233
+ addListeners: jest.fn(() => jest.fn()),
234
+ };
235
+
236
+ mockUseActions.mockReturnValue(actionContext);
237
+
238
+ const { getByTestId } = render(
239
+ <ActionButtonController
240
+ action={{ identifier: "navigation_action" }}
241
+ entry={entry}
242
+ >
243
+ {({ onPress, isActive }) => (
244
+ <Text testID="content" onPress={onPress}>
245
+ {String(isActive)}
246
+ </Text>
247
+ )}
248
+ </ActionButtonController>
249
+ );
250
+
251
+ expect(getByTestId("content").props.children).toBe("true");
252
+
253
+ fireEvent.press(getByTestId("content"));
254
+
255
+ expect(removeFavourite).toHaveBeenCalledWith(entry);
256
+ expect(addFavourite).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it("uses legacy addFavourite when item is not selected", () => {
260
+ const addFavourite = jest.fn();
261
+ const removeFavourite = jest.fn();
262
+
263
+ const actionContext = {
264
+ state: [],
265
+ masterCell: {
266
+ isSelected: jest.fn(() => false),
267
+ },
268
+ addFavourite,
269
+ removeFavourite,
270
+ isActionAvailable: jest.fn(() => true),
271
+ addListeners: jest.fn(() => jest.fn()),
272
+ };
273
+
274
+ mockUseActions.mockReturnValue(actionContext);
275
+
276
+ const { getByTestId } = render(
277
+ <ActionButtonController
278
+ action={{ identifier: "navigation_action" }}
279
+ entry={entry}
280
+ >
281
+ {({ onPress, isActive }) => (
282
+ <Text testID="content" onPress={onPress}>
283
+ {String(isActive)}
284
+ </Text>
285
+ )}
286
+ </ActionButtonController>
287
+ );
288
+
289
+ expect(getByTestId("content").props.children).toBe("false");
290
+
291
+ fireEvent.press(getByTestId("content"));
292
+
293
+ expect(addFavourite).toHaveBeenCalledWith(entry);
294
+ expect(removeFavourite).not.toHaveBeenCalled();
295
+ });
296
+
297
+ it("prefers invokeAction over legacy favourites handlers in legacy mode", () => {
298
+ const invokeAction = jest.fn();
299
+ const addFavourite = jest.fn();
300
+ const removeFavourite = jest.fn();
301
+
302
+ const actionContext = {
303
+ state: [],
304
+ masterCell: {
305
+ isSelected: jest.fn(() => false),
306
+ },
307
+ invokeAction,
308
+ addFavourite,
309
+ removeFavourite,
310
+ isActionAvailable: jest.fn(() => true),
311
+ addListeners: jest.fn(() => jest.fn()),
312
+ };
313
+
314
+ mockUseActions.mockReturnValue(actionContext);
315
+
316
+ const { getByTestId } = render(
317
+ <ActionButtonController
318
+ action={{ identifier: "navigation_action" }}
319
+ entry={entry}
320
+ >
321
+ {({ onPress }) => (
322
+ <Text testID="content" onPress={onPress}>
323
+ press
324
+ </Text>
325
+ )}
326
+ </ActionButtonController>
327
+ );
328
+
329
+ fireEvent.press(getByTestId("content"));
330
+
331
+ expect(invokeAction).toHaveBeenCalledWith(entry);
332
+ expect(addFavourite).not.toHaveBeenCalled();
333
+ expect(removeFavourite).not.toHaveBeenCalled();
334
+ });
335
+
336
+ it("calls resolved legacy toggleAction onPress when invokeAction is missing", async () => {
337
+ const addFavourite = jest.fn(() => Promise.resolve("added"));
338
+ const removeFavourite = jest.fn();
339
+
340
+ const actionContext = {
341
+ state: [],
342
+ masterCell: {
343
+ isSelected: jest.fn(() => false),
344
+ },
345
+ addFavourite,
346
+ removeFavourite,
347
+ isActionAvailable: jest.fn(() => true),
348
+ addListeners: jest.fn(() => jest.fn()),
349
+ };
350
+
351
+ mockUseActions.mockReturnValue(actionContext);
352
+
353
+ let capturedOnPress: (() => Promise<unknown> | unknown) | undefined;
354
+
355
+ render(
356
+ <ActionButtonController
357
+ action={{ identifier: "navigation_action" }}
358
+ entry={entry}
359
+ >
360
+ {({ onPress }) => {
361
+ capturedOnPress = onPress;
362
+
363
+ return <Text testID="content">press</Text>;
364
+ }}
365
+ </ActionButtonController>
366
+ );
367
+
368
+ await capturedOnPress?.();
369
+
370
+ expect(addFavourite).toHaveBeenCalledWith(entry);
371
+ expect(removeFavourite).not.toHaveBeenCalled();
372
+ });
373
+
374
+ it("wires invokeAction updateState callback to update render state", () => {
375
+ const actionContext = {
376
+ isActionAvailable: jest.fn(() => true),
377
+ initialEntryState: jest.fn(() => ({ active: false })),
378
+ invokeAction: jest.fn((_entry, options) => {
379
+ options.updateState({ active: true });
380
+ }),
381
+ addListener: jest.fn(() => jest.fn()),
382
+ };
383
+
384
+ mockUseActions.mockReturnValue(actionContext);
385
+
386
+ const { getByTestId, getByText } = render(
387
+ <ActionButtonController
388
+ action={{ identifier: "navigation_action" }}
389
+ entry={entry}
390
+ >
391
+ {({ onPress, actionState }) => (
392
+ <Text testID="content" onPress={onPress}>
393
+ {String(actionState?.active)}
394
+ </Text>
395
+ )}
396
+ </ActionButtonController>
397
+ );
398
+
399
+ expect(getByText("false")).toBeTruthy();
400
+
401
+ fireEvent.press(getByTestId("content"));
402
+
403
+ expect(getByText("true")).toBeTruthy();
404
+ });
405
+ });
@@ -0,0 +1 @@
1
+ export { ActionButtonController } from "./ActionButtonController";
@@ -0,0 +1,47 @@
1
+ import {
2
+ getButtonSlotPrefix,
3
+ getEnabledButtonSlots,
4
+ getStylePrefix,
5
+ } from "./selectors";
6
+ import { buildContainerLayout } from "./style";
7
+ import { ActionButtonsModel, BuildActionButtonsModelOptions } from "./types";
8
+
9
+ export const buildActionButtonsModel = ({
10
+ configuration,
11
+ value,
12
+ containerPrefix,
13
+ buttonPrefix,
14
+ maxButtons = 3,
15
+ }: BuildActionButtonsModelOptions): ActionButtonsModel | null => {
16
+ if (!value(`${containerPrefix}_buttons_enabled`)) {
17
+ return null;
18
+ }
19
+
20
+ const enabledSlots = getEnabledButtonSlots(
21
+ configuration,
22
+ buttonPrefix,
23
+ maxButtons
24
+ );
25
+
26
+ if (enabledSlots.length === 0) {
27
+ return null;
28
+ }
29
+
30
+ const container = buildContainerLayout(value, containerPrefix);
31
+
32
+ return {
33
+ enabledSlots,
34
+ buttonsCount: enabledSlots.length,
35
+ container,
36
+ buttons: enabledSlots.map((slot, renderIndex) => ({
37
+ slot,
38
+ renderIndex,
39
+ specificPrefix: getButtonSlotPrefix(buttonPrefix, slot),
40
+ stylePrefix: getStylePrefix({
41
+ slot,
42
+ independentStyles: container.independentStyles,
43
+ buttonPrefix,
44
+ }),
45
+ })),
46
+ };
47
+ };
@@ -0,0 +1,170 @@
1
+ type Label = {
2
+ name?: string;
3
+ elements?: Array<Label | unknown>;
4
+ };
5
+
6
+ type LabelContainer = {
7
+ elements?: Array<Label | unknown>;
8
+ };
9
+
10
+ type LabelExtraContainer = {
11
+ elements?: Array<LabelContainer | unknown>;
12
+ };
13
+
14
+ type InsertOptions = {
15
+ position?: string;
16
+ allowOnTop?: boolean;
17
+ appendWhenMissing?: boolean;
18
+ };
19
+
20
+ const hasLabelName = (value: unknown): value is Label =>
21
+ !!value &&
22
+ typeof value === "object" &&
23
+ typeof (value as Label).name === "string" &&
24
+ (value as Label).name !== "";
25
+
26
+ const withButtons = (
27
+ labelName: string | undefined,
28
+ buttons: unknown,
29
+ labels: Label[]
30
+ ) =>
31
+ labels.reduce<Array<Label | unknown>>((acc, label) => {
32
+ if (!!label?.name && labelName?.includes(label.name)) {
33
+ // handle above_*
34
+ if (labelName?.startsWith("above_")) {
35
+ return [...acc, buttons, label];
36
+ }
37
+
38
+ // handle below_* or plain exact match (default: insert after (covers below_* AND plain names))
39
+ return [...acc, label, buttons];
40
+ }
41
+
42
+ return [...acc, label];
43
+ }, []);
44
+
45
+ const withButtonsInNestedViews = (
46
+ labelName: string | undefined,
47
+ buttons: unknown,
48
+ views: Array<LabelContainer | unknown>
49
+ ) => {
50
+ const hasDirectLabels = views.some((view) => hasLabelName(view));
51
+
52
+ if (hasDirectLabels) {
53
+ return withButtons(labelName, buttons, views as Label[]);
54
+ }
55
+
56
+ return views.map((view) => {
57
+ if (
58
+ !view ||
59
+ typeof view !== "object" ||
60
+ !Array.isArray((view as LabelContainer).elements)
61
+ ) {
62
+ return view;
63
+ }
64
+
65
+ return {
66
+ ...(view as LabelContainer),
67
+ elements: withButtons(
68
+ labelName,
69
+ buttons,
70
+ (view as LabelContainer).elements || []
71
+ ),
72
+ };
73
+ });
74
+ };
75
+
76
+ const containsNestedLabel = (
77
+ labelName: string | undefined,
78
+ labelContainers: LabelExtraContainer[]
79
+ ) =>
80
+ labelContainers.some((labelContainer) =>
81
+ (labelContainer.elements || []).some((view) => {
82
+ if (hasLabelName(view)) {
83
+ return labelName?.includes(view.name) ?? false;
84
+ }
85
+
86
+ if (
87
+ !view ||
88
+ typeof view !== "object" ||
89
+ !Array.isArray((view as LabelContainer).elements)
90
+ ) {
91
+ return false;
92
+ }
93
+
94
+ return ((view as LabelContainer).elements || []).some((label) =>
95
+ hasLabelName(label) ? (labelName?.includes(label.name) ?? false) : false
96
+ );
97
+ })
98
+ );
99
+
100
+ export const insertBetweenLabels = (
101
+ { position, allowOnTop = false, appendWhenMissing = false }: InsertOptions,
102
+ buttons: unknown,
103
+ labels: Label[] = []
104
+ ) => {
105
+ if (buttons == null) {
106
+ return labels;
107
+ }
108
+
109
+ if (allowOnTop && position === "on_top") {
110
+ return [buttons, ...labels];
111
+ }
112
+
113
+ const labelsWithButtons = withButtons(position, buttons, labels);
114
+ const inserted = labelsWithButtons.length !== labels.length;
115
+
116
+ if (!inserted && appendWhenMissing) {
117
+ return [...labels, buttons];
118
+ }
119
+
120
+ return inserted ? labelsWithButtons : [...labels];
121
+ };
122
+
123
+ export const insertBetweenLabelContainers = (
124
+ { position, allowOnTop = false, appendWhenMissing = false }: InsertOptions,
125
+ buttons: unknown,
126
+ labelContainers: LabelExtraContainer[] = []
127
+ ) => {
128
+ if (buttons == null) {
129
+ return labelContainers;
130
+ }
131
+
132
+ if (labelContainers.length === 0) {
133
+ return [buttons];
134
+ }
135
+
136
+ if (allowOnTop && position === "on_top") {
137
+ return labelContainers.map((labelContainer, index) =>
138
+ index === 0
139
+ ? {
140
+ ...labelContainer,
141
+ elements: [buttons, ...(labelContainer.elements || [])],
142
+ }
143
+ : labelContainer
144
+ );
145
+ }
146
+
147
+ const hasMatchingLabel = containsNestedLabel(position, labelContainers);
148
+
149
+ const labelContainersWithButtons = labelContainers.map((labelContainer) => ({
150
+ ...labelContainer,
151
+ elements: withButtonsInNestedViews(
152
+ position,
153
+ buttons,
154
+ labelContainer.elements || []
155
+ ),
156
+ }));
157
+
158
+ if (!hasMatchingLabel && appendWhenMissing) {
159
+ return labelContainers.map((labelContainer, index) =>
160
+ index === labelContainers.length - 1
161
+ ? {
162
+ ...labelContainer,
163
+ elements: [...(labelContainer.elements || []), buttons],
164
+ }
165
+ : labelContainer
166
+ );
167
+ }
168
+
169
+ return hasMatchingLabel ? labelContainersWithButtons : labelContainers;
170
+ };
@@ -0,0 +1,26 @@
1
+ export const getButtonSlotPrefix = (buttonPrefix: string, slot: number) =>
2
+ `${buttonPrefix}_${slot}`;
3
+
4
+ export const getEnabledButtonSlots = (
5
+ configuration: Record<string, unknown>,
6
+ buttonPrefix: string,
7
+ maxButtons = 3
8
+ ): number[] =>
9
+ Array.from({ length: maxButtons }, (_, index) => index + 1).filter((slot) =>
10
+ Boolean(
11
+ configuration[`${getButtonSlotPrefix(buttonPrefix, slot)}_button_enabled`]
12
+ )
13
+ );
14
+
15
+ export const getStylePrefix = ({
16
+ slot,
17
+ independentStyles,
18
+ buttonPrefix,
19
+ }: {
20
+ slot: number;
21
+ independentStyles: boolean;
22
+ buttonPrefix: string;
23
+ }) =>
24
+ independentStyles
25
+ ? getButtonSlotPrefix(buttonPrefix, slot)
26
+ : getButtonSlotPrefix(buttonPrefix, 1);
@@ -0,0 +1,29 @@
1
+ import { mapSelfAlignment } from "@applicaster/zapp-react-native-utils/cellUtils";
2
+ import { toNumberWithDefaultZero } from "@applicaster/zapp-react-native-utils/numberUtils";
3
+
4
+ import { ActionButtonsContainerLayout } from "./types";
5
+
6
+ export const getContainerMargins = (
7
+ value: (key: string) => unknown,
8
+ prefix: string
9
+ ) => ({
10
+ top: toNumberWithDefaultZero(value(`${prefix}_margin_top`)),
11
+ right: toNumberWithDefaultZero(value(`${prefix}_margin_right`)),
12
+ bottom: toNumberWithDefaultZero(value(`${prefix}_margin_bottom`)),
13
+ left: toNumberWithDefaultZero(value(`${prefix}_margin_left`)),
14
+ });
15
+
16
+ export const buildContainerLayout = (
17
+ value: (key: string) => unknown,
18
+ prefix: string
19
+ ): ActionButtonsContainerLayout => ({
20
+ horizontalAlign: mapSelfAlignment(value(`${prefix}_align`)),
21
+ margins: getContainerMargins(value, prefix),
22
+ stacking:
23
+ value(`${prefix}_stacking`) === "vertical" ? "vertical" : "horizontal",
24
+ horizontalGutter: toNumberWithDefaultZero(
25
+ value(`${prefix}_horizontal_gutter`)
26
+ ),
27
+ verticalGutter: toNumberWithDefaultZero(value(`${prefix}_vertical_gutter`)),
28
+ independentStyles: Boolean(value(`${prefix}_independent_styles`)),
29
+ });